package handlers import ( "context" "encoding/json" "net/http" "testing" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" ) // ── Project Test Harness ────────────────── type projectHarness struct { *testHarness stores store.Stores userToken string userID string adminToken string adminID string } func setupProjectHarness(t *testing.T) *projectHarness { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { stores = postgres.NewStores(database.TestDB) } r := gin.New() api := r.Group("/api/v1") protected := api.Group("") protected.Use(middleware.Auth(cfg)) projH := NewProjectHandler(stores) protected.GET("/projects", projH.List) protected.POST("/projects", projH.Create) protected.GET("/projects/:id", projH.Get) protected.PUT("/projects/:id", projH.Update) protected.DELETE("/projects/:id", projH.Delete) protected.POST("/projects/:id/channels", projH.AddChannel) protected.DELETE("/projects/:id/channels/:channelId", projH.RemoveChannel) protected.GET("/projects/:id/channels", projH.ListChannels) protected.PUT("/projects/:id/channels/reorder", projH.ReorderChannels) protected.POST("/projects/:id/knowledge-bases", projH.AddKB) protected.DELETE("/projects/:id/knowledge-bases/:kbId", projH.RemoveKB) protected.GET("/projects/:id/knowledge-bases", projH.ListKBs) protected.POST("/projects/:id/notes", projH.AddNote) protected.DELETE("/projects/:id/notes/:noteId", projH.RemoveNote) protected.GET("/projects/:id/notes", projH.ListNotes) // Admin routes admin := api.Group("/admin") admin.Use(middleware.Auth(cfg)) admin.Use(middleware.RequireAdmin()) admin.GET("/projects", projH.AdminList) admin.DELETE("/projects/:id", projH.Delete) userID := database.SeedTestUser(t, "projuser", "projuser@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) userToken := makeToken(userID, "projuser@test.com", "user") adminID := database.SeedTestUser(t, "projadmin", "projadmin@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, role = 'admin' WHERE id = $1"), adminID) adminToken := makeToken(adminID, "projadmin@test.com", "admin") return &projectHarness{ testHarness: &testHarness{router: r, t: t}, stores: stores, userToken: userToken, userID: userID, adminToken: adminToken, adminID: adminID, } } // seedProject creates a project via the store and returns it. func (h *projectHarness) seedProject(name, ownerID string) *models.Project { h.t.Helper() p := &models.Project{ Name: name, Scope: models.ScopePersonal, OwnerID: ownerID, } if err := h.stores.Projects.Create(context.Background(), p); err != nil { h.t.Fatalf("seedProject: %v", err) } return p } // ── GET /projects — envelope ────────────── func TestProjects_List_Empty_Envelope(t *testing.T) { h := setupProjectHarness(t) resp := h.request("GET", "/api/v1/projects", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) data, ok := body["data"] if !ok { t.Fatal("GET /projects must return {\"data\": [...]}, missing \"data\" key") } arr, ok := data.([]interface{}) if !ok { t.Fatalf("data should be an array, got %T", data) } if len(arr) != 0 { t.Errorf("expected empty array, got %d items", len(arr)) } } func TestProjects_List_WithData_Shape(t *testing.T) { h := setupProjectHarness(t) h.seedProject("test-proj", h.userID) resp := h.request("GET", "/api/v1/projects", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) data, ok := body["data"].([]interface{}) if !ok { t.Fatal("data must be an array") } if len(data) != 1 { t.Fatalf("expected 1 project, got %d", len(data)) } proj := data[0].(map[string]interface{}) for _, key := range []string{"id", "name", "scope", "owner_id", "is_archived", "created_at", "updated_at"} { if _, exists := proj[key]; !exists { t.Errorf("missing field %q in project object", key) } } if proj["name"] != "test-proj" { t.Errorf("name: got %v, want test-proj", proj["name"]) } } // ── POST /projects — create ────────────── func TestProjects_Create_201_Shape(t *testing.T) { h := setupProjectHarness(t) resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{ "name": "new-project", "description": "test desc", "color": "#ff0000", "icon": "star", }) if resp.Code != http.StatusCreated { t.Fatalf("expected 201, got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) // Bare object, not wrapped in "data" if _, exists := body["data"]; exists { t.Error("POST /projects should return bare object, not wrapped in 'data'") } if body["name"] != "new-project" { t.Errorf("name: got %v, want new-project", body["name"]) } if body["scope"] != "personal" { t.Errorf("scope should be forced to personal, got %v", body["scope"]) } if body["owner_id"] != h.userID { t.Errorf("owner_id: got %v, want %s", body["owner_id"], h.userID) } if body["color"] != "#ff0000" { t.Errorf("color: got %v, want #ff0000", body["color"]) } if body["icon"] != "star" { t.Errorf("icon: got %v, want star", body["icon"]) } } func TestProjects_Create_RequiresName(t *testing.T) { h := setupProjectHarness(t) resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{ "description": "no name", }) if resp.Code != http.StatusBadRequest { t.Fatalf("expected 400 for missing name, got %d", resp.Code) } } // ── GET /projects/:id — single object ───── func TestProjects_Get_Shape(t *testing.T) { h := setupProjectHarness(t) p := h.seedProject("detail-proj", h.userID) resp := h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) // Bare object if _, exists := body["data"]; exists { t.Error("GET /:id should return object directly, not wrapped in 'data'") } if body["id"] != p.ID { t.Errorf("id: got %v, want %s", body["id"], p.ID) } // Computed counts use omitempty — absent when zero, present as number when nonzero. // With a fresh project all counts are 0, so they'll be omitted. Verify that if // present they're numeric (the AdminList test covers nonzero count visibility). for _, key := range []string{"channel_count", "kb_count", "note_count"} { if val, exists := body[key]; exists { if _, ok := val.(float64); !ok { t.Errorf("%s should be numeric, got %T", key, val) } } } } func TestProjects_Get_NotFound(t *testing.T) { h := setupProjectHarness(t) resp := h.request("GET", "/api/v1/projects/00000000-0000-0000-0000-000000000000", h.userToken, nil) if resp.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.Code) } } // ── PUT /projects/:id — update ──────────── func TestProjects_Update_ReturnsRefreshed(t *testing.T) { h := setupProjectHarness(t) p := h.seedProject("old-name", h.userID) resp := h.request("PUT", "/api/v1/projects/"+p.ID, h.userToken, map[string]interface{}{ "name": "new-name", }) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) if body["name"] != "new-name" { t.Errorf("name should be updated to new-name, got %v", body["name"]) } if body["id"] != p.ID { t.Errorf("id mismatch: got %v, want %s", body["id"], p.ID) } } // ── DELETE /projects/:id ────────────────── func TestProjects_Delete_Shape(t *testing.T) { h := setupProjectHarness(t) p := h.seedProject("doomed", h.userID) resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) if body["message"] != "project deleted" { t.Errorf("expected message 'project deleted', got %v", body["message"]) } // Confirm actually deleted resp = h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil) if resp.Code != http.StatusNotFound { t.Errorf("project should be gone, got %d", resp.Code) } } func TestProjects_Delete_ForbiddenForNonOwner(t *testing.T) { h := setupProjectHarness(t) otherID := database.SeedTestUser(t, "other", "other@test.com") p := h.seedProject("others-proj", otherID) resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil) // Non-owner, non-admin user should not be able to access (404 from loadAndAuthorize) if resp.Code != http.StatusNotFound { t.Fatalf("expected 404 for other user's personal project, got %d", resp.Code) } } // ── Auth ────────────────────────────────── func TestProjects_RequiresAuth(t *testing.T) { h := setupProjectHarness(t) resp := h.request("GET", "/api/v1/projects", "", nil) if resp.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without token, got %d", resp.Code) } } // ── Admin List ──────────────────────────── func TestProjects_AdminList_Envelope(t *testing.T) { h := setupProjectHarness(t) h.seedProject("admin-visible", h.userID) resp := h.request("GET", "/api/v1/admin/projects", h.adminToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) data, ok := body["data"].([]interface{}) if !ok { t.Fatal("admin list must return {\"data\": [...]}") } if len(data) < 1 { t.Fatal("expected at least 1 project in admin list") } proj := data[0].(map[string]interface{}) // Admin list has extra owner_name field if _, exists := proj["owner_name"]; !exists { t.Error("admin list project should include owner_name") } for _, key := range []string{"channel_count", "kb_count", "note_count"} { if _, exists := proj[key]; !exists { t.Errorf("admin list missing computed field %q", key) } } } func TestProjects_AdminList_ForbiddenForUser(t *testing.T) { h := setupProjectHarness(t) resp := h.request("GET", "/api/v1/admin/projects", h.userToken, nil) if resp.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin, got %d", resp.Code) } }