package project import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" ) // fakeResolver 把固定 token "tok-" 解码为 uuid。 type fakeResolver struct{ valid map[string]uuid.UUID } func (f *fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) { uid, ok := f.valid[token] return uid, ok, nil } // fakeAdmin 始终返回 false(owner 路径足够覆盖大多数测试)。 type fakeAdmin struct{} func (fakeAdmin) IsAdmin(_ context.Context, _ uuid.UUID) (bool, error) { return false, nil } func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fakeRepo, http.Handler) { t.Helper() repo := newFakeRepo() pSvc := NewProjectService(repo, nil) gate := NewGateRunner(GateDeps{Repo: repo, Workspace: nil}, nil) rSvc := NewRequirementService(repo, nil, nil, gate) iSvc := NewIssueService(repo, nil) artSvc := NewArtifactService(repo, nil) resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}} h := NewHandler(pSvc, rSvc, iSvc, artSvc, resolver, fakeAdmin{}) r := chi.NewRouter() r.Use(middleware.RequestID) h.Mount(r) return repo, r } func doJSON(t *testing.T, h http.Handler, method, path, token string, body any) *http.Response { t.Helper() var b []byte if body != nil { b, _ = json.Marshal(body) } req := httptest.NewRequest(method, path, bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec.Result() } // checkStatus calls doJSON, asserts the HTTP status, and closes the response body. func checkStatus(t *testing.T, h http.Handler, method, path, token string, body any, wantStatus int) { t.Helper() resp := doJSON(t, h, method, path, token, body) t.Cleanup(func() { _ = resp.Body.Close() }) require.Equal(t, wantStatus, resp.StatusCode) } func TestHandler_FullClosedLoop(t *testing.T) { owner := uuid.New() tok := "tok-1" _, h := mountFullHandler(t, tok, owner) // 1. create project checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) // 2. create 2 requirements checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R1"}, http.StatusCreated) checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R2"}, http.StatusCreated) // 3. phase change checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/phase", tok, map[string]any{"phase": "auditing"}, http.StatusOK) // 4. create issue attached + freelance checkStatus(t, h, "POST", "/api/v1/projects/demo/issues", tok, map[string]any{"title": "I1", "requirement_number": 1}, http.StatusCreated) checkStatus(t, h, "POST", "/api/v1/projects/demo/issues", tok, map[string]any{"title": "Free"}, http.StatusCreated) // 5. close requirement → phase change must fail with 409 / requirement_closed checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/close", tok, nil, http.StatusNoContent) checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/phase", tok, map[string]any{"phase": "done"}, http.StatusConflict) // 6. archive project → all writes blocked checkStatus(t, h, "POST", "/api/v1/projects/demo/archive", tok, nil, http.StatusNoContent) checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "X"}, http.StatusConflict) } func TestHandler_Unauthenticated_Returns401(t *testing.T) { owner := uuid.New() _, h := mountFullHandler(t, "tok", owner) resp := doJSON(t, h, "GET", "/api/v1/projects/", "", nil) t.Cleanup(func() { _ = resp.Body.Close() }) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) } func TestHandler_SlugTaken_Returns409(t *testing.T) { owner := uuid.New() tok := "tok" _, h := mountFullHandler(t, tok, owner) checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "A"}, http.StatusCreated) resp := doJSON(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "B"}) t.Cleanup(func() { _ = resp.Body.Close() }) require.Equal(t, http.StatusConflict, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.Equal(t, "project_slug_taken", body["code"]) } // decodeBodyMap 解析 200 响应里的 JSON 对象。 func decodeBodyMap(t *testing.T, resp *http.Response) map[string]any { t.Helper() t.Cleanup(func() { _ = resp.Body.Close() }) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) return body } // TestHandler_Issue_PATCH_WorkspaceID_TriState 验证 PATCH /issues/{n} 的 // workspace_id 三态:缺失=不变 / null=解绑 / UUID 字符串=关联 / 非法=400。 func TestHandler_Issue_PATCH_WorkspaceID_TriState(t *testing.T) { owner := uuid.New() tok := "tok" _, h := mountFullHandler(t, tok, owner) checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) wsID := uuid.New() resp := doJSON(t, h, "POST", "/api/v1/projects/demo/issues", tok, map[string]any{"title": "I", "workspace_id": wsID.String()}) t.Cleanup(func() { _ = resp.Body.Close() }) require.Equal(t, http.StatusCreated, resp.StatusCode) createBody := decodeBodyMap(t, resp) require.Equal(t, wsID.String(), createBody["workspace_id"]) // 1) 缺失字段:不变 resp2 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"title": "I-new"}) require.Equal(t, http.StatusOK, resp2.StatusCode) body2 := decodeBodyMap(t, resp2) require.Equal(t, wsID.String(), body2["workspace_id"]) // 2) UUID 字符串:关联到新 workspace wsB := uuid.New() resp3 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"workspace_id": wsB.String()}) require.Equal(t, http.StatusOK, resp3.StatusCode) body3 := decodeBodyMap(t, resp3) require.Equal(t, wsB.String(), body3["workspace_id"]) // 3) 显式 null:解绑(用 raw JSON 才能传 null,不能用 map[string]any{"workspace_id": nil}, // 因为 map nil 也会被序列化成 null,这里直接拼字符串保证准确) rawNull := bytes.NewBufferString(`{"workspace_id": null}`) req := httptest.NewRequest("PATCH", "/api/v1/projects/demo/issues/1", rawNull) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tok) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) resp4 := rec.Result() require.Equal(t, http.StatusOK, resp4.StatusCode) body4 := decodeBodyMap(t, resp4) require.Nil(t, body4["workspace_id"]) // 4) 非法字符串:400 resp5 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"workspace_id": "not-a-uuid"}) t.Cleanup(func() { _ = resp5.Body.Close() }) require.Equal(t, http.StatusBadRequest, resp5.StatusCode) } // TestHandler_Requirement_PATCH_WorkspaceID_TriState 验证 PATCH /requirements/{n} // 的 workspace_id 三态。 func TestHandler_Requirement_PATCH_WorkspaceID_TriState(t *testing.T) { owner := uuid.New() tok := "tok" _, h := mountFullHandler(t, tok, owner) checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) wsID := uuid.New() resp := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R", "workspace_id": wsID.String()}) require.Equal(t, http.StatusCreated, resp.StatusCode) createBody := decodeBodyMap(t, resp) require.Equal(t, wsID.String(), createBody["workspace_id"]) // 1) 缺失字段:不变 resp2 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"title": "R-new"}) require.Equal(t, http.StatusOK, resp2.StatusCode) body2 := decodeBodyMap(t, resp2) require.Equal(t, wsID.String(), body2["workspace_id"]) // 2) UUID 字符串:关联到新 workspace wsB := uuid.New() resp3 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"workspace_id": wsB.String()}) require.Equal(t, http.StatusOK, resp3.StatusCode) body3 := decodeBodyMap(t, resp3) require.Equal(t, wsB.String(), body3["workspace_id"]) // 3) 显式 null:解绑 rawNull := bytes.NewBufferString(`{"workspace_id": null}`) req := httptest.NewRequest("PATCH", "/api/v1/projects/demo/requirements/1", rawNull) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tok) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) resp4 := rec.Result() require.Equal(t, http.StatusOK, resp4.StatusCode) body4 := decodeBodyMap(t, resp4) require.Nil(t, body4["workspace_id"]) // 4) 非法字符串:400 resp5 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"workspace_id": "not-a-uuid"}) t.Cleanup(func() { _ = resp5.Body.Close() }) require.Equal(t, http.StatusBadRequest, resp5.StatusCode) } // TestHandler_Artifacts_Routes 验证 artifacts 三条路由:创建 201、list 裸数组 + // ?phase= 过滤、{phase}/{version} 获取、非法 phase 400。 func TestHandler_Artifacts_Routes(t *testing.T) { owner := uuid.New() tok := "tok" _, h := mountFullHandler(t, tok, owner) checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R"}, http.StatusCreated) // 创建 planning v1(带 source_message_id)+ auditing v1 resp := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, map[string]any{"phase": "planning", "content": "# plan", "note": "n1", "source_message_id": 7}) require.Equal(t, http.StatusCreated, resp.StatusCode) created := decodeBodyMap(t, resp) require.Equal(t, "planning", created["phase"]) require.Equal(t, float64(1), created["version"]) require.Equal(t, float64(7), created["source_message_id"]) checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated) // 非法 phase(done 不在 ArtifactPhases)→ 400 checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, map[string]any{"phase": "done", "content": "x"}, http.StatusBadRequest) // list 裸数组:全量 2 条;?phase=planning 过滤 1 条 respList := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts", tok, nil) t.Cleanup(func() { _ = respList.Body.Close() }) require.Equal(t, http.StatusOK, respList.StatusCode) var all []map[string]any require.NoError(t, json.NewDecoder(respList.Body).Decode(&all)) require.Len(t, all, 2) respPlanning := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts?phase=planning", tok, nil) t.Cleanup(func() { _ = respPlanning.Body.Close() }) require.Equal(t, http.StatusOK, respPlanning.StatusCode) var planning []map[string]any require.NoError(t, json.NewDecoder(respPlanning.Body).Decode(&planning)) require.Len(t, planning, 1) require.Equal(t, "# plan", planning[0]["content"]) // get by {phase}/{version} respGet := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/1", tok, nil) got := decodeBodyMap(t, respGet) require.Equal(t, http.StatusOK, respGet.StatusCode) require.Equal(t, "# plan", got["content"]) // get 非法 phase → 400;不存在版本 → 404 checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/bogus/1", tok, nil, http.StatusBadRequest) checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/9", tok, nil, http.StatusNotFound) } // TestHandler_ApproveArtifactAndGatedPhase 验证 approve 端点回写 verdict,且未满足 // gate 的 phase 流转返回 409 phase_gate_failed,批准后放行 200。 func TestHandler_ApproveArtifactAndGatedPhase(t *testing.T) { owner := uuid.New() tok := "tok" _, h := mountFullHandler(t, tok, owner) checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R"}, http.StatusCreated) checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated) // 未批准 auditing 产物时流转到 implementing → 409 phase_gate_failed checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/phase", tok, map[string]any{"phase": "implementing"}, http.StatusConflict) // approve 端点回写 verdict respApprove := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts/auditing/1/approve", tok, map[string]any{"verdict": "pass"}) require.Equal(t, http.StatusOK, respApprove.StatusCode) approved := decodeBodyMap(t, respApprove) require.Equal(t, "pass", approved["verdict"]) // 批准后流转放行 → 200 checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/phase", tok, map[string]any{"phase": "implementing"}, http.StatusOK) }