agent-orchestrator/backend/internal/service/session/service_test.go

614 lines
23 KiB
Go

package session
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
)
type fakeTelemetrySink struct{ events []ports.TelemetryEvent }
func (f *fakeTelemetrySink) Emit(_ context.Context, ev ports.TelemetryEvent) {
f.events = append(f.events, ev)
}
func (f *fakeTelemetrySink) Close(context.Context) error { return nil }
type fakeStore struct {
sessions map[domain.SessionID]domain.SessionRecord
pr map[domain.SessionID]domain.PRFacts
projects map[string]domain.ProjectRecord
num int
}
func newFakeStore() *fakeStore {
return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}, projects: map[string]domain.ProjectRecord{}}
}
func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
f.num++
rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num))
f.sessions[rec.ID] = rec
return rec, nil
}
func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
r, ok := f.sessions[id]
return r, ok, nil
}
func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) {
var out []domain.SessionRecord
for _, r := range f.sessions {
if r.ProjectID == p {
out = append(out, r)
}
}
return out, nil
}
func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) {
out := make([]domain.SessionRecord, 0, len(f.sessions))
for _, r := range f.sessions {
out = append(out, r)
}
return out, nil
}
func (f *fakeStore) RenameSession(_ context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) {
r, ok := f.sessions[id]
if !ok {
return false, nil
}
r.DisplayName = displayName
r.UpdatedAt = updatedAt
f.sessions[id] = r
return true, nil
}
func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) {
pr, ok := f.pr[id]
return pr, ok, nil
}
func (f *fakeStore) ListPRsBySession(_ context.Context, id domain.SessionID) ([]domain.PullRequest, error) {
pr, ok := f.pr[id]
if !ok {
return nil, nil
}
return []domain.PullRequest{{URL: pr.URL, SessionID: id, Number: pr.Number, Draft: pr.Draft, Merged: pr.Merged, Closed: pr.Closed, CI: pr.CI, Review: pr.Review, Mergeability: pr.Mergeability, UpdatedAt: pr.UpdatedAt}}, nil
}
func (f *fakeStore) ListPRFactsForSession(_ context.Context, id domain.SessionID) ([]domain.PRFacts, error) {
pr, ok := f.pr[id]
if !ok {
return nil, nil
}
return []domain.PRFacts{pr}, nil
}
func (f *fakeStore) ListPRComments(context.Context, string) ([]domain.PullRequestComment, error) {
return nil, nil
}
func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) {
p, ok := f.projects[id]
return p, ok, nil
}
func TestSessionListDerivesStatusFromPRFacts(t *testing.T) {
st := newFakeStore()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}}
st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing}
list, err := (&Service{store: st}).List(context.Background(), ListFilter{ProjectID: "mer"})
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Status != domain.StatusCIFailed {
t.Fatalf("got %+v", list)
}
}
func TestSessionRenameUpdatesDisplayName(t *testing.T) {
st := newFakeStore()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"}
err := (&Service{store: st}).Rename(context.Background(), "mer-1", " Fix issue #90 ")
if err != nil {
t.Fatal(err)
}
if got := st.sessions["mer-1"].DisplayName; got != "Fix issue #90" {
t.Fatalf("display name = %q, want trimmed rename", got)
}
}
func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) {
st := newFakeStore()
err := (&Service{store: st}).Rename(context.Background(), "mer-404", "Missing")
var e *apierr.Error
if !errors.As(err, &e) || e.Kind != apierr.KindNotFound || e.Code != "SESSION_NOT_FOUND" {
t.Fatalf("err = %v, want apierr NotFound SESSION_NOT_FOUND", err)
}
}
// fakeCommander records Kill/Spawn calls so a test can assert the
// clean-orchestrator ordering without wiring a real session engine.
type fakeCommander struct {
killed []domain.SessionID
cleanupProjects []domain.ProjectID
killErr error
cleanupErr error
spawnErr error
spawned bool
killsAtSpawn int
}
func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) {
if f.spawnErr != nil {
return domain.SessionRecord{}, f.spawnErr
}
f.spawned = true
f.killsAtSpawn = len(f.killed)
return domain.SessionRecord{ID: "mer-9", ProjectID: cfg.ProjectID, Kind: cfg.Kind, Harness: cfg.Harness}, nil
}
func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) {
return domain.SessionRecord{}, nil
}
func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, error) {
if f.killErr != nil {
return false, f.killErr
}
f.killed = append(f.killed, id)
return true, nil
}
func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil }
func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) {
f.cleanupProjects = append(f.cleanupProjects, project)
if f.cleanupErr != nil {
return sessionmanager.CleanupResult{}, f.cleanupErr
}
return sessionmanager.CleanupResult{
Cleaned: []domain.SessionID{"mer-1"},
Skipped: []sessionmanager.CleanupSkip{{SessionID: "mer-2", Reason: "workspace has uncommitted changes"}},
}, nil
}
func (f *fakeCommander) RollbackSpawn(context.Context, domain.SessionID) (bool, bool, error) {
return false, false, nil
}
// TestCleanupMapsManagerResult: the service forwards both reclaimed and
// skipped sessions, with non-nil slices so the wire shape stays stable.
func TestCleanupMapsManagerResult(t *testing.T) {
svc := &Service{manager: &fakeCommander{}}
out, err := svc.Cleanup(context.Background(), "mer")
if err != nil {
t.Fatalf("Cleanup: %v", err)
}
if len(out.Cleaned) != 1 || out.Cleaned[0] != "mer-1" {
t.Fatalf("cleaned = %#v", out.Cleaned)
}
if len(out.Skipped) != 1 || out.Skipped[0].SessionID != "mer-2" || out.Skipped[0].Reason != "workspace has uncommitted changes" {
t.Fatalf("skipped = %#v", out.Skipped)
}
}
func TestTeardownProjectKillsActiveSessionsThenCleansProject(t *testing.T) {
st := newFakeStore()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"}
st.sessions["mer-2"] = domain.SessionRecord{ID: "mer-2", ProjectID: "mer", IsTerminated: true}
st.sessions["other-1"] = domain.SessionRecord{ID: "other-1", ProjectID: "other"}
fc := &fakeCommander{}
svc := &Service{manager: fc, store: st}
if err := svc.TeardownProject(context.Background(), "mer"); err != nil {
t.Fatalf("TeardownProject: %v", err)
}
if len(fc.killed) != 1 || fc.killed[0] != "mer-1" {
t.Fatalf("killed = %#v, want only mer-1", fc.killed)
}
if len(fc.cleanupProjects) != 1 || fc.cleanupProjects[0] != "mer" {
t.Fatalf("cleanup projects = %#v, want [mer]", fc.cleanupProjects)
}
}
func TestTeardownProjectStopsOnKillError(t *testing.T) {
st := newFakeStore()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"}
boom := errors.New("boom")
fc := &fakeCommander{killErr: boom}
svc := &Service{manager: fc, store: st}
err := svc.TeardownProject(context.Background(), "mer")
if !errors.Is(err, boom) {
t.Fatalf("TeardownProject err = %v, want boom", err)
}
if len(fc.cleanupProjects) != 0 {
t.Fatalf("cleanup projects = %#v, want none after kill failure", fc.cleanupProjects)
}
}
func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
// Two active orchestrators plus an unrelated worker and a terminated
// orchestrator that must be left alone.
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
st.sessions["mer-2"] = domain.SessionRecord{ID: "mer-2", ProjectID: "mer", Kind: domain.KindOrchestrator}
st.sessions["mer-3"] = domain.SessionRecord{ID: "mer-3", ProjectID: "mer", Kind: domain.KindWorker}
st.sessions["mer-4"] = domain.SessionRecord{ID: "mer-4", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
fc := &fakeCommander{}
svc := &Service{manager: fc, store: st}
if _, err := svc.SpawnOrchestrator(context.Background(), "mer", true); err != nil {
t.Fatalf("SpawnOrchestrator: %v", err)
}
if len(fc.killed) != 2 {
t.Fatalf("killed = %v, want the two active orchestrators", fc.killed)
}
if !fc.spawned || fc.killsAtSpawn != 2 {
t.Fatalf("spawn must run after both kills: spawned=%v killsAtSpawn=%d", fc.spawned, fc.killsAtSpawn)
}
}
// TestSpawnUnknownProjectReturns404 covers Bug 1: an HTTP spawn for an
// unregistered projectId must surface PROJECT_NOT_FOUND (apierr.NotFound)
// BEFORE any session row is created, so no orphan terminated row is left
// behind under `--include-terminated`.
func TestSpawnUnknownProjectReturns404(t *testing.T) {
st := newFakeStore()
fc := &fakeCommander{}
svc := &Service{manager: fc, store: st}
_, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "ghost", Kind: domain.KindWorker})
var e *apierr.Error
if !errors.As(err, &e) || e.Kind != apierr.KindNotFound || e.Code != "PROJECT_NOT_FOUND" {
t.Fatalf("err = %v, want apierr.NotFound PROJECT_NOT_FOUND", err)
}
if fc.spawned {
t.Fatal("manager.Spawn must NOT be invoked for an unknown project")
}
}
func TestSpawnEmitsFirstSessionOnboardingAndDuration(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", RegisteredAt: time.Unix(100, 0).UTC()}
sink := &fakeTelemetrySink{}
fc := &fakeCommander{}
svc := NewWithDeps(Deps{
Manager: fc,
Store: st,
Telemetry: sink,
Clock: func() time.Time { return time.Unix(102, 0).UTC() },
})
if _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer"}); err != nil {
t.Fatalf("Spawn: %v", err)
}
if len(sink.events) != 2 {
t.Fatalf("events = %#v, want spawned + first_session", sink.events)
}
if sink.events[0].Name != "ao.session.spawned" || sink.events[1].Name != "ao.onboarding.first_session_spawned" {
t.Fatalf("event names = %#v", []string{sink.events[0].Name, sink.events[1].Name})
}
if got := sink.events[0].Payload["duration_ms"]; got != int64(0) {
t.Fatalf("spawn duration_ms = %#v, want 0 with fixed clock", got)
}
if got := sink.events[1].Payload["since_first_project_ms"]; got != int64(2000) {
t.Fatalf("since_first_project_ms = %#v, want 2000", got)
}
}
func TestSpawnFailedEmitsDuration(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
sink := &fakeTelemetrySink{}
fc := &fakeCommander{spawnErr: errors.New("boom")}
now := time.Unix(200, 0).UTC()
svc := NewWithDeps(Deps{
Manager: fc,
Store: st,
Telemetry: sink,
Clock: func() time.Time {
v := now
now = now.Add(1500 * time.Millisecond)
return v
},
})
if _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer"}); err == nil {
t.Fatal("Spawn should fail")
}
if len(sink.events) != 1 || sink.events[0].Name != "ao.session.spawn_failed" {
t.Fatalf("events = %#v, want one spawn_failed", sink.events)
}
if got := sink.events[0].Payload["duration_ms"]; got != int64(1500) {
t.Fatalf("spawn_failed duration_ms = %#v, want 1500", got)
}
if got := sink.events[0].Payload["error_kind"]; got != "internal" {
t.Fatalf("spawn_failed error_kind = %#v, want internal", got)
}
if got := sink.events[0].Payload["component"]; got != "session_service" {
t.Fatalf("spawn_failed component = %#v, want session_service", got)
}
if got := sink.events[0].Payload["operation"]; got != "spawn_session" {
t.Fatalf("spawn_failed operation = %#v, want spawn_session", got)
}
if got := sink.events[0].Payload["fingerprint"]; got == "" {
t.Fatalf("spawn_failed fingerprint = %#v, want non-empty", got)
}
}
func TestSpawnEmitsTelemetryOnSuccess(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
st.sessions["old-1"] = domain.SessionRecord{ID: "old-1", ProjectID: "other"}
fc := &fakeCommander{}
ts := &fakeTelemetrySink{}
svc := NewWithDeps(Deps{Manager: fc, Store: st, Telemetry: ts, Clock: func() time.Time { return time.Unix(1700000000, 0).UTC() }})
_, err := svc.Spawn(context.Background(), ports.SpawnConfig{
ProjectID: "mer",
Kind: domain.KindWorker,
Harness: domain.HarnessCodex,
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
if len(ts.events) != 1 {
t.Fatalf("telemetry events = %d, want 1", len(ts.events))
}
ev := ts.events[0]
if ev.Name != "ao.session.spawned" || ev.Source != "session_service" {
t.Fatalf("event = %+v", ev)
}
if ev.ProjectID == nil || *ev.ProjectID != "mer" || ev.SessionID == nil || *ev.SessionID != "mer-9" {
t.Fatalf("event ids = %+v", ev)
}
}
func TestSpawnEmitsTelemetryOnFailure(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
fc := &fakeCommander{spawnErr: errors.New("boom")}
ts := &fakeTelemetrySink{}
svc := NewWithDeps(Deps{Manager: fc, Store: st, Telemetry: ts, Clock: func() time.Time { return time.Unix(1700000000, 0).UTC() }})
_, err := svc.Spawn(context.Background(), ports.SpawnConfig{
ProjectID: "mer",
Kind: domain.KindWorker,
Harness: domain.HarnessCodex,
})
if err == nil {
t.Fatal("Spawn error = nil, want failure")
}
if len(ts.events) != 1 {
t.Fatalf("telemetry events = %d, want 1", len(ts.events))
}
ev := ts.events[0]
if ev.Name != "ao.session.spawn_failed" || ev.Source != "session_service" || ev.Level != ports.TelemetryLevelError {
t.Fatalf("event = %+v", ev)
}
if ev.ProjectID == nil || *ev.ProjectID != "mer" || ev.SessionID != nil {
t.Fatalf("event ids = %+v", ev)
}
if got := ev.Payload["error_kind"]; got != "internal" {
t.Fatalf("event payload error_kind = %#v, want internal", got)
}
if got := ev.Payload["component"]; got != "session_service" {
t.Fatalf("event payload component = %#v, want session_service", got)
}
if got := ev.Payload["operation"]; got != "spawn_session" {
t.Fatalf("event payload operation = %#v, want spawn_session", got)
}
if got := ev.Payload["fingerprint"]; got == "" {
t.Fatalf("event payload fingerprint = %#v, want non-empty", got)
}
if _, ok := ev.Payload["error"]; ok {
t.Fatalf("event payload leaked raw error: %+v", ev.Payload)
}
}
func TestSpawnEmitsTypedErrorCodeOnFailure(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
fc := &fakeCommander{spawnErr: fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus")}
ts := &fakeTelemetrySink{}
svc := NewWithDeps(Deps{Manager: fc, Store: st, Telemetry: ts, Clock: func() time.Time { return time.Unix(1700000000, 0).UTC() }})
_, err := svc.Spawn(context.Background(), ports.SpawnConfig{
ProjectID: "mer",
Kind: domain.KindWorker,
Harness: domain.HarnessCodex,
})
if err == nil {
t.Fatal("Spawn error = nil, want failure")
}
if len(ts.events) != 1 {
t.Fatalf("telemetry events = %d, want 1", len(ts.events))
}
ev := ts.events[0]
if got := ev.Payload["error_kind"]; got != "invalid" {
t.Fatalf("event payload error_kind = %#v, want invalid", got)
}
if got := ev.Payload["error_code"]; got != "UNKNOWN_HARNESS" {
t.Fatalf("event payload error_code = %#v, want UNKNOWN_HARNESS", got)
}
}
// TestSpawnOrchestratorUnknownProjectReturns404 is the orchestrator-side guard
// for Bug 1: same pre-validation, same typed envelope.
func TestSpawnOrchestratorUnknownProjectReturns404(t *testing.T) {
st := newFakeStore()
fc := &fakeCommander{}
svc := &Service{manager: fc, store: st}
_, err := svc.SpawnOrchestrator(context.Background(), "ghost", false)
var e *apierr.Error
if !errors.As(err, &e) || e.Kind != apierr.KindNotFound || e.Code != "PROJECT_NOT_FOUND" {
t.Fatalf("err = %v, want apierr.NotFound PROJECT_NOT_FOUND", err)
}
if fc.spawned {
t.Fatal("manager.Spawn must NOT be invoked for an unknown project")
}
}
// TestToAPIErrorMapsWorkspaceBranchSentinels covers Bug 3: the workspace
// adapter's typed branch errors map to typed envelope errors instead of
// collapsing to a 500.
func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) {
cases := []struct {
name string
err error
wantKind apierr.Kind
wantCode string
}{
{"checked out elsewhere", fmt.Errorf("spawn mer-1: workspace: %w: \"x\" is checked out at \"/tmp\"", ports.ErrWorkspaceBranchCheckedOutElsewhere), apierr.KindConflict, "BRANCH_CHECKED_OUT_ELSEWHERE"},
{"not fetched", fmt.Errorf("spawn mer-1: workspace: %w: \"x\" has no local head", ports.ErrWorkspaceBranchNotFetched), apierr.KindInvalid, "BRANCH_NOT_FETCHED"},
{"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"},
{"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"},
{"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"},
{"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mapped := toAPIError(tc.err)
var e *apierr.Error
if !errors.As(mapped, &e) || e.Kind != tc.wantKind || e.Code != tc.wantCode {
t.Fatalf("mapped = %v, want %s %s", mapped, tc.wantCode, e)
}
})
}
}
func TestSpawnOrchestratorNoCleanSkipsKills(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
fc := &fakeCommander{}
svc := &Service{manager: fc, store: st}
if _, err := svc.SpawnOrchestrator(context.Background(), "mer", false); err != nil {
t.Fatalf("SpawnOrchestrator: %v", err)
}
if len(fc.killed) != 0 || !fc.spawned {
t.Fatalf("clean=false must spawn without kills: killed=%v spawned=%v", fc.killed, fc.spawned)
}
}
type fakePRClaimer struct {
out errorFreeClaimOutcome
err error
}
type errorFreeClaimOutcome struct {
ports.ClaimOutcome
}
func (f fakePRClaimer) ClaimPR(context.Context, domain.PullRequest, []domain.PullRequestCheck, []domain.PullRequestReviewThread, []domain.PullRequestComment, ports.ReviewWriteMode, bool) (ports.ClaimOutcome, error) {
return f.out.ClaimOutcome, f.err
}
type fakeSCM struct {
obs ports.SCMObservation
review ports.SCMReviewObservation
fetchErr error
reviewErr error
}
func (f fakeSCM) ParseRepository(remote string) (ports.SCMRepo, bool) {
owner, repo, err := githubRepoFromURL(remote)
if err != nil {
return ports.SCMRepo{}, false
}
return ports.SCMRepo{Provider: "github", Host: "github.com", Owner: owner, Name: repo, Repo: owner + "/" + repo}, true
}
func (f fakeSCM) FetchPullRequests(context.Context, []ports.SCMPRRef) ([]ports.SCMObservation, error) {
if f.fetchErr != nil {
return nil, f.fetchErr
}
if !f.obs.Fetched && f.obs.PR.URL == "" && f.obs.PR.Number == 0 {
return nil, nil
}
return []ports.SCMObservation{f.obs}, nil
}
func (f fakeSCM) FetchReviewThreads(context.Context, ports.SCMPRRef) (ports.SCMReviewObservation, error) {
return f.review, f.reviewErr
}
func TestClaimPRMapsObserverAndStoreErrors(t *testing.T) {
st := newFakeStore()
now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker, Metadata: domain.SessionMetadata{WorkspacePath: "/ws"}}
st.projects["mer"] = domain.ProjectRecord{ID: "mer", RepoOriginURL: "https://github.com/acme/repo"}
cases := []struct {
name string
svc *Service
want error
}{
{"missing scm", NewWithDeps(Deps{Store: st}), ErrSCMUnavailable},
{"not found", NewWithDeps(Deps{Store: st, PRClaimer: fakePRClaimer{}, SCM: fakeSCM{fetchErr: ports.ErrSCMNotFound}}), ErrPRNotFound},
{"closed", NewWithDeps(Deps{Store: st, PRClaimer: fakePRClaimer{}, SCM: fakeSCM{obs: ports.SCMObservation{Fetched: true, Provider: "github", Host: "github.com", Repo: "acme/repo", PR: ports.SCMPRObservation{URL: "https://github.com/acme/repo/pull/7", Number: 7, Closed: true}}}}), ErrPRNotOpen},
{"active owner", NewWithDeps(Deps{Store: st, PRClaimer: fakePRClaimer{err: ports.PRClaimedByActiveSessionError{Owner: "mer-2"}}, SCM: fakeSCM{obs: ports.SCMObservation{Fetched: true, Provider: "github", Host: "github.com", Repo: "acme/repo", PR: ports.SCMPRObservation{URL: "https://github.com/acme/repo/pull/7", Number: 7}}}}), ports.ErrPRClaimedByActiveSession},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.svc.ClaimPR(context.Background(), "mer-1", "7", ClaimPROptions{AllowTakeover: false})
if !errors.Is(err, tc.want) {
t.Fatalf("err=%v, want %v", err, tc.want)
}
})
}
st.pr["mer-1"] = domain.PRFacts{URL: "https://github.com/acme/repo/pull/7", Number: 7, CI: domain.CIPassing, UpdatedAt: now}
svc := NewWithDeps(Deps{Store: st, PRClaimer: fakePRClaimer{out: errorFreeClaimOutcome{ports.ClaimOutcome{PreviousOwner: "mer-2"}}}, SCM: fakeSCM{obs: ports.SCMObservation{Fetched: true, Provider: "github", Host: "github.com", Repo: "acme/repo", PR: ports.SCMPRObservation{URL: "https://github.com/acme/repo/pull/7", Number: 7}}}})
res, err := svc.ClaimPR(context.Background(), "mer-1", "7", ClaimPROptions{AllowTakeover: true})
if err != nil {
t.Fatal(err)
}
if len(res.TakenOverFrom) != 1 || res.TakenOverFrom[0] != "mer-2" || len(res.PRs) != 1 || res.PRs[0].URL == "" {
t.Fatalf("claim result = %+v", res)
}
}
func TestListPRsOrdersActiveBeforeClosedThenUpdatedDesc(t *testing.T) {
st := newFakeStore()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker}
now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
st.pr = map[domain.SessionID]domain.PRFacts{}
stList := &multiPRFakeStore{fakeStore: st, prs: []domain.PullRequest{
{URL: "closed-new", SessionID: "mer-1", Number: 1, Closed: true, UpdatedAt: now.Add(2 * time.Hour)},
{URL: "open-old", SessionID: "mer-1", Number: 2, UpdatedAt: now},
{URL: "open-new", SessionID: "mer-1", Number: 3, UpdatedAt: now.Add(time.Hour)},
}}
got, err := (&Service{store: stList}).ListPRs(context.Background(), "mer-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 || got[0].URL != "open-new" || got[1].URL != "open-old" || got[2].URL != "closed-new" {
t.Fatalf("order = %+v", got)
}
}
type multiPRFakeStore struct {
*fakeStore
prs []domain.PullRequest
}
func (f *multiPRFakeStore) ListPRsBySession(context.Context, domain.SessionID) ([]domain.PullRequest, error) {
return f.prs, nil
}