package routes_test import ( "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "doormile/db" "doormile/internal/ai/registry" "doormile/internal/testpg" "doormile/models" ) // End to end through the real router and real handlers, against a real // Postgres. Skipped unless REGISTRY_TEST_DSN is set; the DSN must be a // THROWAWAY database — the five registry tables are dropped and recreated. // See internal/ai/registry/store_integration_test.go for how to start one. func registryApp(t *testing.T) func(method, path, bearer, body string, headers ...string) (int, http.Header, map[string]any) { t.Helper() dsn := os.Getenv("REGISTRY_TEST_DSN") if dsn == "" { t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres end-to-end test") } gdb := testpg.Open(t, dsn, "airegistry_routes_test") all := []any{&models.AIRegistryAudit{}, &models.AISkillTool{}, &models.AISkill{}, &models.AITool{}, &models.AIAgent{}} if err := gdb.Migrator().DropTable(all...); err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(all...); err != nil { t.Fatal(err) } if err := registry.Seed(gdb); err != nil { t.Fatal(err) } prev := db.DB db.DB = gdb t.Cleanup(func() { db.DB = prev }) app := newApp() return func(method, path, bearer, body string, headers ...string) (int, http.Header, map[string]any) { var req *http.Request if body != "" { req = httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } resp, err := app.Test(req, int(10*time.Second/time.Millisecond)) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } defer resp.Body.Close() var out map[string]any _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, resp.Header, out } } func TestPGRegistryEndToEnd(t *testing.T) { call := registryApp(t) admin := token(t, 1, 1) // Read the inventory. code, _, body := call(http.MethodGet, "/api/v1/admin/ai/agents", admin, "") if code != http.StatusOK { t.Fatalf("GET agents = %d %v", code, body) } if total, _ := body["total"].(float64); int(total) != len(registry.SeedAgents) { t.Errorf("GET agents total = %v, want %d (body %v)", body["total"], len(registry.SeedAgents), body) } code, _, body = call(http.MethodGet, "/api/v1/admin/ai/skills?agent=CONSOLE_OPS_AGENT", token(t, 1, 3), "") if code != http.StatusOK { t.Fatalf("manager GET skills = %d %v", code, body) } // Patch a skill as admin: 200, version 2, thresholds as a JSON object. code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/skills/skill_doorstep_stall", admin, `{"enabled":false,"thresholds":{"arrivedStalledMin":30}}`) if code != http.StatusOK { t.Fatalf("PATCH skill = %d %v", code, body) } data, _ := body["data"].(map[string]any) th, _ := data["thresholds"].(map[string]any) if data["enabled"] != false || data["version"] != float64(2) || th["arrivedStalledMin"] != float64(30) { t.Errorf("PATCH response = %v", data) } // A bad value is a 400 naming the problem, and changes nothing. code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/skills/skill_doorstep_stall", admin, `{"thresholds":{"arrivedStalledMin":999}}`) if code != http.StatusBadRequest { t.Errorf("out-of-range PATCH = %d %v, want 400", code, body) } code, _, _ = call(http.MethodPatch, "/api/v1/admin/ai/skills/nope", admin, `{"enabled":true}`) if code != http.StatusNotFound { t.Errorf("PATCH unknown skill = %d, want 404", code) } // Autonomy: refused without the typed confirmation, accepted with it. code, _, _ = call(http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", admin, `{"autonomous":true}`) if code != http.StatusBadRequest { t.Errorf("autonomy without confirm = %d, want 400", code) } code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", admin, `{"autonomous":false,"model":"claude-haiku-4-5-20251001"}`) if code != http.StatusOK { t.Errorf("model PATCH = %d %v", code, body) } // Create a custom skill. code, _, body = call(http.MethodPost, "/api/v1/admin/ai/skills", admin, `{"agentid":"CONSOLE_OPS_AGENT","title":"Night shift watch","tools":["scan_bookings"]}`) if code != http.StatusCreated { t.Fatalf("POST skill = %d %v", code, body) } // The audit shows all three changes. code, _, body = call(http.MethodGet, "/api/v1/admin/ai/audit", admin, "") if total, _ := body["total"].(float64); code != http.StatusOK || int(total) != 4 { t.Errorf("audit = %d, total %v, want 4 rows (enabled, thresholds, model, created)", code, body["total"]) } // Every row names who made the change, even when the login has no appusers row. for _, row := range body["data"].([]any) { if email := row.(map[string]any)["changedbyemail"]; email != "test@doormile.com" { t.Errorf("audit row changedbyemail = %v, want the token's email", email) } } // The engine's endpoint: 200 with an ETag, then 304 for the same tag. t.Setenv("INTERNAL_API_KEY", "engine-key") code, hdr, body := call(http.MethodGet, "/api/v1/internal/ai/registry", "", "", "X-Internal-Key", "engine-key") tag := hdr.Get("ETag") if code != http.StatusOK || tag == "" { t.Fatalf("internal registry = %d, etag %q", code, tag) } if snap, _ := body["data"].(map[string]any); len(snap["agents"].([]any)) != len(registry.SeedAgents) { t.Errorf("internal registry agents = %v", len(snap["agents"].([]any))) } code, _, _ = call(http.MethodGet, "/api/v1/internal/ai/registry", "", "", "X-Internal-Key", "engine-key", "If-None-Match", tag) if code != http.StatusNotModified { t.Errorf("If-None-Match with the current tag = %d, want 304", code) } }