package project import ( "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) func newIssueSvc(repo *fakeRepo) (IssueService, RequirementService, *spyAudit) { spy := &spyAudit{} return NewIssueService(repo, spy), NewRequirementService(repo, spy), spy } func TestIssueService_Create_FreelanceAndAttached(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) issueSvc, reqSvc, _ := newIssueSvc(repo) caller := Caller{UserID: owner} // 游离 free, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "Free"}) require.NoError(t, err) require.Nil(t, free.RequirementID) // 挂载 r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) num := r.Number att, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "A", RequirementNumber: &num}) require.NoError(t, err) require.NotNil(t, att.RequirementID) require.Equal(t, r.ID, *att.RequirementID) } func TestIssueService_Create_RejectsClosedRequirement(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) issueSvc, reqSvc, _ := newIssueSvc(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)) num := r.Number _, err = issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "X", RequirementNumber: &num}) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeRequirementClosed, ae.Code) } func TestIssueService_Update_MoveBetweenRequirementsAndDetach(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) issueSvc, reqSvc, _ := newIssueSvc(repo) caller := Caller{UserID: owner} r1, _ := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R1"}) r2, _ := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R2"}) n1 := r1.Number issue, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "I", RequirementNumber: &n1}) require.NoError(t, err) // move r1 → r2 n2 := r2.Number pn2 := &n2 out, err := issueSvc.Update(context.Background(), caller, "demo", issue.Number, UpdateIssueInput{RequirementNumber: &pn2}) require.NoError(t, err) require.Equal(t, r2.ID, *out.RequirementID) // detach (传 nil 指针 → 取消挂载) var nilPtr *int out, err = issueSvc.Update(context.Background(), caller, "demo", issue.Number, UpdateIssueInput{RequirementNumber: &nilPtr}) require.NoError(t, err) require.Nil(t, out.RequirementID) } func TestIssueService_Assign_AndUnassign(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) issueSvc, _, _ := newIssueSvc(repo) caller := Caller{UserID: owner} i, _ := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "I"}) dev := uuid.New() out, err := issueSvc.Assign(context.Background(), caller, "demo", i.Number, &dev) require.NoError(t, err) require.NotNil(t, out.AssigneeID) require.Equal(t, dev, *out.AssigneeID) out, err = issueSvc.Assign(context.Background(), caller, "demo", i.Number, nil) require.NoError(t, err) require.Nil(t, out.AssigneeID) } func TestIssueService_Update_BlockedWhenProjectArchived(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) issueSvc, _, _ := newIssueSvc(repo) projSvc := NewProjectService(repo, nil) caller := Caller{UserID: owner} i, _ := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "I"}) require.NoError(t, projSvc.Archive(context.Background(), caller, "demo")) t1 := "Renamed" _, err := issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{Title: &t1}) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeProjectArchived, ae.Code) } func TestIssueService_Create_WithWorkspaceID(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) issueSvc, _, spy := newIssueSvc(repo) caller := Caller{UserID: owner} wsID := uuid.New() out, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{ Title: "I", WorkspaceID: &wsID, }) require.NoError(t, err) require.NotNil(t, out.WorkspaceID) require.Equal(t, wsID, *out.WorkspaceID) // 再 Get 一次确认持久化 got, err := issueSvc.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) last := spy.entries[len(spy.entries)-1] require.Equal(t, "issue.create", last.Action) require.Equal(t, wsID.String(), last.Metadata["workspace_id"]) } func TestIssueService_Update_WorkspaceID_TriState(t *testing.T) { repo := newFakeRepo() owner := uuid.New() seedProject(t, repo, owner) issueSvc, _, _ := newIssueSvc(repo) caller := Caller{UserID: owner} wsA := uuid.New() i, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{ Title: "I", WorkspaceID: &wsA, }) require.NoError(t, err) require.NotNil(t, i.WorkspaceID) require.Equal(t, wsA, *i.WorkspaceID) // 1) nil → 不变(用 Title 强制 changed 非空,走持久化路径,验证 nextWS 不被错误清空) newTitle := "I-renamed" out, err := issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{Title: &newTitle}) require.NoError(t, err) require.Equal(t, newTitle, out.Title) require.NotNil(t, out.WorkspaceID) require.Equal(t, wsA, *out.WorkspaceID) // 2) &id(非 Nil)→ 关联到新 workspace wsB := uuid.New() out, err = issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{ 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 = issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{ WorkspaceID: &nilUUID, }) require.NoError(t, err) require.Nil(t, out.WorkspaceID) }