package handlers import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Notes: Validation (no DB needed) ──────── func TestCreateNoteMissingTitle(t *testing.T) { h := NewNoteHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("user_id", "test-user") c.Request = httptest.NewRequest("POST", "/api/v1/notes", strings.NewReader(`{"content":"body only"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Create(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400 for missing title, got %d: %s", w.Code, w.Body.String()) } } func TestCreateNoteMissingContent(t *testing.T) { h := NewNoteHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("user_id", "test-user") c.Request = httptest.NewRequest("POST", "/api/v1/notes", strings.NewReader(`{"title":"title only"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Create(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400 for missing content, got %d: %s", w.Code, w.Body.String()) } } // ── Notes: Full CRUD Integration ──────────── func TestNoteCRUDIntegration(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) userID := database.SeedTestUser(t, "noteuser", "note@test.com") h := NewNoteHandler() r := gin.New() r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() }) r.POST("/notes", h.Create) r.GET("/notes", h.List) r.GET("/notes/search", h.Search) r.GET("/notes/folders", h.ListFolders) r.GET("/notes/:id", h.Get) r.PUT("/notes/:id", h.Update) r.DELETE("/notes/:id", h.Delete) // ── Create ── w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/notes", strings.NewReader(`{ "title": "Meeting Notes", "content": "Discussed project timeline and deliverables", "folder_path": "/work/meetings", "tags": ["project", "planning"] }`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} json.Unmarshal(w.Body.Bytes(), &created) noteID, ok := created["id"].(string) if !ok || noteID == "" { t.Fatal("Create: missing or empty id in response") } if created["title"] != "Meeting Notes" { t.Errorf("Create: title mismatch: %v", created["title"]) } if created["folder_path"] != "/work/meetings/" { t.Errorf("Create: folder_path should be normalized, got %v", created["folder_path"]) } // ── Create second note for search/list tests ── w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/notes", strings.NewReader(`{ "title": "Recipe Ideas", "content": "Try making sourdough bread with rosemary", "folder_path": "/personal", "tags": ["food", "recipes"] }`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("Create 2nd note: expected 201, got %d", w.Code) } // ── Get ── w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes/"+noteID, nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Get: expected 200, got %d", w.Code) } var got map[string]interface{} json.Unmarshal(w.Body.Bytes(), &got) if got["content"] != "Discussed project timeline and deliverables" { t.Errorf("Get: wrong content: %v", got["content"]) } // ── List (all) ── w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("List: expected 200, got %d", w.Code) } var listResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &listResp) total := listResp["total"].(float64) if total != 2 { t.Errorf("List: expected total=2, got %.0f", total) } // ── List (filtered by folder) ── w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes?folder=/work/meetings", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("List by folder: expected 200, got %d", w.Code) } json.Unmarshal(w.Body.Bytes(), &listResp) total = listResp["total"].(float64) if total != 1 { t.Errorf("List by folder: expected total=1, got %.0f", total) } // ── List (filtered by tag) ── w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes?tag=food", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("List by tag: expected 200, got %d", w.Code) } json.Unmarshal(w.Body.Bytes(), &listResp) total = listResp["total"].(float64) if total != 1 { t.Errorf("List by tag: expected total=1, got %.0f", total) } // ── Search ── w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes/search?q=sourdough", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Search: expected 200, got %d: %s", w.Code, w.Body.String()) } var searchResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &searchResp) count := searchResp["count"].(float64) if count != 1 { t.Errorf("Search 'sourdough': expected count=1, got %.0f", count) } // ── Folders ── w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes/folders", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Folders: expected 200, got %d", w.Code) } var folders []interface{} json.Unmarshal(w.Body.Bytes(), &folders) if len(folders) != 2 { t.Errorf("Folders: expected 2 folders, got %d", len(folders)) } // ── Update (replace) ── w = httptest.NewRecorder() req, _ = http.NewRequest("PUT", "/notes/"+noteID, strings.NewReader(`{"title":"Updated Meeting Notes","content":"New content","mode":"replace"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Update: expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify update w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes/"+noteID, nil) r.ServeHTTP(w, req) json.Unmarshal(w.Body.Bytes(), &got) if got["title"] != "Updated Meeting Notes" { t.Errorf("Update title: got %v", got["title"]) } if got["content"] != "New content" { t.Errorf("Update content: got %v", got["content"]) } // ── Update (append) ── w = httptest.NewRecorder() req, _ = http.NewRequest("PUT", "/notes/"+noteID, strings.NewReader(`{"content":"\nAppended line","mode":"append"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Append: expected 200, got %d: %s", w.Code, w.Body.String()) } w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes/"+noteID, nil) r.ServeHTTP(w, req) json.Unmarshal(w.Body.Bytes(), &got) content := got["content"].(string) if !strings.Contains(content, "New content") || !strings.Contains(content, "Appended line") { t.Errorf("Append: expected both parts in content, got %q", content) } // ── Delete ── w = httptest.NewRecorder() req, _ = http.NewRequest("DELETE", "/notes/"+noteID, nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Delete: expected 200, got %d", w.Code) } // Verify gone w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes/"+noteID, nil) r.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("Get after delete: expected 404, got %d", w.Code) } } // ── Notes: Search empty query ─────────────── func TestNoteSearchEmptyQuery(t *testing.T) { database.RequireTestDB(t) h := NewNoteHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("user_id", "test-user") c.Request = httptest.NewRequest("GET", "/api/v1/notes/search", nil) h.Search(c) if w.Code != http.StatusBadRequest { t.Errorf("Search with no query: expected 400, got %d", w.Code) } } // ── Notes: Cross-user isolation ───────────── func TestNoteIsolationBetweenUsers(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) userA := database.SeedTestUser(t, "alice", "alice@test.com") userB := database.SeedTestUser(t, "bob", "bob@test.com") h := NewNoteHandler() // Alice creates a note rA := gin.New() rA.Use(func(c *gin.Context) { c.Set("user_id", userA); c.Next() }) rA.POST("/notes", h.Create) rA.GET("/notes", h.List) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/notes", strings.NewReader(`{"title":"Alice Secret","content":"private stuff"}`)) req.Header.Set("Content-Type", "application/json") rA.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("Alice create: expected 201, got %d", w.Code) } // Bob lists — should see zero rB := gin.New() rB.Use(func(c *gin.Context) { c.Set("user_id", userB); c.Next() }) rB.GET("/notes", h.List) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes", nil) rB.ServeHTTP(w, req) var listResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &listResp) total := listResp["total"].(float64) if total != 0 { t.Errorf("Bob should see 0 notes, got %.0f", total) } // Alice lists — should see one w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/notes", nil) rA.ServeHTTP(w, req) json.Unmarshal(w.Body.Bytes(), &listResp) total = listResp["total"].(float64) if total != 1 { t.Errorf("Alice should see 1 note, got %.0f", total) } }