diff --git a/internal/project/handler.go b/internal/project/handler.go new file mode 100644 index 0000000..3de8597 --- /dev/null +++ b/internal/project/handler.go @@ -0,0 +1,713 @@ +// Package project 内的 handler.go 是三个聚合根的 HTTP 接入层。 +// +// 路由约定(PM spec §3.1): +// +// /api/v1/projects CRUD + archive +// /api/v1/projects/{slug}/requirements/... CRUD + phase + close +// /api/v1/projects/{slug}/issues/... CRUD + assign + close +// +// 设计取舍: +// - 把 Caller 提取做成 callerFromCtx helper:从 middleware.UserIDFromContext +// 读 uid,再通过 AdminLookup 拿 is_admin。AdminLookup 是窄接口,避免直接 +// 依赖 internal/user 的具体类型(也方便测试时注入 stub)。 +// - phase 切换 / close / reopen / archive / assign 都走独立 POST endpoint: +// 便于 audit 区分语义;PATCH 仅改 title/description/visibility/owner_id。 +package project + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// AdminLookup 根据 user id 判 is_admin。 +type AdminLookup interface { + IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) +} + +// Handler 把三个 service 与一个 AdminLookup 拼到 HTTP 路由上。 +type Handler struct { + projects ProjectService + requirements RequirementService + issues IssueService + resolver middleware.SessionResolver + users AdminLookup +} + +// NewHandler 构造 Handler。 +func NewHandler(p ProjectService, r RequirementService, i IssueService, resolver middleware.SessionResolver, users AdminLookup) *Handler { + return &Handler{projects: p, requirements: r, issues: i, resolver: resolver, users: users} +} + +// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。 +func (h *Handler) Mount(r chi.Router) { + r.Route("/api/v1/projects", func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + + r.Post("/", h.createProject) + r.Get("/", h.listProjects) + r.Get("/{slug}", h.getProject) + r.Patch("/{slug}", h.updateProject) + r.Post("/{slug}/archive", h.archiveProject) + r.Post("/{slug}/unarchive", h.unarchiveProject) + + r.Post("/{slug}/requirements", h.createRequirement) + r.Get("/{slug}/requirements", h.listRequirements) + r.Get("/{slug}/requirements/{number}", h.getRequirement) + r.Patch("/{slug}/requirements/{number}", h.updateRequirement) + r.Post("/{slug}/requirements/{number}/phase", h.changeRequirementPhase) + r.Post("/{slug}/requirements/{number}/close", h.closeRequirement) + r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement) + + r.Post("/{slug}/issues", h.createIssue) + r.Get("/{slug}/issues", h.listIssues) + r.Get("/{slug}/issues/{number}", h.getIssue) + r.Patch("/{slug}/issues/{number}", h.updateIssue) + r.Post("/{slug}/issues/{number}/assign", h.assignIssue) + r.Post("/{slug}/issues/{number}/close", h.closeIssue) + r.Post("/{slug}/issues/{number}/reopen", h.reopenIssue) + }) +} + +// callerFromCtx 从 ctx 取出 uid,再调用 AdminLookup 取 is_admin。 +// 没有 uid 时返回 401(Auth 中间件已经在前面拦了,这里是双保险)。 +func (h *Handler) callerFromCtx(r *http.Request) (Caller, error) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + return Caller{}, errs.New(errs.CodeUnauthorized, "未认证") + } + isAdmin, err := h.users.IsAdmin(r.Context(), uid) + if err != nil { + return Caller{}, err + } + return Caller{UserID: uid, IsAdmin: isAdmin}, nil +} + +// numberParam 解析 chi URL 中的 {number}。失败时返回 invalid_input。 +func numberParam(r *http.Request) (int, error) { + raw := chi.URLParam(r, "number") + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return 0, errs.New(errs.CodeInvalidInput, "number 非法") + } + return n, nil +} + +func writeErr(w http.ResponseWriter, r *http.Request, err error) { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) +} + +func decodeBody(r *http.Request, dst any) error { + if err := json.NewDecoder(r.Body).Decode(dst); err != nil { + return errs.New(errs.CodeInvalidInput, "请求体无效") + } + return nil +} + +// ===== DTO ===== + +type projectDTO struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + OwnerID string `json:"owner_id"` + ArchivedAt *string `json:"archived_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type requirementDTO struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Phase string `json:"phase"` + Status string `json:"status"` + OwnerID string `json:"owner_id"` + ClosedAt *string `json:"closed_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type issueDTO struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + RequirementID *string `json:"requirement_id"` + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + AssigneeID *string `json:"assignee_id"` + CreatedBy string `json:"created_by"` + ClosedAt *string `json:"closed_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toProjectDTO(p *Project) projectDTO { + d := projectDTO{ + ID: p.ID.String(), Slug: p.Slug, Name: p.Name, Description: p.Description, + Visibility: string(p.Visibility), OwnerID: p.OwnerID.String(), + CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: p.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + if p.ArchivedAt != nil { + s := p.ArchivedAt.UTC().Format("2006-01-02T15:04:05Z") + d.ArchivedAt = &s + } + return d +} + +func toRequirementDTO(r *Requirement) requirementDTO { + d := requirementDTO{ + ID: r.ID.String(), ProjectID: r.ProjectID.String(), + Number: r.Number, Title: r.Title, Description: r.Description, + Phase: string(r.Phase), Status: string(r.Status), OwnerID: r.OwnerID.String(), + CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + if r.ClosedAt != nil { + s := r.ClosedAt.UTC().Format("2006-01-02T15:04:05Z") + d.ClosedAt = &s + } + return d +} + +func toIssueDTO(i *Issue) issueDTO { + d := issueDTO{ + ID: i.ID.String(), ProjectID: i.ProjectID.String(), + Number: i.Number, Title: i.Title, Description: i.Description, + Status: string(i.Status), CreatedBy: i.CreatedBy.String(), + CreatedAt: i.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: i.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + if i.RequirementID != nil { + s := i.RequirementID.String() + d.RequirementID = &s + } + if i.AssigneeID != nil { + s := i.AssigneeID.String() + d.AssigneeID = &s + } + if i.ClosedAt != nil { + s := i.ClosedAt.UTC().Format("2006-01-02T15:04:05Z") + d.ClosedAt = &s + } + return d +} + +// ===== Project endpoints ===== + +type createProjectReq struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` +} + +func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + var req createProjectReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + vis := Visibility(req.Visibility) + if req.Visibility == "" { + vis = VisibilityPrivate + } + out, err := h.projects.Create(r.Context(), c, CreateProjectInput{ + Slug: req.Slug, Name: req.Name, Description: req.Description, Visibility: vis, + }) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, toProjectDTO(out)) +} + +func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + includeArchived := r.URL.Query().Get("archived") == "true" + out, err := h.projects.List(r.Context(), c, includeArchived) + if err != nil { + writeErr(w, r, err) + return + } + dtos := make([]projectDTO, 0, len(out)) + for _, p := range out { + dtos = append(dtos, toProjectDTO(p)) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos}) +} + +func (h *Handler) getProject(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + out, err := h.projects.Get(r.Context(), c, chi.URLParam(r, "slug")) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out)) +} + +type updateProjectReq struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Visibility *string `json:"visibility,omitempty"` +} + +func (h *Handler) updateProject(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + var req updateProjectReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + in := UpdateProjectInput{Name: req.Name, Description: req.Description} + if req.Visibility != nil { + v := Visibility(*req.Visibility) + in.Visibility = &v + } + out, err := h.projects.Update(r.Context(), c, chi.URLParam(r, "slug"), in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out)) +} + +func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, true) } +func (h *Handler) unarchiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, false) } + +func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive bool) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + slug := chi.URLParam(r, "slug") + if archive { + err = h.projects.Archive(r.Context(), c, slug) + } else { + err = h.projects.Unarchive(r.Context(), c, slug) + } + if err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ===== Requirement endpoints ===== + +type createRequirementReq struct { + Title string `json:"title"` + Description string `json:"description"` + OwnerID *string `json:"owner_id,omitempty"` +} + +func (h *Handler) createRequirement(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + var req createRequirementReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + in := CreateRequirementInput{Title: req.Title, Description: req.Description} + if req.OwnerID != nil { + uid, perr := uuid.Parse(*req.OwnerID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "owner_id 非法")) + return + } + in.OwnerID = &uid + } + out, err := h.requirements.Create(r.Context(), c, chi.URLParam(r, "slug"), in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, toRequirementDTO(out)) +} + +func (h *Handler) listRequirements(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + out, err := h.requirements.List(r.Context(), c, chi.URLParam(r, "slug"), RequirementFilter{ + Phase: Phase(r.URL.Query().Get("phase")), + Status: Status(r.URL.Query().Get("status")), + }) + if err != nil { + writeErr(w, r, err) + return + } + dtos := make([]requirementDTO, 0, len(out)) + for _, x := range out { + dtos = append(dtos, toRequirementDTO(x)) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos}) +} + +func (h *Handler) getRequirement(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + req, err := h.requirements.Get(r.Context(), c, chi.URLParam(r, "slug"), num) + if err != nil { + writeErr(w, r, err) + return + } + // 详情携带挂在其下的 issues 摘要 + issues, _ := h.issues.List(r.Context(), c, chi.URLParam(r, "slug"), IssueFilter{Requirement: &num}) + idtos := make([]issueDTO, 0, len(issues)) + for _, i := range issues { + idtos = append(idtos, toIssueDTO(i)) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{ + "requirement": toRequirementDTO(req), + "issues": idtos, + }) +} + +type updateRequirementReq struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + OwnerID *string `json:"owner_id,omitempty"` +} + +func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + var req updateRequirementReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + in := UpdateRequirementInput{Title: req.Title, Description: req.Description} + if req.OwnerID != nil { + uid, perr := uuid.Parse(*req.OwnerID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "owner_id 非法")) + return + } + in.OwnerID = &uid + } + out, err := h.requirements.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out)) +} + +type phaseReq struct { + Phase string `json:"phase"` +} + +func (h *Handler) changeRequirementPhase(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + var req phaseReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + out, err := h.requirements.ChangePhase(r.Context(), c, chi.URLParam(r, "slug"), num, Phase(req.Phase)) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out)) +} + +func (h *Handler) closeRequirement(w http.ResponseWriter, r *http.Request) { h.toggleReqStatus(w, r, true) } +func (h *Handler) reopenRequirement(w http.ResponseWriter, r *http.Request) { h.toggleReqStatus(w, r, false) } + +func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClose bool) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + if doClose { + err = h.requirements.Close(r.Context(), c, chi.URLParam(r, "slug"), num) + } else { + err = h.requirements.Reopen(r.Context(), c, chi.URLParam(r, "slug"), num) + } + if err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ===== Issue endpoints ===== + +type createIssueReq struct { + Title string `json:"title"` + Description string `json:"description"` + RequirementNumber *int `json:"requirement_number,omitempty"` + AssigneeID *string `json:"assignee_id,omitempty"` +} + +func (h *Handler) createIssue(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + var req createIssueReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + in := CreateIssueInput{Title: req.Title, Description: req.Description, RequirementNumber: req.RequirementNumber} + if req.AssigneeID != nil { + uid, perr := uuid.Parse(*req.AssigneeID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee_id 非法")) + return + } + in.AssigneeID = &uid + } + out, err := h.issues.Create(r.Context(), c, chi.URLParam(r, "slug"), in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, toIssueDTO(out)) +} + +func (h *Handler) listIssues(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + q := r.URL.Query() + filter := IssueFilter{Status: Status(q.Get("status"))} + switch req := q.Get("requirement"); req { + case "": + // 不过滤 + case "none", "null": + zero := 0 + filter.Requirement = &zero + default: + n, perr := strconv.Atoi(req) + if perr != nil || n <= 0 { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement 参数非法")) + return + } + filter.Requirement = &n + } + if a := q.Get("assignee"); a != "" { + uid, perr := uuid.Parse(a) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee 非法")) + return + } + filter.AssigneeID = &uid + } + out, err := h.issues.List(r.Context(), c, chi.URLParam(r, "slug"), filter) + if err != nil { + writeErr(w, r, err) + return + } + dtos := make([]issueDTO, 0, len(out)) + for _, x := range out { + dtos = append(dtos, toIssueDTO(x)) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos}) +} + +func (h *Handler) getIssue(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + out, err := h.issues.Get(r.Context(), c, chi.URLParam(r, "slug"), num) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out)) +} + +// updateIssueReq 用 json.RawMessage 区分 "未传"、"显式传 null"、"传值"。 +type updateIssueReq struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + RequirementNumber json.RawMessage `json:"requirement_number,omitempty"` +} + +func (h *Handler) updateIssue(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + var req updateIssueReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + in := UpdateIssueInput{Title: req.Title, Description: req.Description} + if len(req.RequirementNumber) > 0 { + // 字段在 JSON 中存在 + if string(req.RequirementNumber) == "null" { + var nilPtr *int + in.RequirementNumber = &nilPtr + } else { + var n int + if jerr := json.Unmarshal(req.RequirementNumber, &n); jerr != nil || n <= 0 { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement_number 非法")) + return + } + pn := &n + in.RequirementNumber = &pn + } + } + out, err := h.issues.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out)) +} + +type assignReq struct { + AssigneeID *string `json:"assignee_id"` +} + +func (h *Handler) assignIssue(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + var req assignReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + var assignee *uuid.UUID + if req.AssigneeID != nil { + uid, perr := uuid.Parse(*req.AssigneeID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee_id 非法")) + return + } + assignee = &uid + } + out, err := h.issues.Assign(r.Context(), c, chi.URLParam(r, "slug"), num, assignee) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out)) +} + +func (h *Handler) closeIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, true) } +func (h *Handler) reopenIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, false) } + +func (h *Handler) toggleIssueStatus(w http.ResponseWriter, r *http.Request, doClose bool) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + if doClose { + err = h.issues.Close(r.Context(), c, chi.URLParam(r, "slug"), num) + } else { + err = h.issues.Reopen(r.Context(), c, chi.URLParam(r, "slug"), num) + } + if err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/project/handler_test.go b/internal/project/handler_test.go new file mode 100644 index 0000000..95f7e2b --- /dev/null +++ b/internal/project/handler_test.go @@ -0,0 +1,116 @@ +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) + 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"]) +}