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) }