152 lines
5.7 KiB
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)
|
|
}
|
|
}
|