Files
agentic-coding-workflow/internal/project/handler_test.go
T

216 lines
8.8 KiB
Go

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-<uid>" 解码为 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)
rSvc := NewRequirementService(repo, nil)
iSvc := NewIssueService(repo, nil)
resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}}
h := NewHandler(pSvc, rSvc, iSvc, 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)
}