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/spaces", "/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":"team_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 Team 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 != "team_admin" { t.Fatalf("expected invited user to receive role 'team_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/spaces", "/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 space createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody)) 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/spaces, got %d: %s", rec.Code, rec.Body.String()) } var createdSpace domain.Space _ = json.Unmarshal(rec.Body.Bytes(), &createdSpace) if createdSpace.ID == "" { t.Fatalf("expected created space with ID, got %+v", createdSpace) } // 2. Admin invites normalUser by username "user" addMemberBody := []byte(`{"userId":"user","role":"editor"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody)) 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 space member, got %d: %s", rec.Code, rec.Body.String()) } var newMember domain.SpaceMember _ = json.Unmarshal(rec.Body.Bytes(), &newMember) if newMember.UserID != normalUser.ID { t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID) } // 3. User checks their spaces; the shared space must be listed req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", 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 spaces, got %d", rec.Code) } var userSpaces []domain.Space _ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) found := false for _, s := range userSpaces { if s.ID == createdSpace.ID { found = true break } } if !found { t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces) } // 4. Admin removes user from space req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.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 member, got %d: %s", rec.Code, rec.Body.String()) } }