package project import ( "context" "sort" "sync" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5/pgconn" "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 prototypes map[uuid.UUID]*Prototype // 置 true 时,下一次 CreatePrototype 返回一次真实的 PG 唯一约束冲突后自动复位, // 用于覆盖 prototypeService.createWithRetry 的 version 竞争重试分支。 createPrototypeFailOnce bool } func newFakeRepo() *fakeRepo { return &fakeRepo{ projects: map[uuid.UUID]*Project{}, requirements: map[uuid.UUID]*Requirement{}, issues: map[uuid.UUID]*Issue{}, prototypes: map[uuid.UUID]*Prototype{}, } } 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) clonePrototype(p *Prototype) *Prototype { 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 { maxNum := 0 for _, x := range r.requirements { if x.ProjectID == projectID && x.Number > maxNum { maxNum = x.Number } } return maxNum + 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.WorkspaceID = in.WorkspaceID 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 { maxNum := 0 for _, x := range r.issues { if x.ProjectID == projectID && x.Number > maxNum { maxNum = x.Number } } return maxNum + 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 { if *filter.Requirement == 0 { if x.RequirementID != nil { continue } } else 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, workspaceID *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.WorkspaceID = workspaceID 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 } func (r *fakeRepo) GetMaxPrototypeVersion(_ context.Context, requirementID uuid.UUID) (int, error) { r.mu.Lock() defer r.mu.Unlock() maxVer := 0 for _, x := range r.prototypes { if x.RequirementID == requirementID && x.Version > maxVer { maxVer = x.Version } } return maxVer, nil } func (r *fakeRepo) CreatePrototype(_ context.Context, in *Prototype) (*Prototype, error) { r.mu.Lock() defer r.mu.Unlock() if r.createPrototypeFailOnce { r.createPrototypeFailOnce = false // 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。 return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation} } for _, x := range r.prototypes { if x.RequirementID == in.RequirementID && x.Version == in.Version { return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation} } } in.CreatedAt = time.Now() r.prototypes[in.ID] = r.clonePrototype(in) return r.clonePrototype(in), nil } func (r *fakeRepo) ListPrototypesByRequirement(_ context.Context, requirementID uuid.UUID) ([]*Prototype, error) { r.mu.Lock() defer r.mu.Unlock() out := make([]*Prototype, 0) for _, x := range r.prototypes { if x.RequirementID == requirementID { out = append(out, r.clonePrototype(x)) } } sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version }) return out, nil } func (r *fakeRepo) GetPrototypeByVersion(_ context.Context, requirementID uuid.UUID, version int) (*Prototype, error) { r.mu.Lock() defer r.mu.Unlock() for _, x := range r.prototypes { if x.RequirementID == requirementID && x.Version == version { return r.clonePrototype(x), nil } } return nil, errs.New(errs.CodeNotFound, "prototype 不存在") } // 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) }