Files
agentic-coding-workflow/internal/project/project_service_test.go
T
2026-06-10 17:53:09 +08:00

542 lines
15 KiB
Go

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
artifacts map[uuid.UUID]*Artifact
// 置 true 时,下一次 CreateArtifact 返回一次真实的 PG 唯一约束冲突后自动复位,
// 用于覆盖 artifactService.createWithRetry 的 version 竞争重试分支。
createArtifactFailOnce bool
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
projects: map[uuid.UUID]*Project{},
requirements: map[uuid.UUID]*Requirement{},
issues: map[uuid.UUID]*Issue{},
artifacts: map[uuid.UUID]*Artifact{},
}
}
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) cloneArtifact(a *Artifact) *Artifact { v := *a; 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) GetMaxArtifactVersion(_ context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
maxVer := 0
for _, x := range r.artifacts {
if x.RequirementID == requirementID && x.Phase == phase && x.Version > maxVer {
maxVer = x.Version
}
}
return maxVer, nil
}
func (r *fakeRepo) CreateArtifact(_ context.Context, in *Artifact) (*Artifact, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.createArtifactFailOnce {
r.createArtifactFailOnce = false
// 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
}
for _, x := range r.artifacts {
if x.RequirementID == in.RequirementID && x.Phase == in.Phase && x.Version == in.Version {
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
}
}
in.CreatedAt = time.Now()
r.artifacts[in.ID] = r.cloneArtifact(in)
return r.cloneArtifact(in), nil
}
func (r *fakeRepo) ListArtifactsByRequirement(_ context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*Artifact, 0)
for _, x := range r.artifacts {
if x.RequirementID == requirementID && (phase == "" || x.Phase == phase) {
out = append(out, r.cloneArtifact(x))
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Phase != out[j].Phase {
return out[i].Phase < out[j].Phase
}
return out[i].Version < out[j].Version
})
return out, nil
}
func (r *fakeRepo) GetArtifactByVersion(_ context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, x := range r.artifacts {
if x.RequirementID == requirementID && x.Phase == phase && x.Version == version {
return r.cloneArtifact(x), nil
}
}
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
}
// 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)
}