// Package integration exercises the lifecycle + session lane against the real // SQLite store and the real CDC trigger pipeline. Unit tests stay on the // in-memory fakes in lifecycle/ and session/; these live-fire tests prove the // wiring across packages actually flows: SM -> store row -> LCM mutate -> store // update -> DB trigger -> change_log read. package integration import ( "context" "io" "log/slog" "path/filepath" "strings" "sync" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/notification" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) // ---- plugin fakes (minimal: only enough to drive SM through real LCM) ---- type stubRuntime struct { id, name string } func (s *stubRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { return ports.RuntimeHandle{ID: s.id, RuntimeName: s.name}, nil } func (s *stubRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { return true, nil } type stubAgent struct{} func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string { return map[string]string{} } func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id } type stubWorkspace struct { root string } func (w *stubWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { return ports.WorkspaceInfo{ Path: filepath.Join(w.root, string(cfg.SessionID)), Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, }, nil } func (w *stubWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { return nil } func (w *stubWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { return w.Create(ctx, cfg) } type captureMessenger struct { mu sync.Mutex msgs []string } func (m *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error { m.mu.Lock() defer m.mu.Unlock() m.msgs = append(m.msgs, msg) return nil } func (m *captureMessenger) drain() []string { m.mu.Lock() defer m.mu.Unlock() out := append([]string(nil), m.msgs...) m.msgs = nil return out } type captureNotifier struct { mu sync.Mutex events []ports.Event } func (n *captureNotifier) Notify(_ context.Context, e ports.Event) error { n.mu.Lock() defer n.mu.Unlock() n.events = append(n.events, e) return nil } func (n *captureNotifier) drain() []ports.Event { n.mu.Lock() defer n.mu.Unlock() out := append([]ports.Event(nil), n.events...) n.events = nil return out } // ---- harness: real store + real LCM + real SM + change_log poller ---- type liveStack struct { dataDir string store *sqlite.Store lcm *lifecycle.Manager sm *session.Manager notifier *captureNotifier messenger *captureMessenger closed bool // guard so the explicit close() and t.Cleanup don't double-close } // openLiveStack opens the store + hydrates the LCM/SM and registers an // idempotent t.Cleanup so a mid-test t.Fatalf can't leak the SQLite handle. // Tests that need to simulate a daemon restart still call close() explicitly // between phases; the cleanup hook becomes a no-op once that runs. func openLiveStack(t *testing.T, dataDir string) *liveStack { t.Helper() store, err := sqlite.Open(dataDir) if err != nil { t.Fatalf("open sqlite: %v", err) } notifier := &captureNotifier{} messenger := &captureMessenger{} lcm := lifecycle.New(store, store, notifier, messenger) wsRoot := t.TempDir() sm := session.New(session.Deps{ Runtime: &stubRuntime{id: "h1", name: "tmux"}, Agent: stubAgent{}, Workspace: &stubWorkspace{root: wsRoot}, Store: store, Messenger: messenger, Lifecycle: lcm, }) st := &liveStack{ dataDir: dataDir, store: store, lcm: lcm, sm: sm, notifier: notifier, messenger: messenger, } t.Cleanup(func() { if st.closed { return } // Best-effort: failures here would be noise after t.Fatalf already // recorded the real cause. _ = st.store.Close() st.closed = true }) return st } func (s *liveStack) close(t *testing.T) { t.Helper() if s.closed { return } s.closed = true if err := s.store.Close(); err != nil { t.Fatalf("close store: %v", err) } } func seedProject(t *testing.T, store *sqlite.Store, id string) { t.Helper() if err := store.UpsertProject(context.Background(), sqlite.ProjectRow{ ID: id, Path: "/repo/" + id, RegisteredAt: time.Now(), }); err != nil { t.Fatalf("upsert project: %v", err) } } func durableLifecycle(store *sqlite.Store, messenger ports.AgentMessenger) *lifecycle.Manager { renderer := notification.NewRenderer(store) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) notifier := notification.NewEnqueuer(store, renderer, logger) return lifecycle.New(store, store, notifier, messenger) } func durableRecord(project, issue, branch string) domain.SessionRecord { now := time.Now().UTC().Truncate(time.Second) return domain.SessionRecord{ ProjectID: domain.ProjectID(project), IssueID: domain.IssueID(issue), Kind: domain.KindWorker, Lifecycle: domain.CanonicalSessionLifecycle{ Version: domain.LifecycleVersion, Session: domain.SessionSubstate{State: domain.SessionWorking}, IsAlive: true, Activity: domain.ActivitySubstate{ State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceHook, }, }, Metadata: domain.SessionMetadata{Branch: branch, WorkspacePath: "/workspace/" + branch}, CreatedAt: now, UpdatedAt: now, } } // ---- tests ---- // TestHappyPath drives Spawn -> SCM PR observation (open + CI passing) -> Kill, // asserting via direct store reads that the canonical row, the PR row, and the // change_log stream all reflect what each step contributed. func TestHappyPath_Spawn_PR_Kill(t *testing.T) { t.Parallel() ctx := context.Background() st := openLiveStack(t, t.TempDir()) defer st.close(t) seedProject(t, st.store, "mer") // 1. Spawn — SM inserts the session row, LCM marks it live. We only assert // the structural invariant of the id (project-scoped, non-empty), not the // literal counter — that's a store-internal detail. sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ ProjectID: "mer", Kind: domain.KindWorker, Prompt: "ship it", }) if err != nil { t.Fatalf("spawn: %v", err) } if sess.ID == "" || !strings.HasPrefix(string(sess.ID), "mer-") { t.Fatalf("expected project-scoped id like mer-N, got %q", sess.ID) } rec, ok, err := st.store.GetSession(ctx, sess.ID) if err != nil || !ok { t.Fatalf("get session: ok=%v err=%v", ok, err) } if !rec.Lifecycle.IsAlive { t.Fatal("post-spawn: is_alive should be true") } if rec.Lifecycle.Session.State != domain.SessionNotStarted { t.Fatalf("post-spawn state want not_started, got %q", rec.Lifecycle.Session.State) } if rec.Metadata.RuntimeHandleID != "h1" || rec.Metadata.RuntimeName != "tmux" { t.Fatalf("post-spawn handles missing: %+v", rec.Metadata) } if rec.Metadata.WorkspacePath == "" || rec.Metadata.Prompt != "ship it" { t.Fatalf("post-spawn metadata missing: %+v", rec.Metadata) } // 2. SCM observes a fresh PR — open, CI passing. LCM writes the pr row // atomically (one tx, triggers fire pr_created). prURL := "https://github.com/repo/mer/pull/1" if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ Fetched: true, URL: prURL, Number: 1, CI: domain.CIPassing, Review: domain.ReviewNone, Mergeability: domain.MergeMergeable, Checks: []domain.PRCheckRow{{ Name: "ci/build", CommitHash: "abc123", Status: "passed", CreatedAt: time.Now(), }}, }); err != nil { t.Fatalf("apply pr: %v", err) } prRow, ok, err := st.store.GetPR(ctx, prURL) if err != nil || !ok { t.Fatalf("get pr: ok=%v err=%v", ok, err) } if prRow.SessionID != string(sess.ID) || prRow.CI != domain.CIPassing || prRow.Draft || prRow.Merged || prRow.Closed { t.Fatalf("pr row wrong: %+v", prRow) } // 3. Kill — SM routes to LCM and tears down runtime+workspace. freed, err := st.sm.Kill(ctx, sess.ID, domain.TermManuallyKilled) if err != nil || !freed { t.Fatalf("kill freed=%v err=%v", freed, err) } rec, _, _ = st.store.GetSession(ctx, sess.ID) if rec.Lifecycle.Session.State != domain.SessionTerminated || rec.Lifecycle.TerminationReason != domain.TermManuallyKilled || rec.Lifecycle.IsAlive { t.Fatalf("post-kill canonical wrong: %+v", rec.Lifecycle) } // 4. Assert the change_log captured the full timeline. The DB triggers // write the only durable CDC; we don't want to assume an ordering of // interleaved events, just that each expected event_type shows up. rows, err := st.store.ReadChangeLogAfter(ctx, 0, 100) if err != nil { t.Fatalf("read change_log: %v", err) } seen := map[string]bool{} for _, r := range rows { seen[r.EventType] = true } for _, want := range []string{"session_created", "session_updated", "pr_created", "pr_check_recorded"} { if !seen[want] { t.Fatalf("missing change_log event %q (got: %v)", want, seen) } } } // TestRestoreRoundTrip simulates a daemon restart: spawn a session, persist the // kill, fully close the in-process LCM/SM, open a fresh stack against the SAME // DB file, and Restore. The restored session must keep its metadata (the agent // session id is the must-survive bit). func TestRestoreRoundTrip_PreservesMetadata(t *testing.T) { t.Parallel() ctx := context.Background() dir := t.TempDir() st := openLiveStack(t, dir) seedProject(t, st.store, "mer") // Phase A: spawn with an agent session id, then kill so the row is terminal // and Restore is legal. sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ ProjectID: "mer", Kind: domain.KindWorker, Prompt: "remember me", }) if err != nil { t.Fatalf("spawn: %v", err) } // fold an AgentSessionID into the row — the LCM does this through the spawn // outcome on Restore too, but a fresh spawn doesn't (the agent has not // reported one yet). We patch via the store so the restore branch has // something to resume from. Check ok/err: without it, a missed row would // hand UpdateSession a zero-value record (ID==""), which matches no rows // and returns nil — Phase B would then fail with a misleading "agent id // lost across restart" rather than the real cause. rec, ok, err := st.store.GetSession(ctx, sess.ID) if err != nil || !ok { t.Fatalf("get session for patch: ok=%v err=%v", ok, err) } rec.Metadata.AgentSessionID = "agent-xyz" if err := st.store.UpdateSession(ctx, rec); err != nil { t.Fatalf("patch agent id: %v", err) } if _, err := st.sm.Kill(ctx, sess.ID, domain.TermManuallyKilled); err != nil { t.Fatalf("kill: %v", err) } st.close(t) // Phase B: reopen against the same data dir; everything in memory is gone. st2 := openLiveStack(t, dir) defer st2.close(t) // Confirm the row survived the restart. rec2, ok, err := st2.store.GetSession(ctx, sess.ID) if err != nil || !ok { t.Fatalf("reopen get: ok=%v err=%v", ok, err) } if rec2.Metadata.AgentSessionID != "agent-xyz" { t.Fatalf("agent session id lost across restart: %+v", rec2.Metadata) } if rec2.Lifecycle.Session.State != domain.SessionTerminated { t.Fatalf("expected terminal after reopen, got %q", rec2.Lifecycle.Session.State) } // Phase C: Restore — must drive a fresh OnSpawnCompleted and surface the // preserved AgentSessionID into the new outcome. restored, err := st2.sm.Restore(ctx, sess.ID) if err != nil { t.Fatalf("restore: %v", err) } if !restored.Lifecycle.IsAlive { t.Fatal("restored session should be is_alive after spawn-completed") } if restored.Metadata.AgentSessionID != "agent-xyz" { t.Fatalf("restored row dropped AgentSessionID: %+v", restored.Metadata) } } // TestCIFailureAndRecovery drives the CI-failed reaction path: a failing // observation injects a nudge into the agent (messenger), a recovery // observation (CI passing) flips state without re-firing the nudge, and the // pr_checks history records both runs so the brake's "last 3 all failed" query // reads the truth. func TestCIFailureAndRecovery_NudgeThenClears(t *testing.T) { t.Parallel() ctx := context.Background() st := openLiveStack(t, t.TempDir()) defer st.close(t) seedProject(t, st.store, "mer") sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) if err != nil { t.Fatalf("spawn: %v", err) } // Move the session out of not_started so the reaction path engages on real // PR facts (not_started doesn't react on PRs). if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), }); err != nil { t.Fatalf("activity: %v", err) } _ = st.messenger.drain() // ignore startup nudges, focus on CI prURL := "https://github.com/repo/mer/pull/2" // Failing CI: handleCIFailure should send a CI-failed nudge with the log // tail injected. if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ Fetched: true, URL: prURL, Number: 2, CI: domain.CIFailing, Mergeability: domain.MergeUnstable, Checks: []domain.PRCheckRow{{ Name: "ci/build", CommitHash: "c1", Status: "failed", LogTail: "panic: nil map", CreatedAt: time.Now(), }}, }); err != nil { t.Fatalf("apply pr (failing): %v", err) } got := st.messenger.drain() if len(got) == 0 { t.Fatal("expected CI-failed nudge to the agent") } if !strings.Contains(got[0], "CI is failing") || !strings.Contains(got[0], "panic: nil map") { t.Fatalf("ci-failed message missing content: %q", got[0]) } // Brake confirmation: only one failure so far, RecentCheckStatuses should // reflect it. history, err := st.store.RecentCheckStatuses(ctx, prURL, "ci/build", 3) if err != nil { t.Fatalf("recent checks: %v", err) } if len(history) != 1 || history[0] != "failed" { t.Fatalf("ci history wrong: %v", history) } // Recovery: CI passing on a new commit. With the dedupe slot still on // rxCIFailed, the dispatch path moves to rxApprovedGreen (mergeable) and // the human notifier is the one that pages. if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ Fetched: true, URL: prURL, Number: 2, CI: domain.CIPassing, Mergeability: domain.MergeMergeable, Checks: []domain.PRCheckRow{{ Name: "ci/build", CommitHash: "c2", Status: "passed", CreatedAt: time.Now(), }}, }); err != nil { t.Fatalf("apply pr (recovery): %v", err) } ev := st.notifier.drain() if len(ev) == 0 { t.Fatal("recovery: notifier should have received an event (approved-and-green)") } if !anyEventType(ev, "reaction.approved-and-green") { t.Fatalf("recovery should notify approved-and-green, got %+v", ev) } // And the pr row reflects the recovery in the canonical fact store. prRow, ok, _ := st.store.GetPR(ctx, prURL) if !ok || prRow.CI != domain.CIPassing { t.Fatalf("pr ci_state should be passing post-recovery: %+v", prRow) } } // TestDetectingPersistsAcrossRestart drives the runtime quarantine path: a // failed probe puts the session into the detecting state, which means the // decider's anti-flap memory MUST be flushed to the detecting_* columns and // survive a restart. A subsequent alive probe must clear it. func TestDetectingPersistsAcrossRestart(t *testing.T) { t.Parallel() ctx := context.Background() dir := t.TempDir() st := openLiveStack(t, dir) seedProject(t, st.store, "mer") sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) if err != nil { t.Fatalf("spawn: %v", err) } // Move to working so the runtime decider doesn't bail on not_started. if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), }); err != nil { t.Fatalf("activity: %v", err) } // One failed probe should park the session in detecting with attempts=1. if err := st.lcm.ApplyRuntimeObservation(ctx, sess.ID, ports.RuntimeFacts{ ObservedAt: time.Now(), Runtime: ports.ProbeFailed, Process: ports.ProbeFailed, }); err != nil { t.Fatalf("apply runtime: %v", err) } rec, ok, err := st.store.GetSession(ctx, sess.ID) if err != nil || !ok { t.Fatalf("get session post-probe: ok=%v err=%v", ok, err) } if rec.Lifecycle.Session.State != domain.SessionDetecting { t.Fatalf("expected detecting state, got %q", rec.Lifecycle.Session.State) } if rec.Lifecycle.Detecting == nil || rec.Lifecycle.Detecting.Attempts == 0 { t.Fatalf("detecting memory should be populated: %+v", rec.Lifecycle.Detecting) } // Restart: close, reopen, verify the detecting_* columns round-tripped. st.close(t) st2 := openLiveStack(t, dir) defer st2.close(t) rec2, ok, _ := st2.store.GetSession(ctx, sess.ID) if !ok || rec2.Lifecycle.Detecting == nil { t.Fatalf("detecting lost across restart: %+v", rec2.Lifecycle) } if rec2.Lifecycle.Detecting.Attempts != rec.Lifecycle.Detecting.Attempts { t.Fatalf("attempts round-trip mismatch: pre=%d post=%d", rec.Lifecycle.Detecting.Attempts, rec2.Lifecycle.Detecting.Attempts) } if rec2.Lifecycle.Detecting.EvidenceHash != rec.Lifecycle.Detecting.EvidenceHash { t.Fatal("evidence hash dropped across restart") } // Recovery probe — alive — must clear detecting and flip state out of it. if err := st2.lcm.ApplyRuntimeObservation(ctx, sess.ID, ports.RuntimeFacts{ ObservedAt: time.Now(), Runtime: ports.ProbeAlive, Process: ports.ProbeAlive, }); err != nil { t.Fatalf("recovery probe: %v", err) } rec3, ok3, err := st2.store.GetSession(ctx, sess.ID) if err != nil || !ok3 { t.Fatalf("get session post-recovery: ok=%v err=%v", ok3, err) } if rec3.Lifecycle.Detecting != nil { t.Fatalf("alive probe should clear detecting, got %+v", rec3.Lifecycle.Detecting) } if rec3.Lifecycle.Session.State == domain.SessionDetecting { t.Fatalf("session state should leave detecting, got %q", rec3.Lifecycle.Session.State) } } // TestCDCPollerReceivesAllStages drives the full real pipeline including the // in-process CDC poller — proving the trigger writes become broadcaster events // in the same order the storage layer observes them. func TestCDCPollerReceivesAllStages(t *testing.T) { t.Parallel() ctx := context.Background() st := openLiveStack(t, t.TempDir()) defer st.close(t) seedProject(t, st.store, "mer") bcast := cdc.NewBroadcaster() src := pollerSource{st.store} poller := cdc.NewPoller(src, bcast, cdc.PollerConfig{Batch: 100}) var ( mu sync.Mutex events []cdc.Event ) bcast.Subscribe(func(e cdc.Event) { mu.Lock() defer mu.Unlock() events = append(events, e) }) sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) if err != nil { t.Fatalf("spawn: %v", err) } if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), }); err != nil { t.Fatalf("activity: %v", err) } if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ Fetched: true, URL: "https://github.com/repo/mer/pull/3", Number: 3, CI: domain.CIPassing, Mergeability: domain.MergeMergeable, }); err != nil { t.Fatalf("apply pr: %v", err) } if err := poller.Poll(ctx); err != nil { t.Fatalf("poll: %v", err) } mu.Lock() defer mu.Unlock() types := map[cdc.EventType]bool{} for _, e := range events { types[e.Type] = true } for _, want := range []cdc.EventType{cdc.EventSessionCreated, cdc.EventSessionUpdated, cdc.EventPRCreated} { if !types[want] { t.Fatalf("poller missed event %q (got %+v)", want, types) } } // Seq monotonicity invariant — the wiring assumes it; assert it here. var prev int64 for _, e := range events { if e.Seq <= prev { t.Fatalf("seq not monotonic: %d after %d", e.Seq, prev) } prev = e.Seq } } func TestLifecycleDurableNotification_NeedsInput(t *testing.T) { t.Parallel() ctx := context.Background() store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatalf("open sqlite: %v", err) } defer store.Close() seedProject(t, store, "mer") rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-1", "feat/input")) if err != nil { t.Fatalf("create session: %v", err) } lcm := durableLifecycle(store, &captureMessenger{}) startSeq, _ := store.MaxChangeLogSeq(ctx) if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{ Valid: true, State: domain.ActivityWaitingInput, Source: domain.SourceHook, Timestamp: time.Now(), }); err != nil { t.Fatalf("activity: %v", err) } notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) if err != nil { t.Fatalf("list notifications: %v", err) } if len(notifications) != 1 || notifications[0].SemanticType != "session.needs_input" || notifications[0].DedupeKey == "" { t.Fatalf("needs_input notification missing: %+v", notifications) } assertNotificationCreatedCDC(t, store, startSeq) } func TestLifecycleDurableNotification_ApprovedAndGreen(t *testing.T) { t.Parallel() ctx := context.Background() store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatalf("open sqlite: %v", err) } defer store.Close() seedProject(t, store, "mer") rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-2", "feat/green")) if err != nil { t.Fatalf("create session: %v", err) } lcm := durableLifecycle(store, &captureMessenger{}) if err := lcm.ApplyPRObservation(ctx, rec.ID, ports.PRObservation{ Fetched: true, URL: "https://github.com/org/repo/pull/2", Number: 2, CI: domain.CIPassing, Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable, }); err != nil { t.Fatalf("apply pr: %v", err) } notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) if err != nil { t.Fatalf("list notifications: %v", err) } if len(notifications) != 1 || notifications[0].SemanticType != "merge.ready" { t.Fatalf("approved-and-green notification missing: %+v", notifications) } } func TestLifecycleDurableNotification_PRMerged(t *testing.T) { t.Parallel() ctx := context.Background() store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatalf("open sqlite: %v", err) } defer store.Close() seedProject(t, store, "mer") rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-3", "feat/merge")) if err != nil { t.Fatalf("create session: %v", err) } lcm := durableLifecycle(store, &captureMessenger{}) startSeq, _ := store.MaxChangeLogSeq(ctx) if err := lcm.ApplyPRObservation(ctx, rec.ID, ports.PRObservation{ Fetched: true, URL: "https://github.com/org/repo/pull/3", Number: 3, Merged: true, CI: domain.CIPassing, Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable, }); err != nil { t.Fatalf("apply pr: %v", err) } notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) if err != nil { t.Fatalf("list notifications: %v", err) } if len(notifications) != 1 || notifications[0].SemanticType != "pr.merged" { t.Fatalf("pr_merged notification missing: %+v", notifications) } assertNotificationCreatedCDC(t, store, startSeq) } func assertNotificationCreatedCDC(t *testing.T, store *sqlite.Store, after int64) { t.Helper() evs, err := store.ReadChangeLogAfter(context.Background(), after, 20) if err != nil { t.Fatalf("read change_log: %v", err) } for _, e := range evs { if e.EventType == string(cdc.EventNotificationCreated) { return } } t.Fatalf("missing notification_created CDC after %d: %+v", after, evs) } // ---- small helpers ---- type pollerSource struct{ *sqlite.Store } func (s pollerSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { rows, err := s.Store.ReadChangeLogAfter(ctx, after, limit) if err != nil { return nil, err } out := make([]cdc.Event, len(rows)) for i, r := range rows { out[i] = cdc.Event{ Seq: r.Seq, ProjectID: r.ProjectID, SessionID: r.SessionID, Type: cdc.EventType(r.EventType), Payload: []byte(r.Payload), CreatedAt: r.CreatedAt, } } return out, nil } func (s pollerSource) LatestSeq(ctx context.Context) (int64, error) { return s.Store.MaxChangeLogSeq(ctx) } func anyEventType(evs []ports.Event, t string) bool { for _, e := range evs { if e.Type == t { return true } } return false }