387 lines
9.9 KiB
Go
387 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|