You've already forked agentic-coding-workflow
feat(project): ProjectService with owner/visibility access control + audit
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
// Package project 内的 project_service.go 实装 ProjectService 接口。
|
||||||
|
//
|
||||||
|
// 鉴权矩阵(PM spec §1.3):
|
||||||
|
//
|
||||||
|
// read private: owner + admin
|
||||||
|
// read internal: 任意已登录
|
||||||
|
// write *: owner + admin
|
||||||
|
//
|
||||||
|
// 已归档项目下的所有写操作(包含 Requirement / Issue 的写)一律返回
|
||||||
|
// CodeProjectArchived;这是 spec 强约定,不区分 owner / admin。
|
||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectService struct {
|
||||||
|
repo Repository
|
||||||
|
audit audit.Recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProjectService 用 Repository 与可选 audit recorder 构造 ProjectService。
|
||||||
|
// recorder == nil 时跳过审计写入(便于单测)。
|
||||||
|
func NewProjectService(repo Repository, rec audit.Recorder) ProjectService {
|
||||||
|
return &projectService{repo: repo, audit: rec}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertReadable 是 read 类操作的鉴权门面:private 仅 owner+admin,internal
|
||||||
|
// 任意登录用户。失败统一返回 CodeNotFound(避免存在性探测)。
|
||||||
|
func assertReadable(p *Project, c Caller) error {
|
||||||
|
if p.Visibility == VisibilityInternal {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.IsAdmin || p.OwnerID == c.UserID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertWritable 是 write 类操作的鉴权门面:仅 owner+admin。已归档项目额外
|
||||||
|
// 拒绝;调用方应在执行写入前调用此函数。
|
||||||
|
func assertWritable(p *Project, c Caller) error {
|
||||||
|
if !(c.IsAdmin || p.OwnerID == c.UserID) {
|
||||||
|
// stranger 对 private 看不见;对 internal 也不能写。统一 not_found 防探测。
|
||||||
|
if p.Visibility == VisibilityPrivate {
|
||||||
|
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||||
|
}
|
||||||
|
return errs.New(errs.CodeForbidden, "无权修改该项目")
|
||||||
|
}
|
||||||
|
if p.ArchivedAt != nil {
|
||||||
|
return errs.New(errs.CodeProjectArchived, "项目已归档,禁止写操作")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *projectService) Create(ctx context.Context, c Caller, in CreateProjectInput) (*Project, error) {
|
||||||
|
if in.Slug == "" || in.Name == "" {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "slug / name 必填")
|
||||||
|
}
|
||||||
|
if in.Visibility == "" {
|
||||||
|
in.Visibility = VisibilityPrivate
|
||||||
|
}
|
||||||
|
if !in.Visibility.IsValid() {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "visibility 非法")
|
||||||
|
}
|
||||||
|
p := &Project{
|
||||||
|
ID: uuid.New(), Slug: in.Slug, Name: in.Name, Description: in.Description,
|
||||||
|
Visibility: in.Visibility, OwnerID: c.UserID,
|
||||||
|
}
|
||||||
|
out, err := s.repo.CreateProject(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, c, "project.create", out.ID.String(), map[string]any{
|
||||||
|
"slug": out.Slug, "name": out.Name, "visibility": string(out.Visibility),
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *projectService) Get(ctx context.Context, c Caller, slug string) (*Project, error) {
|
||||||
|
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := assertReadable(p, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *projectService) List(ctx context.Context, c Caller, includeArchived bool) ([]*Project, error) {
|
||||||
|
all, err := s.repo.ListProjects(ctx, includeArchived)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*Project, 0, len(all))
|
||||||
|
for _, p := range all {
|
||||||
|
if assertReadable(p, c) == nil {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *projectService) Update(ctx context.Context, c Caller, slug string, in UpdateProjectInput) (*Project, error) {
|
||||||
|
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := assertWritable(p, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changed := map[string]any{}
|
||||||
|
if in.Name != nil {
|
||||||
|
p.Name = *in.Name
|
||||||
|
changed["name"] = *in.Name
|
||||||
|
}
|
||||||
|
if in.Description != nil {
|
||||||
|
p.Description = *in.Description
|
||||||
|
changed["description"] = "<changed>"
|
||||||
|
}
|
||||||
|
if in.Visibility != nil {
|
||||||
|
if !in.Visibility.IsValid() {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "visibility 非法")
|
||||||
|
}
|
||||||
|
p.Visibility = *in.Visibility
|
||||||
|
changed["visibility"] = string(*in.Visibility)
|
||||||
|
}
|
||||||
|
if len(changed) == 0 {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
out, err := s.repo.UpdateProject(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, c, "project.update", out.ID.String(), map[string]any{"changed_fields": changed})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *projectService) Archive(ctx context.Context, c Caller, slug string) error {
|
||||||
|
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 归档/取消归档仍要 owner+admin 校验,但已归档不再额外阻塞 archive。
|
||||||
|
if !(c.IsAdmin || p.OwnerID == c.UserID) {
|
||||||
|
if p.Visibility == VisibilityPrivate {
|
||||||
|
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||||
|
}
|
||||||
|
return errs.New(errs.CodeForbidden, "无权操作该项目")
|
||||||
|
}
|
||||||
|
if p.ArchivedAt != nil {
|
||||||
|
return nil // 幂等
|
||||||
|
}
|
||||||
|
if err := s.repo.ArchiveProject(ctx, p.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, c, "project.archive", p.ID.String(), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *projectService) Unarchive(ctx context.Context, c Caller, slug string) error {
|
||||||
|
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !(c.IsAdmin || p.OwnerID == c.UserID) {
|
||||||
|
if p.Visibility == VisibilityPrivate {
|
||||||
|
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||||
|
}
|
||||||
|
return errs.New(errs.CodeForbidden, "无权操作该项目")
|
||||||
|
}
|
||||||
|
if p.ArchivedAt == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.repo.UnarchiveProject(ctx, p.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, c, "project.unarchive", p.ID.String(), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit 是 best-effort 写入:失败仅吞错(不影响业务)。ctx 用
|
||||||
|
// context.WithoutCancel 派生,使 HTTP 响应已返回但审计仍能落库。
|
||||||
|
func (s *projectService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
||||||
|
if s.audit == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := c.UserID
|
||||||
|
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
||||||
|
UserID: &uid,
|
||||||
|
Action: action,
|
||||||
|
TargetType: "project",
|
||||||
|
TargetID: targetID,
|
||||||
|
Metadata: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRepo 是内存版 Repository,仅用于 service 单测;按 ID/slug 查找用 map,
|
||||||
|
// number 自增手动维护。并发安全(一把大锁就够)。
|
||||||
|
type fakeRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
projects map[uuid.UUID]*Project
|
||||||
|
requirements map[uuid.UUID]*Requirement
|
||||||
|
issues map[uuid.UUID]*Issue
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRepo() *fakeRepo {
|
||||||
|
return &fakeRepo{
|
||||||
|
projects: map[uuid.UUID]*Project{},
|
||||||
|
requirements: map[uuid.UUID]*Requirement{},
|
||||||
|
issues: map[uuid.UUID]*Issue{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v }
|
||||||
|
func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v }
|
||||||
|
func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v }
|
||||||
|
|
||||||
|
func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, x := range r.projects {
|
||||||
|
if x.Slug == p.Slug {
|
||||||
|
return nil, errs.New(errs.CodeProjectSlugTaken, "slug 已被使用")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
p.CreatedAt, p.UpdatedAt = now, now
|
||||||
|
r.projects[p.ID] = r.cloneProject(p)
|
||||||
|
return r.cloneProject(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) GetProjectBySlug(_ context.Context, slug string) (*Project, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, p := range r.projects {
|
||||||
|
if p.Slug == slug {
|
||||||
|
return r.cloneProject(p), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "project 不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) GetProjectByID(_ context.Context, id uuid.UUID) (*Project, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
p, ok := r.projects[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "project 不存在")
|
||||||
|
}
|
||||||
|
return r.cloneProject(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) ListProjects(_ context.Context, includeArchived bool) ([]*Project, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]*Project, 0, len(r.projects))
|
||||||
|
for _, p := range r.projects {
|
||||||
|
if !includeArchived && p.ArchivedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r.cloneProject(p))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) UpdateProject(_ context.Context, p *Project) (*Project, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.projects[p.ID]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "project 不存在")
|
||||||
|
}
|
||||||
|
cur.Name, cur.Description, cur.Visibility = p.Name, p.Description, p.Visibility
|
||||||
|
cur.UpdatedAt = time.Now()
|
||||||
|
return r.cloneProject(cur), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) ArchiveProject(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
p, ok := r.projects[id]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
p.ArchivedAt, p.UpdatedAt = &now, now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) UnarchiveProject(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
p, ok := r.projects[id]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.ArchivedAt, p.UpdatedAt = nil, time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Requirement =====
|
||||||
|
|
||||||
|
func (r *fakeRepo) nextRequirementNumber(projectID uuid.UUID) int {
|
||||||
|
max := 0
|
||||||
|
for _, x := range r.requirements {
|
||||||
|
if x.ProjectID == projectID && x.Number > max {
|
||||||
|
max = x.Number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) CreateRequirement(_ context.Context, in *Requirement) (*Requirement, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
in.Number = r.nextRequirementNumber(in.ProjectID)
|
||||||
|
now := time.Now()
|
||||||
|
in.CreatedAt, in.UpdatedAt = now, now
|
||||||
|
if in.Phase == "" {
|
||||||
|
in.Phase = PhasePlanning
|
||||||
|
}
|
||||||
|
if in.Status == "" {
|
||||||
|
in.Status = StatusOpen
|
||||||
|
}
|
||||||
|
r.requirements[in.ID] = r.cloneReq(in)
|
||||||
|
return r.cloneReq(in), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) GetRequirementByNumber(_ context.Context, projectID uuid.UUID, number int) (*Requirement, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, x := range r.requirements {
|
||||||
|
if x.ProjectID == projectID && x.Number == number {
|
||||||
|
return r.cloneReq(x), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "requirement 不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) GetRequirementByID(_ context.Context, id uuid.UUID) (*Requirement, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
x, ok := r.requirements[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "requirement 不存在")
|
||||||
|
}
|
||||||
|
return r.cloneReq(x), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) ListRequirements(_ context.Context, projectID uuid.UUID, phase Phase, status Status) ([]*Requirement, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]*Requirement, 0)
|
||||||
|
for _, x := range r.requirements {
|
||||||
|
if x.ProjectID != projectID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if phase != "" && x.Phase != phase {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status != "" && x.Status != status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r.cloneReq(x))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) UpdateRequirement(_ context.Context, in *Requirement) (*Requirement, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.requirements[in.ID]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "requirement 不存在")
|
||||||
|
}
|
||||||
|
cur.Title, cur.Description, cur.OwnerID = in.Title, in.Description, in.OwnerID
|
||||||
|
cur.UpdatedAt = time.Now()
|
||||||
|
return r.cloneReq(cur), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) UpdateRequirementPhase(_ context.Context, id uuid.UUID, to Phase) (*Requirement, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.requirements[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "requirement 不存在")
|
||||||
|
}
|
||||||
|
cur.Phase = to
|
||||||
|
cur.UpdatedAt = time.Now()
|
||||||
|
return r.cloneReq(cur), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) CloseRequirement(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.requirements[id]
|
||||||
|
if !ok || cur.Status != StatusOpen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
cur.Status, cur.ClosedAt, cur.UpdatedAt = StatusClosed, &now, now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) ReopenRequirement(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.requirements[id]
|
||||||
|
if !ok || cur.Status != StatusClosed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cur.Status, cur.ClosedAt, cur.UpdatedAt = StatusOpen, nil, time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Issue =====
|
||||||
|
|
||||||
|
func (r *fakeRepo) nextIssueNumber(projectID uuid.UUID) int {
|
||||||
|
max := 0
|
||||||
|
for _, x := range r.issues {
|
||||||
|
if x.ProjectID == projectID && x.Number > max {
|
||||||
|
max = x.Number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) CreateIssue(_ context.Context, in *Issue) (*Issue, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
in.Number = r.nextIssueNumber(in.ProjectID)
|
||||||
|
now := time.Now()
|
||||||
|
in.CreatedAt, in.UpdatedAt = now, now
|
||||||
|
if in.Status == "" {
|
||||||
|
in.Status = StatusOpen
|
||||||
|
}
|
||||||
|
r.issues[in.ID] = r.cloneIssue(in)
|
||||||
|
return r.cloneIssue(in), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) GetIssueByNumber(_ context.Context, projectID uuid.UUID, number int) (*Issue, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, x := range r.issues {
|
||||||
|
if x.ProjectID == projectID && x.Number == number {
|
||||||
|
return r.cloneIssue(x), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) ListIssues(_ context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]*Issue, 0)
|
||||||
|
for _, x := range r.issues {
|
||||||
|
if x.ProjectID != projectID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.Status != "" && x.Status != filter.Status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.Requirement != nil {
|
||||||
|
switch {
|
||||||
|
case *filter.Requirement == 0:
|
||||||
|
if x.RequirementID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if x.RequirementID == nil || *x.RequirementID != *requirementID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filter.AssigneeID != nil {
|
||||||
|
if x.AssigneeID == nil || *x.AssigneeID != *filter.AssigneeID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, r.cloneIssue(x))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) ListIssuesByRequirement(_ context.Context, requirementID uuid.UUID) ([]*Issue, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]*Issue, 0)
|
||||||
|
for _, x := range r.issues {
|
||||||
|
if x.RequirementID != nil && *x.RequirementID == requirementID {
|
||||||
|
out = append(out, r.cloneIssue(x))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) UpdateIssueCore(_ context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID) (*Issue, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.issues[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||||
|
}
|
||||||
|
cur.Title, cur.Description, cur.RequirementID = title, description, requirementID
|
||||||
|
cur.UpdatedAt = time.Now()
|
||||||
|
return r.cloneIssue(cur), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) UpdateIssueAssignee(_ context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.issues[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||||
|
}
|
||||||
|
cur.AssigneeID = assignee
|
||||||
|
cur.UpdatedAt = time.Now()
|
||||||
|
return r.cloneIssue(cur), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) CloseIssue(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.issues[id]
|
||||||
|
if !ok || cur.Status != StatusOpen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
cur.Status, cur.ClosedAt, cur.UpdatedAt = StatusClosed, &now, now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cur, ok := r.issues[id]
|
||||||
|
if !ok || cur.Status != StatusClosed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cur.Status, cur.ClosedAt, cur.UpdatedAt = StatusOpen, nil, time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spyAudit 收集所有写入的 audit entry,方便断言。
|
||||||
|
type spyAudit struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries []audit.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spyAudit) Record(_ context.Context, e audit.Entry) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.entries = append(s.entries, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spyAudit) actions() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]string, 0, len(s.entries))
|
||||||
|
for _, e := range s.entries {
|
||||||
|
out = append(out, e.Action)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedProject 写一条测试用 Project 并返回。caller 默认为 owner。
|
||||||
|
func seedProject(t *testing.T, r *fakeRepo, ownerID uuid.UUID) *Project {
|
||||||
|
t.Helper()
|
||||||
|
p := &Project{
|
||||||
|
ID: uuid.New(), Slug: "demo", Name: "Demo",
|
||||||
|
Visibility: VisibilityPrivate, OwnerID: ownerID,
|
||||||
|
}
|
||||||
|
out, err := r.CreateProject(context.Background(), p)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectService_Create_HappyPath(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
spy := &spyAudit{}
|
||||||
|
svc := NewProjectService(repo, spy)
|
||||||
|
owner := uuid.New()
|
||||||
|
p, err := svc.Create(context.Background(), Caller{UserID: owner, IsAdmin: false}, CreateProjectInput{
|
||||||
|
Slug: "demo", Name: "Demo", Visibility: VisibilityPrivate,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "demo", p.Slug)
|
||||||
|
require.Equal(t, owner, p.OwnerID)
|
||||||
|
require.Equal(t, []string{"project.create"}, spy.actions())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectService_Create_SlugTaken(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
svc := NewProjectService(repo, nil)
|
||||||
|
_, err := svc.Create(context.Background(), Caller{UserID: owner}, CreateProjectInput{
|
||||||
|
Slug: "demo", Name: "Other", Visibility: VisibilityPrivate,
|
||||||
|
})
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeProjectSlugTaken, ae.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectService_Get_PrivateRejectsStranger(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
svc := NewProjectService(repo, nil)
|
||||||
|
_, err := svc.Get(context.Background(), Caller{UserID: uuid.New()}, "demo")
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
// stranger 看 private project:屏蔽存在性,按 not_found 处理。
|
||||||
|
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectService_Get_InternalAllowsLoggedIn(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
p := seedProject(t, repo, owner)
|
||||||
|
p.Visibility = VisibilityInternal
|
||||||
|
_, err := repo.UpdateProject(context.Background(), p)
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := NewProjectService(repo, nil)
|
||||||
|
got, err := svc.Get(context.Background(), Caller{UserID: uuid.New()}, "demo")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "demo", got.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectService_Update_RequiresOwnerOrAdmin(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
svc := NewProjectService(repo, nil)
|
||||||
|
name := "Renamed"
|
||||||
|
_, err := svc.Update(context.Background(), Caller{UserID: uuid.New()}, "demo", UpdateProjectInput{Name: &name})
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeNotFound, ae.Code, "stranger 看 private 直接 not_found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectService_Archive_BlocksWrites(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
owner := uuid.New()
|
||||||
|
seedProject(t, repo, owner)
|
||||||
|
svc := NewProjectService(repo, nil)
|
||||||
|
require.NoError(t, svc.Archive(context.Background(), Caller{UserID: owner}, "demo"))
|
||||||
|
name := "X"
|
||||||
|
_, err := svc.Update(context.Background(), Caller{UserID: owner}, "demo", UpdateProjectInput{Name: &name})
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, errs.CodeProjectArchived, ae.Code)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user