You've already forked agentic-coding-workflow
305 lines
10 KiB
Go
305 lines
10 KiB
Go
package project
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
func newReqSvc(repo *fakeRepo) (RequirementService, ProjectService, *spyAudit) {
|
|
spy := &spyAudit{}
|
|
return NewRequirementService(repo, spy, nil, nil), NewProjectService(repo, spy), spy
|
|
}
|
|
|
|
func TestRequirementService_Create_HappyAndAutoIncrement(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
svc, _, spy := newReqSvc(repo)
|
|
caller := Caller{UserID: owner}
|
|
|
|
r1, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "First"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, r1.Number)
|
|
|
|
r2, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "Second"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, r2.Number)
|
|
|
|
require.Contains(t, spy.actions(), "requirement.create")
|
|
}
|
|
|
|
func TestRequirementService_PhaseChange_FollowsEnum(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
svc, _, _ := newReqSvc(repo)
|
|
caller := Caller{UserID: owner}
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
|
require.NoError(t, err)
|
|
|
|
// 任意跳跃合法:planning → reviewing
|
|
out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseReviewing)
|
|
require.NoError(t, err)
|
|
require.Equal(t, PhaseReviewing, out.Phase)
|
|
|
|
// 非枚举字符串 → invalid
|
|
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, "bogus")
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
|
}
|
|
|
|
func TestRequirementService_PhaseChange_BlockedWhenClosed(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
svc, _, _ := newReqSvc(repo)
|
|
caller := Caller{UserID: owner}
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.Close(context.Background(), caller, "demo", r.Number))
|
|
|
|
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseAuditing)
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
|
|
}
|
|
|
|
func TestRequirementService_Write_BlockedWhenProjectArchived(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
reqSvc, projSvc, _ := newReqSvc(repo)
|
|
caller := Caller{UserID: owner}
|
|
require.NoError(t, projSvc.Archive(context.Background(), caller, "demo"))
|
|
|
|
_, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "X"})
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeProjectArchived, ae.Code)
|
|
}
|
|
|
|
func TestRequirementService_Reopen_AllowsPhaseAgain(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
svc, _, _ := newReqSvc(repo)
|
|
caller := Caller{UserID: owner}
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.Close(context.Background(), caller, "demo", r.Number))
|
|
require.NoError(t, svc.Reopen(context.Background(), caller, "demo", r.Number))
|
|
|
|
out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseDone)
|
|
require.NoError(t, err)
|
|
require.Equal(t, PhaseDone, out.Phase)
|
|
}
|
|
|
|
func TestRequirementService_Create_WithWorkspaceID(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
svc, _, spy := newReqSvc(repo)
|
|
caller := Caller{UserID: owner}
|
|
|
|
wsID := uuid.New()
|
|
out, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{
|
|
Title: "R", WorkspaceID: &wsID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.WorkspaceID)
|
|
require.Equal(t, wsID, *out.WorkspaceID)
|
|
|
|
// 再 Get 一次确认持久化
|
|
got, err := svc.Get(context.Background(), caller, "demo", out.Number)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.WorkspaceID)
|
|
require.Equal(t, wsID, *got.WorkspaceID)
|
|
|
|
// audit metadata 包含 workspace_id
|
|
require.NotEmpty(t, spy.entries)
|
|
var createEntry *audit.Entry
|
|
for i := range spy.entries {
|
|
if spy.entries[i].Action == "requirement.create" {
|
|
createEntry = &spy.entries[i]
|
|
}
|
|
}
|
|
require.NotNil(t, createEntry)
|
|
require.Equal(t, wsID.String(), createEntry.Metadata["workspace_id"])
|
|
}
|
|
|
|
func TestRequirementService_Update_WorkspaceID_TriState(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
svc, _, _ := newReqSvc(repo)
|
|
caller := Caller{UserID: owner}
|
|
|
|
wsA := uuid.New()
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{
|
|
Title: "R", WorkspaceID: &wsA,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, r.WorkspaceID)
|
|
require.Equal(t, wsA, *r.WorkspaceID)
|
|
|
|
// 1) nil → 不变(结合一个其它 patch 字段触发 update)
|
|
newTitle := "R2"
|
|
out, err := svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{
|
|
Title: &newTitle,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.WorkspaceID)
|
|
require.Equal(t, wsA, *out.WorkspaceID)
|
|
|
|
// 2) &id(非 Nil)→ 关联到新 workspace
|
|
wsB := uuid.New()
|
|
out, err = svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{
|
|
WorkspaceID: &wsB,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.WorkspaceID)
|
|
require.Equal(t, wsB, *out.WorkspaceID)
|
|
|
|
// 3) &uuid.Nil → 显式解绑
|
|
nilUUID := uuid.Nil
|
|
out, err = svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{
|
|
WorkspaceID: &nilUUID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, out.WorkspaceID)
|
|
}
|
|
|
|
// fakeEmitter 收集 EmitPhaseChange 调用,便于断言事件发出且仅发一次。
|
|
type fakeEmitter struct {
|
|
events []PhaseChangeEvent
|
|
}
|
|
|
|
func (e *fakeEmitter) EmitPhaseChange(_ context.Context, ev PhaseChangeEvent) {
|
|
e.events = append(e.events, ev)
|
|
}
|
|
|
|
func newReqSvcWith(repo *fakeRepo, emitter PhaseEventEmitter, gate GateRunner) (RequirementService, *spyAudit) {
|
|
spy := &spyAudit{}
|
|
return NewRequirementService(repo, spy, emitter, gate), spy
|
|
}
|
|
|
|
func TestRequirementService_ChangePhase_EmitsEvent(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
emitter := &fakeEmitter{}
|
|
svc, spy := newReqSvcWith(repo, emitter, nil)
|
|
caller := Caller{UserID: owner}
|
|
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
|
require.NoError(t, err)
|
|
|
|
out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseAuditing)
|
|
require.NoError(t, err)
|
|
require.Equal(t, PhaseAuditing, out.Phase)
|
|
|
|
require.Len(t, emitter.events, 1)
|
|
ev := emitter.events[0]
|
|
require.Equal(t, PhasePlanning, ev.From)
|
|
require.Equal(t, PhaseAuditing, ev.To)
|
|
require.Equal(t, "demo", ev.ProjectSlug)
|
|
require.Equal(t, r.Number, ev.RequirementNumber)
|
|
require.Equal(t, owner, ev.ActorID)
|
|
// 事件与审计互补:审计仍记一条。
|
|
require.Contains(t, spy.actions(), "requirement.phase_change")
|
|
}
|
|
|
|
func TestRequirementService_ChangePhase_NoopEmitsNothing(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
emitter := &fakeEmitter{}
|
|
svc, _ := newReqSvcWith(repo, emitter, nil)
|
|
caller := Caller{UserID: owner}
|
|
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
|
require.NoError(t, err)
|
|
|
|
// 当前已是 planning,切到 planning 是 no-op。
|
|
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhasePlanning)
|
|
require.NoError(t, err)
|
|
require.Empty(t, emitter.events)
|
|
}
|
|
|
|
func TestRequirementService_ChangePhase_GateBlocksAndSkipsEmit(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
emitter := &fakeEmitter{}
|
|
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
|
svc, spy := newReqSvcWith(repo, emitter, gate)
|
|
caller := Caller{UserID: owner}
|
|
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
|
require.NoError(t, err)
|
|
|
|
// implementing 需要已批准的 auditing 产物,缺失 → phase_gate_failed。
|
|
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing)
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodePhaseGateFailed, ae.Code)
|
|
|
|
// gate 失败不写库、不发事件、不记 phase_change 审计。
|
|
require.Empty(t, emitter.events)
|
|
require.NotContains(t, spy.actions(), "requirement.phase_change")
|
|
got, err := svc.Get(context.Background(), caller, "demo", r.Number)
|
|
require.NoError(t, err)
|
|
require.Equal(t, PhasePlanning, got.Phase)
|
|
}
|
|
|
|
func TestRequirementService_ChangePhase_ClosedCheckedBeforeGate(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
emitter := &fakeEmitter{}
|
|
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
|
svc, _ := newReqSvcWith(repo, emitter, gate)
|
|
caller := Caller{UserID: owner}
|
|
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.Close(context.Background(), caller, "demo", r.Number))
|
|
|
|
// 关闭检查先于 gate:即便目标是被网关的 implementing,也返回 requirement_closed。
|
|
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing)
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
|
|
require.Empty(t, emitter.events)
|
|
}
|
|
|
|
func TestRequirementService_ChangePhase_GateAllowsAfterApproval(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
emitter := &fakeEmitter{}
|
|
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
|
svc, _ := newReqSvcWith(repo, emitter, gate)
|
|
artSvc := NewArtifactService(repo, nil)
|
|
caller := Caller{UserID: owner}
|
|
|
|
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
|
require.NoError(t, err)
|
|
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"})
|
|
require.NoError(t, err)
|
|
_, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass)
|
|
require.NoError(t, err)
|
|
|
|
out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing)
|
|
require.NoError(t, err)
|
|
require.Equal(t, PhaseImplementing, out.Phase)
|
|
require.Len(t, emitter.events, 1)
|
|
}
|