281 lines
11 KiB
Go
281 lines
11 KiB
Go
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
|
|
)
|
|
|
|
type fakeStore struct {
|
|
run domain.ReviewRun
|
|
ok bool
|
|
batchRuns []domain.ReviewRun
|
|
prs []domain.PullRequest
|
|
|
|
updateCalls int
|
|
markCalls int
|
|
markedIDs []string
|
|
}
|
|
|
|
func (f *fakeStore) GetReviewRun(_ context.Context, id string) (domain.ReviewRun, bool, error) {
|
|
for _, run := range f.batchRuns {
|
|
if run.ID == id {
|
|
return run, true, nil
|
|
}
|
|
}
|
|
if f.ok && f.run.ID == id {
|
|
return f.run, true, nil
|
|
}
|
|
return domain.ReviewRun{}, false, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) {
|
|
for i := range f.batchRuns {
|
|
if f.batchRuns[i].ID == id {
|
|
if f.batchRuns[i].Status != domain.ReviewRunRunning {
|
|
return false, nil
|
|
}
|
|
f.updateCalls++
|
|
f.batchRuns[i].Status = status
|
|
f.batchRuns[i].Verdict = verdict
|
|
f.batchRuns[i].Body = body
|
|
f.batchRuns[i].GithubReviewID = githubReviewID
|
|
if f.run.ID == id {
|
|
f.run = f.batchRuns[i]
|
|
}
|
|
return true, nil
|
|
}
|
|
}
|
|
if f.run.Status != domain.ReviewRunRunning {
|
|
return false, nil
|
|
}
|
|
f.updateCalls++
|
|
f.run.Status = status
|
|
f.run.Verdict = verdict
|
|
f.run.Body = body
|
|
f.run.GithubReviewID = githubReviewID
|
|
return true, nil
|
|
}
|
|
|
|
func (f *fakeStore) MarkReviewRunDelivered(_ context.Context, id string, deliveredAt time.Time) (bool, error) {
|
|
f.markCalls++
|
|
f.markedIDs = append(f.markedIDs, id)
|
|
if f.run.ID == id && f.run.Status == domain.ReviewRunComplete && f.run.DeliveredAt == nil {
|
|
f.run.Status = domain.ReviewRunDelivered
|
|
f.run.DeliveredAt = &deliveredAt
|
|
}
|
|
for i := range f.batchRuns {
|
|
if f.batchRuns[i].ID == id && f.batchRuns[i].Status == domain.ReviewRunComplete && f.batchRuns[i].DeliveredAt == nil {
|
|
f.batchRuns[i].Status = domain.ReviewRunDelivered
|
|
f.batchRuns[i].DeliveredAt = &deliveredAt
|
|
return true, nil
|
|
}
|
|
}
|
|
if f.run.ID != id || f.run.Status != domain.ReviewRunDelivered {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListReviewRunsByBatch(context.Context, domain.SessionID, string) ([]domain.ReviewRun, error) {
|
|
out := append([]domain.ReviewRun(nil), f.batchRuns...)
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListPRsBySession(context.Context, domain.SessionID) ([]domain.PullRequest, error) {
|
|
out := append([]domain.PullRequest(nil), f.prs...)
|
|
return out, nil
|
|
}
|
|
|
|
type fakeReducer struct {
|
|
outcome lifecycle.ReviewDeliveryOutcome
|
|
err error
|
|
calls int
|
|
batchCalls int
|
|
got lifecycle.ReviewResult
|
|
gotBatchID string
|
|
gotBatch []lifecycle.ReviewResult
|
|
}
|
|
|
|
func (f *fakeReducer) ApplyReviewResult(_ context.Context, _ domain.SessionID, result lifecycle.ReviewResult) (lifecycle.ReviewDeliveryOutcome, error) {
|
|
f.calls++
|
|
f.got = result
|
|
return f.outcome, f.err
|
|
}
|
|
|
|
func (f *fakeReducer) ApplyReviewBatch(_ context.Context, _ domain.SessionID, batchID string, results []lifecycle.ReviewResult) (lifecycle.ReviewDeliveryOutcome, error) {
|
|
f.batchCalls++
|
|
f.gotBatchID = batchID
|
|
f.gotBatch = append([]lifecycle.ReviewResult(nil), results...)
|
|
return f.outcome, f.err
|
|
}
|
|
|
|
func TestSubmitPersistsThenAppliesThenStampsDelivered(t *testing.T) {
|
|
now := time.Unix(100, 0).UTC()
|
|
st := &fakeStore{ok: true, run: domain.ReviewRun{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}}
|
|
reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent}
|
|
svc := New(nil, st, WithLifecycleReducer(reducer), WithClock(func() time.Time { return now }))
|
|
|
|
run, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", "987")
|
|
if err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
if st.updateCalls != 1 || reducer.calls != 1 || st.markCalls != 1 {
|
|
t.Fatalf("calls update/reducer/mark = %d/%d/%d", st.updateCalls, reducer.calls, st.markCalls)
|
|
}
|
|
if reducer.got.Verdict != domain.VerdictChangesRequested || reducer.got.Body != "fix it" || reducer.got.GithubReviewID != "987" {
|
|
t.Fatalf("reducer saw wrong result: %+v", reducer.got)
|
|
}
|
|
if run.Status != domain.ReviewRunDelivered || run.DeliveredAt == nil || !run.DeliveredAt.Equal(now) {
|
|
t.Fatalf("run not stamped delivered: %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestSubmitBatchRunDoesNotWaitForOtherRunningRuns(t *testing.T) {
|
|
now := time.Unix(100, 0).UTC()
|
|
st := &fakeStore{
|
|
ok: true,
|
|
run: domain.ReviewRun{ID: "run-1", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning},
|
|
batchRuns: []domain.ReviewRun{
|
|
{ID: "run-1", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning},
|
|
{ID: "run-2", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr2", TargetSHA: "sha2", Status: domain.ReviewRunRunning},
|
|
},
|
|
prs: []domain.PullRequest{{URL: "pr1", HeadSHA: "sha1"}, {URL: "pr2", HeadSHA: "sha2"}},
|
|
}
|
|
reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent}
|
|
svc := New(nil, st, WithLifecycleReducer(reducer), WithClock(func() time.Time { return now }))
|
|
|
|
run, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix pr1", "101")
|
|
if err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
if run.Status != domain.ReviewRunDelivered || run.DeliveredAt == nil || !run.DeliveredAt.Equal(now) {
|
|
t.Fatalf("first submit status = %+v, want delivered", run)
|
|
}
|
|
if reducer.batchCalls != 1 || len(reducer.gotBatch) != 1 || reducer.gotBatch[0].RunID != "run-1" || st.markCalls != 1 {
|
|
t.Fatalf("submitted run should deliver independently: batchCalls=%d got=%+v markCalls=%d", reducer.batchCalls, reducer.gotBatch, st.markCalls)
|
|
}
|
|
}
|
|
|
|
func TestSubmitManySendsCombinedChangesRequested(t *testing.T) {
|
|
now := time.Unix(100, 0).UTC()
|
|
st := &fakeStore{
|
|
ok: true,
|
|
batchRuns: []domain.ReviewRun{
|
|
{ID: "run-1", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning},
|
|
{ID: "run-2", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr2", TargetSHA: "sha2", Status: domain.ReviewRunRunning},
|
|
{ID: "run-3", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr3", TargetSHA: "sha3", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved},
|
|
{ID: "run-4", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr4", TargetSHA: "old", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested, Body: "stale"},
|
|
{ID: "run-5", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr5", TargetSHA: "sha5", Status: domain.ReviewRunFailed},
|
|
},
|
|
prs: []domain.PullRequest{
|
|
{URL: "pr1", HeadSHA: "sha1"},
|
|
{URL: "pr2", HeadSHA: "sha2"},
|
|
{URL: "pr3", HeadSHA: "sha3"},
|
|
{URL: "pr4", HeadSHA: "new"},
|
|
{URL: "pr5", HeadSHA: "sha5"},
|
|
},
|
|
}
|
|
reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent}
|
|
svc := New(nil, st, WithLifecycleReducer(reducer), WithClock(func() time.Time { return now }))
|
|
|
|
runs, err := svc.SubmitMany(context.Background(), "mer-1", []SubmittedReview{
|
|
{RunID: "run-1", Verdict: domain.VerdictChangesRequested, Body: "fix pr1", GithubReviewID: "101"},
|
|
{RunID: "run-2", Verdict: domain.VerdictChangesRequested, Body: "fix pr2", GithubReviewID: "102"},
|
|
{RunID: "run-3", Verdict: domain.VerdictApproved},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitMany: %v", err)
|
|
}
|
|
if reducer.batchCalls != 1 || reducer.gotBatchID != "batch-1" {
|
|
t.Fatalf("batch delivery calls/id = %d/%q", reducer.batchCalls, reducer.gotBatchID)
|
|
}
|
|
if len(reducer.gotBatch) != 2 || reducer.gotBatch[0].RunID != "run-1" || reducer.gotBatch[1].RunID != "run-2" {
|
|
t.Fatalf("delivered batch = %+v, want run-1 and run-2 only", reducer.gotBatch)
|
|
}
|
|
if st.markCalls != 2 {
|
|
t.Fatalf("markCalls = %d, want 2", st.markCalls)
|
|
}
|
|
if runs[0].Status != domain.ReviewRunDelivered || runs[0].DeliveredAt == nil || !runs[0].DeliveredAt.Equal(now) ||
|
|
runs[1].Status != domain.ReviewRunDelivered || runs[1].DeliveredAt == nil || !runs[1].DeliveredAt.Equal(now) {
|
|
t.Fatalf("submitted runs not stamped delivered: %+v", runs)
|
|
}
|
|
}
|
|
|
|
func TestSubmitBatchApprovedOnlySendsNothing(t *testing.T) {
|
|
st := &fakeStore{
|
|
ok: true,
|
|
run: domain.ReviewRun{ID: "run-2", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr2", TargetSHA: "sha2", Status: domain.ReviewRunRunning},
|
|
batchRuns: []domain.ReviewRun{
|
|
{ID: "run-1", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved},
|
|
{ID: "run-2", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr2", TargetSHA: "sha2", Status: domain.ReviewRunRunning},
|
|
},
|
|
prs: []domain.PullRequest{{URL: "pr1", HeadSHA: "sha1"}, {URL: "pr2", HeadSHA: "sha2"}},
|
|
}
|
|
reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent}
|
|
svc := New(nil, st, WithLifecycleReducer(reducer))
|
|
|
|
if _, err := svc.Submit(context.Background(), "mer-1", "run-2", domain.VerdictApproved, "", "102"); err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
if reducer.batchCalls != 0 || st.markCalls != 0 {
|
|
t.Fatalf("approved-only batch should not deliver: batchCalls=%d markCalls=%d", reducer.batchCalls, st.markCalls)
|
|
}
|
|
}
|
|
|
|
func TestSubmitDeliveryFailureLeavesCompletedUndeliveredForRetry(t *testing.T) {
|
|
sendErr := errors.New("dead pane")
|
|
st := &fakeStore{ok: true, run: domain.ReviewRun{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}}
|
|
reducer := &fakeReducer{err: sendErr}
|
|
svc := New(nil, st, WithLifecycleReducer(reducer))
|
|
|
|
if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", "987"); !errors.Is(err, sendErr) {
|
|
t.Fatalf("err = %v, want sendErr", err)
|
|
}
|
|
if st.run.Status != domain.ReviewRunComplete || st.run.DeliveredAt != nil || st.markCalls != 0 {
|
|
t.Fatalf("failed delivery should leave completed/undelivered without stamp: %+v markCalls=%d", st.run, st.markCalls)
|
|
}
|
|
|
|
reducer.err = nil
|
|
reducer.outcome = lifecycle.ReviewDeliverySent
|
|
if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix it", "987"); err != nil {
|
|
t.Fatalf("retry Submit: %v", err)
|
|
}
|
|
if st.updateCalls != 1 || reducer.calls != 2 || st.run.Status != domain.ReviewRunDelivered || st.run.DeliveredAt == nil {
|
|
t.Fatalf("retry should not rewrite result and should stamp delivery: update=%d reducer=%d run=%+v", st.updateCalls, reducer.calls, st.run)
|
|
}
|
|
}
|
|
|
|
func TestSubmitCompletedRetryRejectsDifferentRecordedFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
githubReviewID string
|
|
}{
|
|
{name: "different body", body: "different", githubReviewID: "987"},
|
|
{name: "different review id", body: "fix it", githubReviewID: "654"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
st := &fakeStore{ok: true, run: domain.ReviewRun{
|
|
ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1",
|
|
Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested,
|
|
Body: "fix it", GithubReviewID: "987",
|
|
}}
|
|
reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent}
|
|
svc := New(nil, st, WithLifecycleReducer(reducer))
|
|
|
|
if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, tt.body, tt.githubReviewID); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("err = %v, want ErrInvalid", err)
|
|
}
|
|
if st.updateCalls != 0 || st.markCalls != 0 || reducer.calls != 0 {
|
|
t.Fatalf("mismatched retry should not rewrite or deliver: update=%d mark=%d reducer=%d", st.updateCalls, st.markCalls, reducer.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|