package project import ( "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) func newArtifactSvc(repo *fakeRepo) (ArtifactService, RequirementService, ProjectService, *spyAudit) { spy := &spyAudit{} return NewArtifactService(repo, spy), NewRequirementService(repo, spy, nil, nil), NewProjectService(repo, spy), spy } func TestArtifactService_Create_VersionAutoIncrement(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, spy := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) a1, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"}) require.NoError(t, err) require.Equal(t, 1, a1.Version) a2, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v2", Note: "second"}) require.NoError(t, err) require.Equal(t, 2, a2.Version) require.Equal(t, "second", a2.Note) require.Contains(t, spy.actions(), "requirement_artifact.create") } func TestArtifactService_Create_VersionsIndependentPerPhase(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) // planning 走到 v2,prototyping 的版本序列从 1 重新开始。 _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p1"}) require.NoError(t, err) _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p2"}) require.NoError(t, err) proto, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "x1"}) require.NoError(t, err) require.Equal(t, 1, proto.Version) } func TestArtifactService_Create_RejectsNonArtifactPhase(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) for _, phase := range []Phase{PhaseDone, Phase("bogus"), Phase("")} { _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"}) ae, ok := errs.As(err) require.True(t, ok, "phase=%s", phase) require.Equal(t, errs.CodeInvalidInput, ae.Code, "phase=%s", phase) } } func TestArtifactService_Create_RetriesOnVersionConflict(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) // 首轮 CreateArtifact 撞 UNIQUE,service 应捕获并重试一次后成功。 repo.createArtifactFailOnce = true a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"}) require.NoError(t, err) require.Equal(t, 1, a.Version) require.False(t, repo.createArtifactFailOnce, "失败开关应已被消费") } func TestArtifactService_Create_RequiresContent(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: ""}) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeInvalidInput, ae.Code) } func TestArtifactService_Create_BlockedWhenRequirementClosed(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number)) _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"}) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeRequirementClosed, ae.Code) } func TestArtifactService_Create_RecordsSourceMessageID(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) srcID := int64(42) a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{ Phase: PhaseAuditing, Content: "from chat", SourceMessageID: &srcID, }) require.NoError(t, err) require.NotNil(t, a.SourceMessageID) require.Equal(t, srcID, *a.SourceMessageID) } func TestArtifactService_List_FilterByPhaseAndAll(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) for i := 0; i < 2; i++ { _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "x"}) require.NoError(t, err) } _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "y"}) require.NoError(t, err) planning, err := artSvc.List(context.Background(), caller, "demo", r.Number, PhasePlanning) require.NoError(t, err) require.Len(t, planning, 2) require.Equal(t, 1, planning[0].Version) require.Equal(t, 2, planning[1].Version) all, err := artSvc.List(context.Background(), caller, "demo", r.Number, "") require.NoError(t, err) require.Len(t, all, 3) _, err = artSvc.List(context.Background(), caller, "demo", r.Number, Phase("bogus")) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeInvalidInput, ae.Code) } func TestArtifactService_GetByVersion_NotFound(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) _, err = artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePlanning, 99) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeNotFound, ae.Code) } func TestArtifactService_GetByVersion_HappyPath(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "hello", Note: "n"}) require.NoError(t, err) got, err := artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePrototyping, 1) require.NoError(t, err) require.Equal(t, "hello", got.Content) require.Equal(t, "n", got.Note) require.Equal(t, owner, got.CreatedBy) } func TestArtifactService_Create_ForbiddenForOutsider(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) owns := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) // 私有项目下,非 owner / 非 admin 调用者写入被拒(统一返回 not_found 防探测)。 outsider := Caller{UserID: uuid.New()} _, err = artSvc.Create(context.Background(), outsider, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"}) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeNotFound, ae.Code) } func TestArtifactService_List_ForbiddenForOutsider(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) owns := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) outsider := Caller{UserID: uuid.New()} _, err = artSvc.List(context.Background(), outsider, "demo", r.Number, "") ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeNotFound, ae.Code) } func TestArtifactService_Create_AcceptsImplementingReviewing(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) caller := Caller{UserID: owner} r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) // 扩展后的 ArtifactPhases 接受 implementing/reviewing。 require.True(t, PhaseImplementing.IsArtifactPhase()) require.True(t, PhaseReviewing.IsArtifactPhase()) for _, phase := range []Phase{PhaseImplementing, PhaseReviewing} { a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"}) require.NoError(t, err, "phase=%s", phase) require.Equal(t, VerdictNone, a.Verdict) } } func TestArtifactService_Approve_SetsVerdictAndPointer(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, _, _, spy := newArtifactSvc(repo) r := seedRequirement(t, repo, owner, nil) caller := Caller{UserID: owner} _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) require.NoError(t, err) out, err := artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass) require.NoError(t, err) require.Equal(t, VerdictPass, out.Verdict) ptr, err := repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing) require.NoError(t, err) require.NotNil(t, ptr.ApprovedArtifactID) require.Equal(t, out.ID, *ptr.ApprovedArtifactID) require.Contains(t, spy.actions(), "requirement_artifact.approve") } func TestArtifactService_Approve_FailDoesNotSetPointer(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, _, _, _ := newArtifactSvc(repo) r := seedRequirement(t, repo, owner, nil) caller := Caller{UserID: owner} _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) require.NoError(t, err) out, err := artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictFail) require.NoError(t, err) require.Equal(t, VerdictFail, out.Verdict) _, err = repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeNotFound, ae.Code) } func TestArtifactService_Approve_MovesPointerToNewerVersion(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, _, _, _ := newArtifactSvc(repo) r := seedRequirement(t, repo, owner, nil) caller := Caller{UserID: owner} _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "v1"}) require.NoError(t, err) v2, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "v2"}) require.NoError(t, err) _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass) require.NoError(t, err) _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 2, VerdictPass) require.NoError(t, err) ptr, err := repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing) require.NoError(t, err) require.Equal(t, v2.ID, *ptr.ApprovedArtifactID) } func TestArtifactService_Approve_BlockedWhenClosed(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, reqSvc, _, _ := newArtifactSvc(repo) r := seedRequirement(t, repo, owner, nil) caller := Caller{UserID: owner} _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) require.NoError(t, err) require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number)) _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeRequirementClosed, ae.Code) } func TestArtifactService_Approve_RejectsInvalidVerdict(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) artSvc, _, _, _ := newArtifactSvc(repo) r := seedRequirement(t, repo, owner, nil) caller := Caller{UserID: owner} _, 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, VerdictNone) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeInvalidInput, ae.Code) }