Files
2026-06-22 08:55:57 +08:00

313 lines
9.9 KiB
Go

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