Files
doormile_backend/routes/routes_ai_registry_pg_test.go

152 lines
5.7 KiB
Go

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)
}
}