package scheduler import ( "context" "encoding/json" "errors" "testing" "github.com/jackc/pgx/v5/pgxpool" "github.com/riverqueue/river" "github.com/nomadcode/nomadcode-core/internal/projectsync" "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workflow" "github.com/nomadcode/nomadcode-core/internal/workitem" ) // fakeCreationSyncRunner records the input it was driven with so a worker test // can assert the job args were mapped into the orchestrator input. type fakeCreationSyncRunner struct { called bool input roadmapsyncpipeline.SyncCreationInput result roadmapsyncpipeline.SyncCreationResult err error } func (f *fakeCreationSyncRunner) SyncCreation(_ context.Context, in roadmapsyncpipeline.SyncCreationInput) (roadmapsyncpipeline.SyncCreationResult, error) { f.called = true f.input = in return f.result, f.err } func sampleCreationSyncArgs() RoadmapCreationSyncJobArgs { identity := roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: "agent-roadmap/phase/p1/milestones/m1.md", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", } return RoadmapCreationSyncJobArgs{ Expected: identity, Ref: workitem.Ref{Provider: "plane", ID: "NOMAD-1"}, OriginalBody: "

body

", TodoStateID: "todo-state", MilestoneMarkdown: "# m", ProviderRevision: "rev-1", RoadmapRevision: "rev-1", } } func TestRoadmapCreationSyncWorkerCallsService(t *testing.T) { runner := &fakeCreationSyncRunner{ result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionProjected}, } worker := &RoadmapCreationSyncWorker{Sync: runner} args := sampleCreationSyncArgs() err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args}) if err != nil { t.Fatalf("Work returned error: %v", err) } if !runner.called { t.Fatal("expected orchestrator to be called") } if runner.input.Expected.WorkItemID != "NOMAD-1" { t.Errorf("unexpected work item id: %q", runner.input.Expected.WorkItemID) } if runner.input.MilestoneMarkdown != "# m" || runner.input.TodoStateID != "todo-state" { t.Errorf("job args not mapped into orchestrator input: %#v", runner.input) } } func TestRoadmapCreationSyncWorkerReturnsServiceError(t *testing.T) { wantErr := errors.New("provider failed") runner := &fakeCreationSyncRunner{err: wantErr} worker := &RoadmapCreationSyncWorker{Sync: runner} err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: sampleCreationSyncArgs()}) if !errors.Is(err, wantErr) { t.Fatalf("expected service error to propagate (so River retries), got %v", err) } } type fakeTaskFinalizer struct { called bool provider string id string result []byte task storage.Task transitioned bool } func (f *fakeTaskFinalizer) CompleteTaskByExternalRef(ctx context.Context, provider, id string, result json.RawMessage) (storage.Task, bool, error) { f.called = true f.provider = provider f.id = id f.result = result return f.task, f.transitioned, nil } func TestRoadmapCreationSyncWorkerFinalizesExternalTaskOnProjected(t *testing.T) { runner := &fakeCreationSyncRunner{ result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionProjected, Reason: "projected successfully"}, } finalizer := &fakeTaskFinalizer{ transitioned: true, task: storage.Task{ ID: "task-auth-projected", Source: "plane", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), }, } slotUpdater := &fakeWorkspaceSlotUpdater{} worker := &RoadmapCreationSyncWorker{ Sync: runner, TaskFinalizer: finalizer, SlotUpdater: slotUpdater, } args := sampleCreationSyncArgs() err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args}) if err != nil { t.Fatalf("Work returned error: %v", err) } if !runner.called { t.Fatal("expected orchestrator to be called") } if !finalizer.called { t.Fatal("expected finalizer to be called on projected outcome") } if finalizer.provider != "plane" || finalizer.id != "NOMAD-1" { t.Errorf("unexpected finalizer arguments: provider=%q, id=%q", finalizer.provider, finalizer.id) } var resMap map[string]any if err := json.Unmarshal(finalizer.result, &resMap); err != nil { t.Fatalf("fail to unmarshal finalizer result: %v", err) } if resMap["action"] != "projected" || resMap["reason"] != "projected successfully" { t.Errorf("unexpected finalizer result payload: %v", resMap) } if resMap["summary"] != "projected successfully" { t.Errorf("expected summary payload, got %v", resMap["summary"]) } if resMap[workflow.MetadataKeyAuthoringRunState] != "succeeded" { t.Errorf("expected authoring_run_state succeeded, got %v", resMap[workflow.MetadataKeyAuthoringRunState]) } if resMap[workflow.MetadataKeyAuthoringRunUpdatedAt] == nil || resMap[workflow.MetadataKeyAuthoringRunUpdatedAt] == "" { t.Error("expected authoring_run_updated_at to be set") } if len(slotUpdater.calls) != 1 { t.Fatalf("expected 1 slot update, got %d", len(slotUpdater.calls)) } if slotUpdater.calls[0].ID != 1 || slotUpdater.calls[0].State != string(projectsync.SlotStateAvailable) { t.Errorf("expected slot 1 -> available, got %#v", slotUpdater.calls[0]) } } func TestRoadmapCreationSyncWorkerFinalizesExternalTaskOnComplete(t *testing.T) { runner := &fakeCreationSyncRunner{ result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionComplete, Reason: "already complete"}, } finalizer := &fakeTaskFinalizer{} worker := &RoadmapCreationSyncWorker{ Sync: runner, TaskFinalizer: finalizer, } args := sampleCreationSyncArgs() err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args}) if err != nil { t.Fatalf("Work returned error: %v", err) } if !runner.called { t.Fatal("expected orchestrator to be called") } if !finalizer.called { t.Fatal("expected finalizer to be called on complete outcome") } if finalizer.provider != "plane" || finalizer.id != "NOMAD-1" { t.Errorf("unexpected finalizer arguments: provider=%q, id=%q", finalizer.provider, finalizer.id) } var resMap map[string]any if err := json.Unmarshal(finalizer.result, &resMap); err != nil { t.Fatalf("fail to unmarshal finalizer result: %v", err) } if resMap["action"] != "complete" || resMap["reason"] != "already complete" { t.Errorf("unexpected finalizer result payload: %v", resMap) } if resMap["summary"] != "already complete" { t.Errorf("expected summary payload, got %v", resMap["summary"]) } if resMap[workflow.MetadataKeyAuthoringRunState] != "succeeded" { t.Errorf("expected authoring_run_state succeeded, got %v", resMap[workflow.MetadataKeyAuthoringRunState]) } } func TestRoadmapCreationSyncWorkerDoesNotFinalizeNotReady(t *testing.T) { runner := &fakeCreationSyncRunner{ result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionNotReady, Reason: "not ready yet"}, } finalizer := &fakeTaskFinalizer{} worker := &RoadmapCreationSyncWorker{ Sync: runner, TaskFinalizer: finalizer, } args := sampleCreationSyncArgs() err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args}) if err != nil { t.Fatalf("Work returned error: %v", err) } if !runner.called { t.Fatal("expected orchestrator to be called") } if finalizer.called { t.Fatal("expected finalizer to NOT be called on not_ready outcome") } } // TestRoadmapCreationSyncWorkerDoesNotRetryTerminalOutcomes covers S07's // operator-visible-but-safe requirement at the job boundary: conflict, // not-ready, and skip_self_mutation are terminal decisions the orchestrator // already made without any provider mutation, so the worker returns nil (River // does not retry — no retry storm) and does not finalize the external task. func TestRoadmapCreationSyncWorkerDoesNotRetryTerminalOutcomes(t *testing.T) { cases := []struct { name string action roadmapsyncpipeline.SyncAction }{ {"conflict", roadmapsyncpipeline.SyncActionConflict}, {"not_ready", roadmapsyncpipeline.SyncActionNotReady}, {"skip_self_mutation", roadmapsyncpipeline.SyncActionSkipSelfMutation}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &fakeCreationSyncRunner{ result: roadmapsyncpipeline.SyncCreationResult{Action: tc.action, Reason: string(tc.action)}, } finalizer := &fakeTaskFinalizer{} worker := &RoadmapCreationSyncWorker{Sync: runner, TaskFinalizer: finalizer} err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: sampleCreationSyncArgs()}) if err != nil { t.Fatalf("Work returned error (would trigger River retry storm): %v", err) } if !runner.called { t.Fatal("expected orchestrator to be called") } if finalizer.called { t.Fatalf("expected finalizer NOT called on %s outcome", tc.action) } }) } } func TestRoadmapCreationSyncWorkerDoesNotUpdateSlotOnDuplicateComplete(t *testing.T) { runner := &fakeCreationSyncRunner{ result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionComplete, Reason: "already complete"}, } finalizer := &fakeTaskFinalizer{ transitioned: false, task: storage.Task{ ID: "task-auth-complete", Source: "plane", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), }, } slotUpdater := &fakeWorkspaceSlotUpdater{} worker := &RoadmapCreationSyncWorker{ Sync: runner, TaskFinalizer: finalizer, SlotUpdater: slotUpdater, } args := sampleCreationSyncArgs() err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args}) if err != nil { t.Fatalf("Work returned error: %v", err) } if !finalizer.called { t.Fatal("expected finalizer to be called") } if len(slotUpdater.calls) != 0 { t.Errorf("expected 0 slot updates because task was already complete, got %d", len(slotUpdater.calls)) } } func TestRoadmapCreationSyncWorkerUpdatesSlotOnCrashRecovery(t *testing.T) { runner := &fakeCreationSyncRunner{ result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionComplete, Reason: "crash recovery complete"}, } finalizer := &fakeTaskFinalizer{ transitioned: true, task: storage.Task{ ID: "task-auth-recovery", Source: "plane", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), }, } slotUpdater := &fakeWorkspaceSlotUpdater{} worker := &RoadmapCreationSyncWorker{ Sync: runner, TaskFinalizer: finalizer, SlotUpdater: slotUpdater, } args := sampleCreationSyncArgs() err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args}) if err != nil { t.Fatalf("Work returned error: %v", err) } if !finalizer.called { t.Fatal("expected finalizer to be called") } if len(slotUpdater.calls) != 1 { t.Fatalf("expected 1 slot update for crash recovery complete, got %d", len(slotUpdater.calls)) } if slotUpdater.calls[0].ID != 1 || slotUpdater.calls[0].State != string(projectsync.SlotStateAvailable) { t.Errorf("expected slot 1 -> available, got %#v", slotUpdater.calls[0]) } } // lazyPool builds a pgxpool without dialing. pgxpool.New is lazy and only // connects on first acquire, so it is safe for constructing the scheduler in a // unit test that never starts the client. func lazyPool(t *testing.T) *pgxpool.Pool { t.Helper() pool, err := pgxpool.New(context.Background(), "postgres://user:pass@127.0.0.1:1/db") if err != nil { t.Fatalf("build lazy pool: %v", err) } t.Cleanup(pool.Close) return pool } func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) { runner := &fakeCreationSyncRunner{} client, err := New(lazyPool(t), nil, nil, nil, nil, nil, runner, nil, nil, 0, nil) if err != nil { t.Fatalf("New with creation sync runner: %v", err) } if client == nil { t.Fatal("expected non-nil client") } // The enqueue seam exists regardless of registration; the worker registration // is exercised by New succeeding with a non-nil runner (River rejects an // unknown job kind only at insert/work time, so construction succeeding plus // the worker unit test above cover the wiring without a live DB). } func TestSchedulerWithoutRoadmapSyncKeepsTaskWorkerOnlyBehavior(t *testing.T) { client, err := New(lazyPool(t), nil, nil, nil, nil, nil, nil, nil, nil, 0, nil) if err != nil { t.Fatalf("New without creation sync runner: %v", err) } if client == nil { t.Fatal("expected non-nil client") } }