package roadmapsyncpipeline import ( "context" "errors" "testing" "github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workitem" ) const ( testMilestonePath = "agent-roadmap/phase/p1/milestones/m1.md" testRevision = "rev-abc" ) // fakeProvider records the order of provider facet calls and can fail a chosen // step so a stop-before-later-step test is possible. type fakeProvider struct { calls []string failOn string failErr error } func (f *fakeProvider) AppendComment(_ context.Context, _ workitem.CommentInput) error { f.calls = append(f.calls, "comment") if f.failOn == "comment" { return f.failErr } return nil } func (f *fakeProvider) ProjectBody(_ context.Context, _ workitem.BodyProjection) error { f.calls = append(f.calls, "body") if f.failOn == "body" { return f.failErr } return nil } func (f *fakeProvider) ProjectStatus(_ context.Context, _ workitem.StatusProjection) error { f.calls = append(f.calls, "status") if f.failOn == "status" { return f.failErr } return nil } // fakeStore is an in-memory Store. identity is the single persisted row (id 0 // means "not found"); marked records the steps MarkStepCompleted was called // with, in order. type fakeStore struct { identity db.RoadmapSyncIdentity found bool completed map[storage.RoadmapSyncStep]bool marked []storage.RoadmapSyncStep upserted bool markErr error } func newFakeStore() *fakeStore { return &fakeStore{completed: map[storage.RoadmapSyncStep]bool{}} } func (f *fakeStore) GetRoadmapSyncIdentityByWorkItem(_ context.Context, _ db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) { if !f.found { return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound } return f.identity, nil } func (f *fakeStore) UpsertRoadmapSyncIdentity(_ context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) { f.upserted = true f.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: args.Shape, RoadmapMilestonePath: args.RoadmapMilestonePath, Provider: args.Provider, Tenant: args.Tenant, Project: args.Project, WorkItemID: args.WorkItemID, ProviderRevision: args.ProviderRevision, RoadmapRevision: args.RoadmapRevision, } f.found = true return f.identity, nil } func (f *fakeStore) CompletedSteps(_ context.Context, _ int64) (map[storage.RoadmapSyncStep]bool, error) { out := make(map[storage.RoadmapSyncStep]bool, len(f.completed)) for k, v := range f.completed { out[k] = v } return out, nil } func (f *fakeStore) MarkStepCompleted(_ context.Context, _ int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error) { if f.markErr != nil { return db.RoadmapSyncStep{}, f.markErr } f.marked = append(f.marked, step) f.completed[step] = true return db.RoadmapSyncStep{Step: string(step)}, nil } func readyInput() SyncCreationInput { identity := roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: testMilestonePath, Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", } return SyncCreationInput{ Scan: roadmapsync.ScanResult{ Revision: roadmapsync.Revision{Branch: "develop", Revision: testRevision}, ChangedFiles: []string{testMilestonePath}, ScannedMilestones: []roadmapsync.ScannedMilestone{ {Path: testMilestonePath, Identity: identity}, }, }, Expected: identity, Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "nomad", ID: "NOMAD-1"}, OriginalBody: "

user request

", TodoStateID: "todo-state", MilestoneMarkdown: "# Milestone\n\nbody", ProviderRevision: testRevision, RoadmapRevision: testRevision, ExternalSource: "nomadcode", ExternalID: "sync-1", } } func TestSyncCreationNotReadyDoesNotCallProvider(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} svc := NewService(store, provider, "") in := readyInput() // Break the develop match: no changed milestone file. in.Scan.ChangedFiles = nil res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionNotReady { t.Fatalf("expected not_ready, got %s (%s)", res.Action, res.Reason) } if len(provider.calls) != 0 { t.Fatalf("expected no provider calls, got %v", provider.calls) } if len(store.marked) != 0 || store.upserted { t.Fatalf("expected no ledger marks or upsert, marked=%v upserted=%v", store.marked, store.upserted) } } func TestSyncCreationReadyRunsStepsAndMarksLedger(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} svc := NewService(store, provider, "") res, err := svc.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionProjected { t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason) } wantProviderOrder := []string{"comment", "body", "status"} if !equalStrings(provider.calls, wantProviderOrder) { t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) } wantLedger := []storage.RoadmapSyncStep{ storage.StepDevelopMatched, storage.StepOriginalCommentPreserved, storage.StepPlaneBodyUpdated, storage.StepPlaneTodoMoved, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } if res.NextStep != roadmapsync.StepPlaneTodoMoved { t.Fatalf("expected last step plane_todo_moved, got %s", res.NextStep) } } func TestSyncCreationRetrySkipsCompletedSteps(t *testing.T) { store := newFakeStore() // Identity already exists with the first two steps recorded. store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", } store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepOriginalCommentPreserved] = true provider := &fakeProvider{} svc := NewService(store, provider, "") res, err := svc.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionProjected { t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason) } if store.upserted { t.Fatal("expected no upsert when identity already exists") } // Comment was already preserved; only body+status should run again. wantProviderOrder := []string{"body", "status"} if !equalStrings(provider.calls, wantProviderOrder) { t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) } wantLedger := []storage.RoadmapSyncStep{ storage.StepPlaneBodyUpdated, storage.StepPlaneTodoMoved, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } } func TestSyncCreationStopsBeforeLaterStepOnProviderError(t *testing.T) { store := newFakeStore() bodyErr := errors.New("plane body projection failed") provider := &fakeProvider{failOn: "body", failErr: bodyErr} svc := NewService(store, provider, "") _, err := svc.SyncCreation(context.Background(), readyInput()) if !errors.Is(err, bodyErr) { t.Fatalf("expected body error, got %v", err) } // Comment ran and was marked; body ran but failed; status must not run. wantProviderOrder := []string{"comment", "body"} if !equalStrings(provider.calls, wantProviderOrder) { t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) } // develop_matched + original_comment_preserved recorded; body never marked. wantLedger := []storage.RoadmapSyncStep{ storage.StepDevelopMatched, storage.StepOriginalCommentPreserved, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } } func TestSyncCreationSkipsSelfMutation(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} svc := NewService(store, provider, "nomadcode-bot") in := readyInput() in.Actor = "nomadcode-bot" res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionSkipSelfMutation { t.Fatalf("expected skip_self_mutation, got %s (%s)", res.Action, res.Reason) } if len(provider.calls) != 0 { t.Fatalf("expected no provider calls on self mutation, got %v", provider.calls) } if len(store.marked) != 0 { t.Fatalf("expected no ledger marks on self mutation, got %v", store.marked) } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func equalSteps(a, b []storage.RoadmapSyncStep) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }