package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "sync" "testing" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/config" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" "github.com/sandrodach/mindsift/internal/ws" ) func setupTestServer(t *testing.T) (*Server, *storage.Store, string) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test_mindsift.db") store, err := storage.NewStore(dbPath) if err != nil { t.Fatalf("failed to create test store: %v", err) } t.Cleanup(func() { _ = store.Close() }) user, err := store.EnsureDefaultUser() if err != nil { t.Fatalf("failed to ensure test user: %v", err) } _ = store.SeedStarterContent(user) cfg := &config.Config{ Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key", AllowRegistration: false, } hub := ws.NewHub() srv := NewServer(cfg, store, hub, nil) token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName) if err != nil { t.Fatalf("failed to generate token: %v", err) } return srv, store, token } func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db") store, err := storage.NewStore(dbPath) if err != nil { t.Fatalf("failed to create fresh store: %v", err) } defer store.Close() cfg := &config.Config{ Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key", AllowRegistration: false, } srv := NewServer(cfg, store, ws.NewHub(), nil) router := srv.Router() // 1. Fresh DB must report needsSetup = true req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code) } var statusResp struct { NeedsSetup bool `json:"needsSetup"` AllowRegistration bool `json:"allowRegistration"` InstanceName string `json:"instanceName"` } _ = json.Unmarshal(rec.Body.Bytes(), &statusResp) if !statusResp.NeedsSetup { t.Fatalf("expected needsSetup=true on fresh database, got false") } // 2. Complete First-Boot Setup (without sample data) setupBody := []byte(`{ "email": "owner@example.com", "password": "secretpassword", "displayName": "Server Owner", "instanceName": "My MindSift", "allowRegistration": false, "sharingMode": "teams_only", "seedSampleData": false }`) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created from /api/v1/auth/setup, got %d: %s", rec.Code, rec.Body.String()) } var setupRes struct { Token string `json:"token"` User domain.User `json:"user"` } _ = json.Unmarshal(rec.Body.Bytes(), &setupRes) if setupRes.User.Role != "admin" || setupRes.Token == "" { t.Fatalf("expected admin user and token, got %+v", setupRes) } // 3. Verify empty endpoints return [] and never null (preventing React crashes) arrayEndpoints := []string{ "/api/v1/task-lists", "/api/v1/tasks", "/api/v1/organizations", "/api/v1/teams", "/api/v1/lists", } for _, ep := range arrayEndpoints { req = httptest.NewRequest(http.MethodGet, ep, nil) req.Header.Set("Authorization", "Bearer "+setupRes.Token) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET %s returned %d", ep, rec.Code) } trimmed := strings.TrimSpace(rec.Body.String()) if trimmed == "null" || !strings.HasPrefix(trimmed, "[") { t.Fatalf("GET %s returned %q instead of JSON array []", ep, trimmed) } } // 4. Public registration without invite must be rejected (403) regBody := []byte(`{"email":"member@example.com","password":"password123","displayName":"Member"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 Forbidden when registration is disabled, got %d", rec.Code) } // 5. Create an Invite as Admin and register with that Invite Code invBody := []byte(`{"role":"admin","validDays":7}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody)) req.Header.Set("Authorization", "Bearer "+setupRes.Token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created for invite, got %d: %s", rec.Code, rec.Body.String()) } var createdInvite domain.UserInvite _ = json.Unmarshal(rec.Body.Bytes(), &createdInvite) if createdInvite.Code == "" { t.Fatalf("expected invite code, got empty") } regWithInviteBody, _ := json.Marshal(map[string]string{ "email": "invited@example.com", "password": "password123", "displayName": "Invited Admin", "inviteCode": createdInvite.Code, }) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created when registering with valid invite code, got %d: %s", rec.Code, rec.Body.String()) } var invitedRes struct { User domain.User `json:"user"` } _ = json.Unmarshal(rec.Body.Bytes(), &invitedRes) if invitedRes.User.Role != "admin" { t.Fatalf("expected invited user to receive role 'admin', got %q", invitedRes.User.Role) } } func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) { srv, _, token := setupTestServer(t) router := srv.Router() endpoints := []string{ "/api/v1/auth/me", "/api/v1/triage/deck", "/api/v1/lists", "/api/v1/tasks", "/api/v1/documents", } var wg sync.WaitGroup for i := 0; i < 3; i++ { for _, ep := range endpoints { wg.Add(1) go func(path string) { defer wg.Done() req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("GET %s returned %d: %s", path, rec.Code, rec.Body.String()) } }(ep) } } wg.Wait() } func TestQuickDumpToTriageAndDirectTargets(t *testing.T) { srv, store, token := setupTestServer(t) router := srv.Router() // Verify stale token from non-existent user is rejected (401) staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old") req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.Header.Set("Authorization", "Bearer "+staleToken) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 Unauthorized for stale token, got %d", rec.Code) } // 1. Capture Quick Dump (default target: inbox) dumpPayload := map[string]string{ "content": "Schneller Quick Dump Testgedanke #test", "priority": "high", "target": "inbox", } body, _ := json.Marshal(dumpPayload) req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created for Quick Dump, got %d: %s", rec.Code, rec.Body.String()) } var createdDump domain.Thought if err := json.Unmarshal(rec.Body.Bytes(), &createdDump); err != nil { t.Fatalf("failed to decode created dump: %v", err) } if createdDump.Status != domain.StatusInbox { t.Fatalf("expected status 'inbox', got %q", createdDump.Status) } // 2. Capture Direct Task (target: task) taskPayload := map[string]string{ "content": "Direkte Aufgabe ohne Triage", "priority": "urgent", "target": "task", "dueDate": "2026-11-01", } body, _ = json.Marshal(taskPayload) req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created for Direct Task, got %d: %s", rec.Code, rec.Body.String()) } var createdTask domain.Thought _ = json.Unmarshal(rec.Body.Bytes(), &createdTask) if createdTask.Status != domain.StatusConverted || createdTask.ConvertedType != "task" { t.Fatalf("expected converted task, got status=%q type=%q", createdTask.Status, createdTask.ConvertedType) } // 3. Capture Direct List Item (target: list_item) defUser, _ := store.EnsureDefaultUser() lists, err := store.GetAllListsForUser(defUser.ID) if err != nil || len(lists) == 0 { t.Fatalf("expected starter lists, got len=%d err=%v", len(lists), err) } targetListID := lists[0].ID listPayload := map[string]string{ "content": "Frische Haferflocken", "target": "list_item", "targetListId": targetListID, } body, _ = json.Marshal(listPayload) req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created for Direct List Item, got %d: %s", rec.Code, rec.Body.String()) } var createdListItemThought domain.Thought _ = json.Unmarshal(rec.Body.Bytes(), &createdListItemThought) if createdListItemThought.ConvertedType != "list_item" || createdListItemThought.ConvertedRefID == "" { t.Fatalf("expected converted list_item, got %+v", createdListItemThought) } // 4. Swipe Triage card to Task triagePayload := map[string]string{ "action": "keep", "convertTo": "task", } body, _ = json.Marshal(triagePayload) req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for triage action, got %d: %s", rec.Code, rec.Body.String()) } } func TestNewFullFeatures(t *testing.T) { srv, _, token := setupTestServer(t) router := srv.Router() // 1. Test TaskLists: Create & Get taskListPayload := map[string]string{ "title": "Homelab & Docker", "icon": "🐳", "color": "#3b82f6", } body, _ := json.Marshal(taskListPayload) req := httptest.NewRequest(http.MethodPost, "/api/v1/task-lists", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created for TaskList, got %d: %s", rec.Code, rec.Body.String()) } var createdTaskList domain.TaskList _ = json.Unmarshal(rec.Body.Bytes(), &createdTaskList) if createdTaskList.Title != "Homelab & Docker" { t.Fatalf("expected task list title 'Homelab & Docker', got %q", createdTaskList.Title) } req = httptest.NewRequest(http.MethodGet, "/api/v1/task-lists", nil) req.Header.Set("Authorization", "Bearer "+token) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code) } // 2. Test User Settings: Update Multi-Channel Alarms & Profile settingsPayload := map[string]interface{}{ "displayName": "Admin User", "alarmChannels": []string{"email", "discord", "webhook"}, "alarmEmail": "alarms@example.com", "discordWebhookUrl": "https://discord.com/api/webhooks/test/123", "customWebhookUrl": "https://ntfy.sh/my-topic", "notifyBeforeMinutes": 15, } body, _ = json.Marshal(settingsPayload) req = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/settings", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for PATCH /api/v1/auth/settings, got %d: %s", rec.Code, rec.Body.String()) } var updatedUser domain.User _ = json.Unmarshal(rec.Body.Bytes(), &updatedUser) if len(updatedUser.AlarmChannels) != 3 || updatedUser.AlarmEmail != "alarms@example.com" { t.Fatalf("expected 3 alarm channels and alarmEmail, got %+v", updatedUser) } // 3. Test Admin routes (default user is admin) req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil) req.Header.Set("Authorization", "Bearer "+token) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for GET /api/v1/admin/users, got %d: %s", rec.Code, rec.Body.String()) } req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) req.Header.Set("Authorization", "Bearer "+token) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for GET /api/v1/admin/settings, got %d: %s", rec.Code, rec.Body.String()) } // 4. Test Kanban & Task details update taskUpdatePayload := map[string]interface{}{ "kanbanStatus": "in_progress", "notes": "Detailnotizen für die Aufgabe", "taskListId": createdTaskList.ID, } req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader([]byte(`{"content":"Kanban Task Test","target":"task"}`))) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) var taskThought domain.Thought _ = json.Unmarshal(rec.Body.Bytes(), &taskThought) body, _ = json.Marshal(taskUpdatePayload) req = httptest.NewRequest(http.MethodPatch, "/api/v1/thoughts/"+taskThought.ID, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for PATCH /api/v1/thoughts/{id}, got %d: %s", rec.Code, rec.Body.String()) } var updatedThought domain.Thought _ = json.Unmarshal(rec.Body.Bytes(), &updatedThought) if updatedThought.KanbanStatus != "in_progress" || updatedThought.Notes != "Detailnotizen für die Aufgabe" { t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought) } } func TestLoginWithUsernameAndDemoMode(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "demo_api_test.db") store, err := storage.NewStore(dbPath) if err != nil { t.Fatalf("failed to create store: %v", err) } defer store.Close() if err := store.EnsureDemoAccounts(); err != nil { t.Fatalf("failed to seed demo accounts: %v", err) } cfg := &config.Config{ Port: "8080", DatabasePath: dbPath, JWTSecret: "demo-jwt-secret", DemoMode: true, } srv := NewServer(cfg, store, ws.NewHub(), nil) router := srv.Router() // 1. Check auth status reports demoMode: true req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code) } var statusResp struct { NeedsSetup bool `json:"needsSetup"` DemoMode bool `json:"demoMode"` } _ = json.Unmarshal(rec.Body.Bytes(), &statusResp) if !statusResp.DemoMode { t.Fatalf("expected demoMode=true, got %v", statusResp.DemoMode) } if statusResp.NeedsSetup { t.Fatalf("expected needsSetup=false when demo accounts exist, got %v", statusResp.NeedsSetup) } // 2. Login with username "admin" / "admin" loginBody := []byte(`{"email":"admin","password":"admin"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for admin login, got %d: %s", rec.Code, rec.Body.String()) } var loginRes struct { Token string `json:"token"` User domain.User `json:"user"` } _ = json.Unmarshal(rec.Body.Bytes(), &loginRes) if loginRes.Token == "" || loginRes.User.Role != "admin" { t.Fatalf("invalid admin login response: %+v", loginRes) } // 3. Login with username "user" / "user" userLoginBody := []byte(`{"username":"user","password":"user"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(userLoginBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for user login, got %d: %s", rec.Code, rec.Body.String()) } var userLoginRes struct { Token string `json:"token"` User domain.User `json:"user"` } _ = json.Unmarshal(rec.Body.Bytes(), &userLoginRes) if userLoginRes.Token == "" || userLoginRes.User.Role != "member" { t.Fatalf("invalid user login response: %+v", userLoginRes) } // 4. Invalid credentials must return 401 wrongLoginBody := []byte(`{"login":"admin","password":"wrongpassword"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(wrongLoginBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code) } } func TestSpaceMembersDirectInvitationAPI(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "space_members_test.db") store, err := storage.NewStore(dbPath) if err != nil { t.Fatalf("failed to create store: %v", err) } defer store.Close() if err := store.EnsureDemoAccounts(); err != nil { t.Fatalf("failed to seed demo accounts: %v", err) } cfg := &config.Config{ Port: "8080", DatabasePath: dbPath, JWTSecret: "test-secret", } srv := NewServer(cfg, store, ws.NewHub(), nil) router := srv.Router() adminUser, err := store.GetUserByLogin("admin") if err != nil { t.Fatalf("failed to get admin user: %v", err) } normalUser, err := store.GetUserByLogin("user") if err != nil { t.Fatalf("failed to get normal user: %v", err) } adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName) if err != nil { t.Fatalf("failed to generate token: %v", err) } userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName) if err != nil { t.Fatalf("failed to generate token: %v", err) } // 1. Admin creates a list createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created from /api/v1/lists, got %d: %s", rec.Code, rec.Body.String()) } var createdList domain.SharedList _ = json.Unmarshal(rec.Body.Bytes(), &createdList) if createdList.ID == "" { t.Fatalf("expected created list with ID, got %+v", createdList) } // 2. Admin shares list with normalUser by username "user" addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created from add list share, got %d: %s", rec.Code, rec.Body.String()) } var newShare domain.ListShare _ = json.Unmarshal(rec.Body.Bytes(), &newShare) if newShare.TargetID != normalUser.ID { t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID) } // 3. User checks their lists; the shared list must be listed req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil) req.Header.Set("Authorization", "Bearer "+userToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for user lists, got %d", rec.Code) } var userLists []domain.SharedList _ = json.Unmarshal(rec.Body.Bytes(), &userLists) found := false for _, l := range userLists { if l.ID == createdList.ID { found = true break } } if !found { t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists) } // 4. Admin removes user share from list req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil) req.Header.Set("Authorization", "Bearer "+adminToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String()) } // 5. Test sharing list with a team team, err := store.CreateTeam("", "WG Team", "Haushalt") if err != nil { t.Fatalf("failed to create team: %v", err) } _ = store.AddTeamMember(team.ID, normalUser.ID, "member") addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String()) } // User should see the list again because their team was added req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil) req.Header.Set("Authorization", "Bearer "+userToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code) } userLists = nil _ = json.Unmarshal(rec.Body.Bytes(), &userLists) found = false for _, l := range userLists { if l.ID == createdList.ID { found = true break } } if !found { t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists) } } func TestWorkspaceTagsAPI(t *testing.T) { srv, store, _ := setupTestServer(t) router := srv.Router() user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member") token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) // Create document with hashtags blocks := []domain.DocumentBlock{ {ID: "b1", Type: "bullet", Content: "Planung #Sprint12", Level: 0}, {ID: "b2", Type: "bullet", Content: "Subtask 1", Level: 1}, } _, err := store.CreateDocument(user.ID, "", "Sprint Plan", "🚀", "", blocks, nil, false) if err != nil { t.Fatalf("CreateDocument failed: %v", err) } // 1. GET /api/v1/workspace/tags req := httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags", nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String()) } var tagList []domain.TagSummary _ = json.Unmarshal(rec.Body.Bytes(), &tagList) if len(tagList) != 1 || tagList[0].Tag != "sprint12" { t.Fatalf("expected tag 'sprint12', got %+v", tagList) } // 2. GET /api/v1/workspace/tags/Sprint12 req = httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags/Sprint12", nil) req.Header.Set("Authorization", "Bearer "+token) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String()) } var details domain.TagDetails _ = json.Unmarshal(rec.Body.Bytes(), &details) if details.Tag != "sprint12" || details.TotalMatches != 1 || len(details.Documents) != 1 { t.Fatalf("unexpected tag details: %+v", details) } matched := details.Documents[0].MatchedBlocks[0] if matched.RootBlock.ID != "b1" || len(matched.Children) != 1 || matched.Children[0].ID != "b2" { t.Fatalf("unexpected matched blocks or children: %+v", matched) } // 3. Folder endpoints test // Create folder createFolderBody := []byte(`{"path":"TestOrdner"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/folders", bytes.NewReader(createFolderBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created from /api/v1/folders, got %d: %s", rec.Code, rec.Body.String()) } // Get folders req = httptest.NewRequest(http.MethodGet, "/api/v1/folders", nil) req.Header.Set("Authorization", "Bearer "+token) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK from /api/v1/folders, got %d", rec.Code) } var folders []string _ = json.Unmarshal(rec.Body.Bytes(), &folders) foundFolder := false for _, f := range folders { if f == "TestOrdner" { foundFolder = true break } } if !foundFolder { t.Fatalf("expected TestOrdner in folders list, got %v", folders) } // Delete folder req = httptest.NewRequest(http.MethodDelete, "/api/v1/folders?path=TestOrdner", nil) req.Header.Set("Authorization", "Bearer "+token) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code) } } func TestWebhookTriageBadgeAndIngestion(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test_webhook.db") store, err := storage.NewStore(dbPath) if err != nil { t.Fatalf("failed to init store: %v", err) } defer store.Close() cfg := &config.Config{ JWTSecret: "test-jwt-secret-key", AllowRegistration: false, } srv := NewServer(cfg, store, ws.NewHub(), nil) router := srv.Router() // 1. Setup user setupBody := []byte(`{ "email": "smarthome_owner@example.com", "password": "secretpassword", "displayName": "Home User", "instanceName": "MindSift Home", "allowRegistration": false, "seedSampleData": false }`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("setup failed: %s", rec.Body.String()) } var authResp struct { Token string `json:"token"` User *domain.User `json:"user"` } _ = json.Unmarshal(rec.Body.Bytes(), &authResp) // 2. Create API key for user apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook") if err != nil || apiKey == nil || apiKey.RawKey == "" { t.Fatalf("failed to create API key: %v", err) } plainKey := apiKey.RawKey // 3. Post to /api/v1/inbox using X-API-Key without passing "source" inboxBody := []byte(`{ "content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent" }`) req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(inboxBody)) req.Header.Set("X-API-Key", plainKey) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created from /api/v1/inbox with API key, got %d: %s", rec.Code, rec.Body.String()) } var createdThought domain.Thought _ = json.Unmarshal(rec.Body.Bytes(), &createdThought) if createdThought.Source != "webhook" { t.Fatalf("expected created thought to have source='webhook' when called with API key, got '%s'", createdThought.Source) } // 4. Post to /api/v1/webhooks/ingest using ?token=... with message alias webhookBody := []byte(`{ "message": "Waschmaschine im Keller fertig", "priority": "high", "tags": ["automation"] }`) req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/ingest?token="+plainKey, bytes.NewReader(webhookBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created from /api/v1/webhooks/ingest, got %d: %s", rec.Code, rec.Body.String()) } var ingestResp struct { Status string `json:"status"` Thought domain.Thought `json:"thought"` } _ = json.Unmarshal(rec.Body.Bytes(), &ingestResp) if ingestResp.Thought.Source != "webhook" { t.Fatalf("expected ingested webhook thought to have source='webhook', got '%s'", ingestResp.Thought.Source) } if !strings.Contains(ingestResp.Thought.Content, "Waschmaschine") { t.Fatalf("expected thought content to contain 'Waschmaschine', got '%s'", ingestResp.Thought.Content) } }