package controllers_test import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) // emptyGetManager returns a GetResult that sets neither Project nor Degraded — // a Manager-contract violation — so the test can prove the handler answers a // clean 500 before writing the 200 status. type emptyGetManager struct{ projectsvc.Manager } func (emptyGetManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) { return projectsvc.GetResult{}, nil } // TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union // invariant: a degenerate GetResult must surface as a parseable 500 envelope, // not a 200 with truncated JSON. func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ Projects: emptyGetManager{}, }, httpd.ControlDeps{})) t.Cleanup(srv.Close) body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "") assertJSON(t, headers) assertErrorCode(t, body, status, http.StatusInternalServerError, "INTERNAL_ERROR") } func newTestServer(t *testing.T) *httptest.Server { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { _ = store.Close() }) srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ Projects: projectsvc.New(store), }, httpd.ControlDeps{})) t.Cleanup(srv.Close) return srv } func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{})) t.Cleanup(srv.Close) body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") assertJSON(t, headers) assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") } func TestProjectsAPI_ListAddGet(t *testing.T) { srv := newTestServer(t) repo := gitRepo(t, "agent-orchestrator") body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") if status != http.StatusOK { t.Fatalf("GET projects = %d, want 200; body=%s", status, body) } assertJSON(t, headers) var list struct { Projects []projectSummary `json:"projects"` } mustJSON(t, body, &list) if len(list.Projects) != 0 { t.Fatalf("initial project count = %d, want 0", len(list.Projects)) } body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`) if status != http.StatusCreated { t.Fatalf("POST project = %d, want 201; body=%s", status, body) } var add struct { Project projectBody `json:"project"` } mustJSON(t, body, &add) if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" { t.Fatalf("created project = %#v", add.Project) } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "") if status != http.StatusOK { t.Fatalf("GET project = %d, want 200; body=%s", status, body) } var get struct { Status string `json:"status"` Project projectBody `json:"project"` } mustJSON(t, body, &get) if get.Status != "ok" || get.Project.ID != "ao" { t.Fatalf("get response = %#v", get) } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "") if status != http.StatusOK { t.Fatalf("GET projects after add = %d, want 200; body=%s", status, body) } mustJSON(t, body, &list) if len(list.Projects) != 1 || list.Projects[0].Path != repo { t.Fatalf("project summary path = %#v, want path %q", list.Projects, repo) } } func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { srv := newTestServer(t) repoA := gitRepo(t, "repo-a") repoB := gitRepo(t, "repo-b") notRepo := t.TempDir() cases := []struct { name, body, wantCode string wantStatus int }{ {name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"}, {name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"}, {name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body) assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) }) } body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`) if status != http.StatusCreated { t.Fatalf("seed create = %d, want 201; body=%s", status, body) } body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`) assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED") body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`) assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED") } func TestProjectsAPI_Delete(t *testing.T) { srv := newTestServer(t) repo := gitRepo(t, "repo") body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`) if status != http.StatusCreated { t.Fatalf("seed create = %d, want 201; body=%s", status, body) } body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "") if status != http.StatusOK { t.Fatalf("DELETE = %d, want 200; body=%s", status, body) } var removed struct { ProjectID string `json:"projectId"` RemovedStorageDir bool `json:"removedStorageDir"` } mustJSON(t, body, &removed) if removed.ProjectID != "proj" || removed.RemovedStorageDir { t.Fatalf("delete response = %#v", removed) } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "") if status != http.StatusNotFound { t.Fatalf("GET archived project = %d, want 404; body=%s", status, body) } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "") if status != http.StatusOK { t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body) } var list struct { Projects []projectSummary `json:"projects"` } mustJSON(t, body, &list) if len(list.Projects) != 0 { t.Fatalf("active projects after archive = %d, want 0", len(list.Projects)) } } // TestProjectsAPI_RejectsUnknownConfigKeys locks the strict-decoder gate on the // project config endpoints: a misspelled or removed field surfaces as a clear // 400 instead of being silently dropped, so the API cannot accumulate dead // config the daemon never reads. func TestProjectsAPI_RejectsUnknownConfigKeys(t *testing.T) { srv := newTestServer(t) repo := gitRepo(t, "rejects-unknown") body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"rej"}`) if status != http.StatusCreated { t.Fatalf("seed create = %d, want 201; body=%s", status, body) } // PUT a config body with an extraneous top-level key. body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"defaultBranch":"develop"},"surprise":"!"}`) assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON") // PUT a config body with a removed field inside the nested config — the // canonical regression: agentRules / tracker are no longer modelled, so // projects can't sneak them back in. body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"agentRules":"x"}}`) assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON") body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"tracker":{"plugin":"github"}}}`) assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON") // POST /projects gets the same gate, so add-time config rides the same rail. otherRepo := gitRepo(t, "rejects-unknown-add") body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(otherRepo)+`,"projectId":"rej2","config":{"orchestratorRules":"x"}}`) assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON") } func TestProjectsRoutes_LegacyUnregistered(t *testing.T) { srv := newTestServer(t) cases := []struct { method, path, wantCode, why string wantStatus int }{ {method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"}, } for _, tc := range cases { t.Run(tc.why, func(t *testing.T) { body, status, _ := doRequest(t, srv, tc.method, tc.path, "") assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) }) } } func TestProjectsRoutes_MissingRoute(t *testing.T) { srv := newTestServer(t) body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "") assertJSON(t, headers) assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND") } func TestOpenAPIYAMLServed(t *testing.T) { srv := newTestServer(t) body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "") if status != http.StatusOK { t.Fatalf("status = %d, want 200", status) } if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { t.Errorf("Content-Type = %q, want application/yaml*", ct) } if !strings.Contains(string(body), "openapi: 3.1.0") { t.Errorf("served body did not start with an OpenAPI 3.1 doc") } } type projectSummary struct { ID string `json:"id"` Name string `json:"name"` Path string `json:"path"` SessionPrefix string `json:"sessionPrefix"` } type projectBody struct { ID string `json:"id"` Name string `json:"name"` Path string `json:"path"` Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` Agent string `json:"agent"` } type errorBody struct { Error string `json:"error"` Code string `json:"code"` Message string `json:"message"` Details map[string]any `json:"details"` } func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) { t.Helper() var req *http.Request var err error if body != "" { req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body)) } else { req, err = http.NewRequest(method, srv.URL+path, nil) } if err != nil { t.Fatalf("new request: %v", err) } if body != "" { req.Header.Set("Content-Type", "application/json") } resp, err := srv.Client().Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer resp.Body.Close() buf, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } return buf, resp.StatusCode, resp.Header } func gitRepo(t *testing.T, name string) string { t.Helper() dir := filepath.Join(t.TempDir(), name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("create git repo fixture: %v", err) } if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil { t.Fatalf("git init fixture: %v\n%s", err, out) } return dir } func quote(s string) string { b, _ := json.Marshal(s) return string(b) } func mustJSON(t *testing.T, body []byte, out any) { t.Helper() if err := json.Unmarshal(body, out); err != nil { t.Fatalf("unmarshal: %v\nbody=%s", err, body) } } func assertJSON(t *testing.T, headers http.Header) { t.Helper() if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Fatalf("Content-Type = %q, want JSON", ct) } } func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) { t.Helper() if status != wantStatus { t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body) } var got errorBody mustJSON(t, body, &got) if got.Code != wantCode { t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body) } }