package handlers import ( "context" "encoding/json" "net/http" "testing" "git.gobha.me/xcaliber/chat-switchboard/database" "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" ) // surfaceStore returns the appropriate SurfaceRegistryStore for the // current test database backend. func surfaceStore() store.SurfaceRegistryStore { if database.IsSQLite() { return sqlite.NewSurfaceRegistryStore() } return postgres.NewSurfaceRegistryStore() } // ── Store: Seed ───────────────────────────── func TestSurfaceStore_Seed_Basic(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() manifest := map[string]any{"route": "/test", "scripts": []string{"app.js"}} if err := s.Seed(ctx, "test-surface", "Test Surface", "core", manifest); err != nil { t.Fatalf("Seed: %v", err) } sr, err := s.Get(ctx, "test-surface") if err != nil || sr == nil { t.Fatal("Get after Seed: surface should exist") } if sr.ID != "test-surface" { t.Errorf("ID: got %q, want %q", sr.ID, "test-surface") } if sr.Title != "Test Surface" { t.Errorf("Title: got %q, want %q", sr.Title, "Test Surface") } if sr.Source != "core" { t.Errorf("Source: got %q, want %q", sr.Source, "core") } if !sr.Enabled { t.Error("newly seeded surface should be enabled") } route, _ := sr.Manifest["route"].(string) if route != "/test" { t.Errorf("Manifest route: got %q, want %q", route, "/test") } } func TestSurfaceStore_Seed_PreservesEnabledFlag(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() // Seed, then disable s.Seed(ctx, "my-surface", "v1", "core", map[string]any{"route": "/v1"}) s.SetEnabled(ctx, "my-surface", false) // Re-seed with updated title and manifest s.Seed(ctx, "my-surface", "v2", "core", map[string]any{"route": "/v2"}) sr, _ := s.Get(ctx, "my-surface") if sr == nil { t.Fatal("surface should exist after re-seed") } if sr.Title != "v2" { t.Errorf("Title should be updated: got %q, want %q", sr.Title, "v2") } if sr.Enabled { t.Error("Enabled should be preserved as false across re-seed") } route, _ := sr.Manifest["route"].(string) if route != "/v2" { t.Errorf("Manifest should be updated: got %q, want %q", route, "/v2") } } func TestSurfaceStore_Seed_NilManifest(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() // Seed with nil manifest — should not panic err := s.Seed(ctx, "nil-manifest", "Nil Manifest", "core", nil) if err != nil { t.Fatalf("Seed with nil manifest: %v", err) } sr, _ := s.Get(ctx, "nil-manifest") if sr == nil { t.Fatal("surface should exist") } } // ── Store: List ───────────────────────────── func TestSurfaceStore_List_Empty(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() surfaces, err := s.List(ctx) if err != nil { t.Fatalf("List: %v", err) } // Should return nil or empty slice, not an error if surfaces != nil && len(surfaces) != 0 { t.Errorf("empty DB should return 0 surfaces, got %d", len(surfaces)) } } func TestSurfaceStore_List_Order(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() // Seed in reverse alphabetical order s.Seed(ctx, "zebra", "Zebra", "extension", map[string]any{}) s.Seed(ctx, "alpha", "Alpha", "core", map[string]any{}) s.Seed(ctx, "beta", "Beta", "core", map[string]any{}) surfaces, err := s.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(surfaces) != 3 { t.Fatalf("want 3 surfaces, got %d", len(surfaces)) } // Ordered by source, then title: core first (Alpha, Beta), then extension (Zebra) if surfaces[0].ID != "alpha" { t.Errorf("first: got %q, want %q", surfaces[0].ID, "alpha") } if surfaces[1].ID != "beta" { t.Errorf("second: got %q, want %q", surfaces[1].ID, "beta") } if surfaces[2].ID != "zebra" { t.Errorf("third: got %q, want %q", surfaces[2].ID, "zebra") } } func TestSurfaceStore_List_IncludesDisabled(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() s.Seed(ctx, "enabled-one", "Enabled", "core", map[string]any{}) s.Seed(ctx, "disabled-one", "Disabled", "extension", map[string]any{}) s.SetEnabled(ctx, "disabled-one", false) surfaces, err := s.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(surfaces) != 2 { t.Fatalf("List should return all surfaces including disabled, got %d", len(surfaces)) } } // ── Store: ListEnabled ────────────────────── func TestSurfaceStore_ListEnabled(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() s.Seed(ctx, "chat", "Chat", "core", map[string]any{}) s.Seed(ctx, "editor", "Editor", "core", map[string]any{}) s.Seed(ctx, "my-ext", "My Ext", "extension", map[string]any{}) s.SetEnabled(ctx, "editor", false) ids, err := s.ListEnabled(ctx) if err != nil { t.Fatalf("ListEnabled: %v", err) } if len(ids) != 2 { t.Fatalf("want 2 enabled IDs, got %d: %v", len(ids), ids) } // Verify disabled one is excluded for _, id := range ids { if id == "editor" { t.Error("disabled surface 'editor' should not appear in ListEnabled") } } } func TestSurfaceStore_ListEnabled_Empty(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() ids, err := s.ListEnabled(ctx) if err != nil { t.Fatalf("ListEnabled: %v", err) } if ids != nil && len(ids) != 0 { t.Errorf("empty DB should return 0 enabled IDs, got %d", len(ids)) } } // ── Store: Get ────────────────────────────── func TestSurfaceStore_Get_NotFound(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() sr, err := s.Get(ctx, "nonexistent") if err != nil { t.Errorf("Get nonexistent should return (nil, nil), got err: %v", err) } if sr != nil { t.Error("Get nonexistent should return nil surface") } } func TestSurfaceStore_Get_ManifestRoundTrip(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() manifest := map[string]any{ "route": "/s/complex", "scripts": []any{"app.js", "vendor.js"}, "css": []any{"style.css"}, "nested": map[string]any{"key": "value"}, } s.Seed(ctx, "complex", "Complex", "extension", manifest) sr, err := s.Get(ctx, "complex") if err != nil || sr == nil { t.Fatal("Get should return the surface") } // Verify manifest round-trips correctly got, _ := json.Marshal(sr.Manifest) want, _ := json.Marshal(manifest) if string(got) != string(want) { t.Errorf("Manifest round-trip mismatch:\n got: %s\n want: %s", got, want) } } // ── Store: SetEnabled ─────────────────────── func TestSurfaceStore_SetEnabled_NotFound(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() err := s.SetEnabled(ctx, "nonexistent", true) if err == nil { t.Error("SetEnabled on nonexistent surface should return error") } } // ── Store: Delete ─────────────────────────── func TestSurfaceStore_Delete_ExtensionOnly(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() // Seed a core surface s.Seed(ctx, "chat", "Chat", "core", map[string]any{}) // Try to delete it at the store level — should fail err := s.Delete(ctx, "chat") if err == nil { t.Error("Delete of core surface should return error at store level") } // Verify still exists sr, _ := s.Get(ctx, "chat") if sr == nil { t.Error("core surface should survive Delete attempt") } } func TestSurfaceStore_Delete_Extension(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() s.Seed(ctx, "my-ext", "My Extension", "extension", map[string]any{}) err := s.Delete(ctx, "my-ext") if err != nil { t.Fatalf("Delete extension: %v", err) } sr, _ := s.Get(ctx, "my-ext") if sr != nil { t.Error("extension surface should be gone after Delete") } } func TestSurfaceStore_Delete_NotFound(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) s := surfaceStore() ctx := context.Background() err := s.Delete(ctx, "nonexistent") if err == nil { t.Error("Delete nonexistent should return error") } } // ── Handler + Store Integration: ListEnabled response shape ───── // This test verifies the exact JSON contract that the frontend depends on. func TestSurface_ListEnabled_ResponseContract(t *testing.T) { h := setupSurfaceHarness(t) h.seedCoreSurface("chat", "Chat") w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil) if w.Code != http.StatusOK { t.Fatalf("want 200, got %d", w.Code) } // Parse as raw JSON to verify exact shape var raw map[string]json.RawMessage json.Unmarshal(w.Body.Bytes(), &raw) surfacesJSON, ok := raw["surfaces"] if !ok { t.Fatal("response must have 'surfaces' key") } var surfaces []map[string]interface{} json.Unmarshal(surfacesJSON, &surfaces) if len(surfaces) != 1 { t.Fatalf("want 1 surface, got %d", len(surfaces)) } s := surfaces[0] // Must have exactly id, title, route expectedKeys := map[string]bool{"id": true, "title": true, "route": true} for key := range s { if !expectedKeys[key] { t.Errorf("unexpected key in response: %q", key) } } for key := range expectedKeys { if _, ok := s[key]; !ok { t.Errorf("missing expected key: %q", key) } } }