package changerequest import ( "context" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/infra/vcs" ) // ===== fakes ===== type fakeRepo struct { items map[uuid.UUID]*ChangeRequest nextNum int } func newFakeRepo() *fakeRepo { return &fakeRepo{items: map[uuid.UUID]*ChangeRequest{}, nextNum: 1} } func (f *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (*ChangeRequest, error) { cr, ok := f.items[id] if !ok { return nil, errs.New(errs.CodeNotFound, "not found") } cp := *cr return &cp, nil } func (f *fakeRepo) GetByNumber(_ context.Context, _ string, number int) (*ChangeRequest, error) { for _, cr := range f.items { if cr.Number == number { cp := *cr return &cp, nil } } return nil, errs.New(errs.CodeNotFound, "not found") } func (f *fakeRepo) ListByWorkspace(_ context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) { var out []*ChangeRequest for _, cr := range f.items { if cr.WorkspaceID == wsID { cp := *cr out = append(out, &cp) } } return out, nil } func (f *fakeRepo) ListByProject(_ context.Context, pid uuid.UUID) ([]*ChangeRequest, error) { var out []*ChangeRequest for _, cr := range f.items { if cr.ProjectID == pid { cp := *cr out = append(out, &cp) } } return out, nil } func (f *fakeRepo) UpdateReview(_ context.Context, id uuid.UUID, v Verdict, by uuid.UUID) (*ChangeRequest, error) { cr := f.items[id] cr.ReviewVerdict = v cr.ReviewedBy = &by now := time.Now() cr.ReviewedAt = &now cp := *cr return &cp, nil } func (f *fakeRepo) UpdateState(_ context.Context, id uuid.UUID, st State, sha string, at *time.Time) (*ChangeRequest, error) { cr := f.items[id] cr.State = st cr.MergeCommitSHA = sha cr.MergedAt = at cp := *cr return &cp, nil } func (f *fakeRepo) UpdateCI(_ context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) { cr := f.items[id] cr.CIState = ci cr.State = st cp := *cr return &cp, nil } func (f *fakeRepo) InTx(ctx context.Context, fn func(Tx) error) error { return fn(&fakeTx{r: f}) } type fakeTx struct{ r *fakeRepo } func (t *fakeTx) NextNumber(_ context.Context, _ uuid.UUID) (int, error) { n := t.r.nextNum t.r.nextNum++ return n, nil } func (t *fakeTx) Create(_ context.Context, cr *ChangeRequest) (*ChangeRequest, error) { cp := *cr // mirror the DB INSERT defaults baked into the query (state/verdict). cp.State = StateOpen cp.ReviewVerdict = VerdictPending if cp.Provider == "" { cp.Provider = "gitea" } t.r.items[cr.ID] = &cp out := cp return &out, nil } type fakeProvider struct { createPR *vcs.PullRequest getPR *vcs.PullRequest mergeSHA string mergeErr error mergeCalled bool } func (p *fakeProvider) CreatePR(_ context.Context, _ vcs.CreatePRInput) (*vcs.PullRequest, error) { if p.createPR != nil { return p.createPR, nil } return &vcs.PullRequest{Number: 1, State: "open", HTMLURL: "http://h/pr/1", CIState: "unknown"}, nil } func (p *fakeProvider) GetPR(_ context.Context, _ vcs.RepoRef, _ int64) (*vcs.PullRequest, error) { if p.getPR != nil { return p.getPR, nil } return &vcs.PullRequest{Number: 1, State: "open", CIState: "success"}, nil } func (p *fakeProvider) Comment(_ context.Context, _ vcs.RepoRef, _ int64, _ string) error { return nil } func (p *fakeProvider) Merge(_ context.Context, _ vcs.RepoRef, _ int64, _ vcs.MergeInput) (string, error) { p.mergeCalled = true if p.mergeErr != nil { return "", p.mergeErr } if p.mergeSHA == "" { return "merged-sha", nil } return p.mergeSHA, nil } func (p *fakeProvider) ListIssues(_ context.Context, _ vcs.RepoRef, _ vcs.IssueListOptions) ([]vcs.Issue, error) { return nil, nil } type fakeWS struct { meta WorkspaceMeta err error } func (f *fakeWS) GetWorkspaceMeta(_ context.Context, _ uuid.UUID) (WorkspaceMeta, error) { return f.meta, f.err } type fakePA struct { canRead, canWrite bool } func (f *fakePA) ResolveByID(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, bool, error) { return f.canRead, f.canWrite, nil } type nopRec struct{} func (nopRec) Record(_ context.Context, _ audit.Entry) error { return nil } var ( tProj = uuid.New() tWS = uuid.New() tUser = uuid.New() ) func newSvc(t *testing.T, prov *fakeProvider, pa *fakePA, cfg Config) (*service, *fakeRepo) { t.Helper() repo := newFakeRepo() ws := &fakeWS{meta: WorkspaceMeta{ProjectID: tProj, GitRemoteURL: "https://git.jerryyan.net/owner/repo.git", DefaultBranch: "main"}} svc := NewService(repo, ws, nopRec{}, prov, pa, cfg).(*service) return svc, repo } func caller() Caller { return Caller{UserID: tUser} } // ===== tests ===== func TestCreate_PersistsPending(t *testing.T) { prov := &fakeProvider{createPR: &vcs.PullRequest{Number: 7, State: "open", HTMLURL: "http://h/7", CIState: "pending"}} svc, _ := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) cr, err := svc.Create(context.Background(), caller(), tWS, CreateInput{ SourceBranch: "feature", Title: "T", }) require.NoError(t, err) assert.Equal(t, VerdictPending, cr.ReviewVerdict) assert.Equal(t, StateOpen, cr.State) assert.Equal(t, "main", cr.TargetBranch) require.NotNil(t, cr.ExternalID) assert.Equal(t, int64(7), *cr.ExternalID) assert.Equal(t, 1, cr.Number) assert.Equal(t, CIPending, cr.CIState) } func TestCreate_RequiresWrite(t *testing.T) { svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"}) _, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "f", Title: "T"}) require.Error(t, err) ae, _ := errs.As(err) assert.Equal(t, errs.CodeForbidden, ae.Code) } func TestCreate_SameBranchRejected(t *testing.T) { svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) _, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "main", Title: "T"}) require.Error(t, err) ae, _ := errs.As(err) assert.Equal(t, errs.CodeInvalidInput, ae.Code) } // seed inserts an open, approved-able CR into the repo for merge tests. func seedCR(repo *fakeRepo, verdict Verdict, ci CIState) *ChangeRequest { id := uuid.New() ext := int64(7) cr := &ChangeRequest{ ID: id, ProjectID: tProj, WorkspaceID: tWS, Number: 1, Title: "T", SourceBranch: "feature", TargetBranch: "main", State: StateOpen, ReviewVerdict: verdict, CIState: ci, Provider: "gitea", ExternalID: &ext, CreatedBy: tUser, } repo.items[id] = cr return cr } func TestMerge_DeniedWhenNotApproved(t *testing.T) { for _, v := range []Verdict{VerdictPending, VerdictChangesRequested, VerdictRejected} { prov := &fakeProvider{} svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) cr := seedCR(repo, v, CISuccess) _, err := svc.Merge(context.Background(), caller(), cr.ID, "") require.Error(t, err, v) ae, _ := errs.As(err) assert.Equal(t, errs.CodeFailedPrecondition, ae.Code, v) assert.False(t, prov.mergeCalled, "provider.Merge must NOT be called when verdict=%s", v) } } func TestMerge_DeniedWhenCINotPassing(t *testing.T) { prov := &fakeProvider{} svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true}) cr := seedCR(repo, VerdictApproved, CIFailure) _, err := svc.Merge(context.Background(), caller(), cr.ID, "") require.Error(t, err) ae, _ := errs.As(err) assert.Equal(t, errs.CodeFailedPrecondition, ae.Code) assert.False(t, prov.mergeCalled) } func TestMerge_AdminBypassesCI(t *testing.T) { prov := &fakeProvider{mergeSHA: "sha-x"} svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true}) cr := seedCR(repo, VerdictApproved, CIFailure) out, err := svc.Merge(context.Background(), Caller{UserID: tUser, IsAdmin: true}, cr.ID, "") require.NoError(t, err) assert.True(t, prov.mergeCalled) assert.Equal(t, StateMerged, out.State) assert.Equal(t, "sha-x", out.MergeCommitSHA) } func TestMerge_AllowedWhenApproved(t *testing.T) { prov := &fakeProvider{mergeSHA: "sha-y"} svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true}) cr := seedCR(repo, VerdictApproved, CISuccess) out, err := svc.Merge(context.Background(), caller(), cr.ID, "squash") require.NoError(t, err) assert.True(t, prov.mergeCalled) assert.Equal(t, StateMerged, out.State) assert.NotNil(t, out.MergedAt) assert.Equal(t, "sha-y", out.MergeCommitSHA) } func TestReview_SetsReviewer(t *testing.T) { svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) cr := seedCR(repo, VerdictPending, CIUnknown) out, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "lgtm") require.NoError(t, err) assert.Equal(t, VerdictApproved, out.ReviewVerdict) require.NotNil(t, out.ReviewedBy) assert.Equal(t, tUser, *out.ReviewedBy) } func TestReview_RequiresWrite(t *testing.T) { svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"}) cr := seedCR(repo, VerdictPending, CIUnknown) _, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "") require.Error(t, err) ae, _ := errs.As(err) assert.Equal(t, errs.CodeForbidden, ae.Code) } func TestSyncStatus_RefreshesCIAndState(t *testing.T) { prov := &fakeProvider{getPR: &vcs.PullRequest{Number: 7, State: "closed", Merged: true, CIState: "success", MergeCommitSHA: "z"}} svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) cr := seedCR(repo, VerdictApproved, CIUnknown) out, err := svc.SyncStatus(context.Background(), caller(), cr.ID) require.NoError(t, err) assert.Equal(t, CISuccess, out.CIState) assert.Equal(t, StateMerged, out.State) }