package service import ( "context" "strings" "testing" "time" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" ) // facts builds a SpaceFacts with the clock and grace fixed, so a case only has // to say what it is about. func facts(head, indexRev string, proposals ...ProposalFact) SpaceFacts { return SpaceFacts{ Space: fxSpace, SpaceID: 1, ApprovedHead: head, IndexRev: indexRev, Proposals: proposals, Now: fxTime(60), Grace: DefaultReconcileGrace, } } const ( headRev = "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809" branchRev = "2a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d" baseRev = "3b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e" ) // The four rows of the design's repair table, plus every state that must be // left alone. Nothing here touches git or Postgres. func TestPlanRepairs(t *testing.T) { old := fxTime(0) // an hour before Now: well past the grace window fresh := fxTime(59) // one minute before Now: still in flight tests := []struct { name string facts SpaceFacts want []RepairKind }{ { name: "nothing to do", facts: facts(headRev, headRev), }, { // Crash between the row insert and the branch write. The row holds // no content and the agent still has the document it wanted. name: "open row with no branch", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, State: core.StateOpen, Created: old, }), want: []RepairKind{RepairDeleteRow}, }, { // The same state a live propose passes through. Deleting it would // destroy an agent's work at random and never in a test. name: "open row with no branch, still inside the grace window", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, State: core.StateOpen, Created: fresh, }), }, { // The id is a Postgres serial and title, rationale, base_rev and // provenance live nowhere in a ref, so the row cannot be rebuilt. name: "orphan ref with no row", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasBranch: true, BranchHead: branchRev, }), want: []RepairKind{RepairDeleteRef}, }, { name: "orphan ref whose name carries no id", facts: facts(headRev, headRev, ProposalFact{ Branch: "proposals/scratch", HasBranch: true, BranchHead: branchRev, }), want: []RepairKind{RepairDeleteRef}, }, { // Crash between the merge commit and the row update. The ref is // truth for merged-ness. name: "open row whose branch merged into the approved head", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, HasBranch: true, State: core.StateOpen, Created: old, MergedIntoApproved: true, BranchHead: branchRev, BaseRev: baseRev, }), want: []RepairKind{RepairMarkMerged}, }, { // A branch still sitting on its base has no content and cannot have // merged, even though its tip is trivially an ancestor of the head. // This is the state every propose passes through between cutting // the branch and the agent's first commit. name: "open row whose branch was cut but never committed to", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, HasBranch: true, State: core.StateOpen, Created: old, MergedIntoApproved: true, BranchHead: baseRev, BaseRev: baseRev, }), }, { // Repairing on facts we could not establish is worse than leaving // the row open where a human can see it. name: "open row whose base could not be resolved", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, HasBranch: true, State: core.StateOpen, Created: old, MergedIntoApproved: true, BranchHead: branchRev, }), }, { name: "ordinary open proposal", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, HasBranch: true, State: core.StateOpen, Created: old, }), }, { // A merged or rejected proposal keeps its branch and its row. // Neither is a half-finished write. name: "merged proposal that still has its branch", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, HasBranch: true, State: core.StateMerged, Created: old, MergedIntoApproved: true, BranchHead: branchRev, BaseRev: baseRev, }), }, { name: "rejected proposal that still has its branch", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, HasBranch: true, State: core.StateRejected, Created: old, }), }, { name: "rejected proposal whose branch is gone", facts: facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, State: core.StateRejected, Created: old, }), }, { name: "index stamp behind the approved head", facts: facts(headRev, branchRev), want: []RepairKind{RepairReindex}, }, { // A missing stamp is stale, never "up to date". name: "space that has never been indexed", facts: facts(headRev, ""), want: []RepairKind{RepairReindex}, }, { name: "several repairs in one space", facts: facts(headRev, branchRev, ProposalFact{ID: 1, Branch: "proposals/1", HasRow: true, State: core.StateOpen, Created: old}, ProposalFact{ID: 2, Branch: "proposals/2", HasBranch: true, BranchHead: branchRev}, ProposalFact{ID: 3, Branch: "proposals/3", HasRow: true, HasBranch: true, State: core.StateOpen, Created: old, MergedIntoApproved: true, BranchHead: branchRev, BaseRev: baseRev}, ), want: []RepairKind{RepairDeleteRow, RepairDeleteRef, RepairMarkMerged, RepairReindex}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := PlanRepairs(tc.facts) if len(got) != len(tc.want) { t.Fatalf("repairs = %v, want %v", kinds(got), tc.want) } for i, want := range tc.want { if got[i].Kind != want { t.Fatalf("repairs = %v, want %v", kinds(got), tc.want) } if got[i].Space != fxSpace || got[i].SpaceID != 1 { t.Errorf("repair %d lost its space: %+v", i, got[i]) } if got[i].Reason == "" { t.Errorf("repair %d has no reason for the log", i) } } }) } } // The approval kind existed only in the memory of the process that died. Of the // two available lies, "policy" is the safe one: "human" would launder // unreviewed content as blessed, while "policy" puts the proposal in the digest // where a human sees it again. func TestPlanRepairsRecordsAPolicyApprovalOnARepairedMerge(t *testing.T) { got := PlanRepairs(facts(headRev, headRev, ProposalFact{ ID: 42, Branch: "proposals/42", HasRow: true, HasBranch: true, State: core.StateOpen, Created: fxTime(0), MergedIntoApproved: true, BranchHead: branchRev, BaseRev: baseRev, })) if len(got) != 1 { t.Fatalf("repairs = %v", kinds(got)) } if got[0].Approval != core.ApprovalPolicy { t.Errorf("approval = %q, want %q", got[0].Approval, core.ApprovalPolicy) } if got[0].Rev != headRev { t.Errorf("merged rev = %q, want the approved head", got[0].Rev) } } func TestPlanRepairsIgnoresAnUnknownApprovedHead(t *testing.T) { if got := PlanRepairs(facts("", "")); len(got) != 0 { t.Fatalf("repairs = %v, want none when the approved head is unknown", kinds(got)) } } func kinds(rs []Repair) []RepairKind { out := make([]RepairKind, 0, len(rs)) for _, r := range rs { out = append(out, r.Kind) } return out } func TestDeleteProposalRefRemovesOnlyProposalBranches(t *testing.T) { svc, root := newService(t) sp := newSpace(t, root, 1) ctx := context.Background() commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{ "specs/0007.md": mdDoc("SPEC-0007", "Storage", "one"), }) cutBranch(t, sp, "proposals/1", sp.ApprovedBranch()) if err := svc.deleteProposalRef(ctx, sp, "proposals/1"); err != nil { t.Fatalf("deleteProposalRef: %v", err) } branches, err := sp.Repo.ListProposalBranches(ctx) if err != nil { t.Fatalf("ListProposalBranches: %v", err) } if len(branches) != 0 { t.Fatalf("branches = %+v, want none", branches) } // The namespace check is the only thing between a caller bug and a deleted // approved branch. err = svc.deleteProposalRef(ctx, sp, sp.ApprovedBranch()) if err == nil { t.Fatal("deleted the approved branch") } if !strings.Contains(err.Error(), gitx.ProposalPrefix) { t.Errorf("err = %v, want it to name the only deletable namespace", err) } if _, err := sp.Repo.ApprovedHead(ctx); err != nil { t.Fatalf("approved branch is gone: %v", err) } } func TestApplyRepairRefusesAnUnimplementedKind(t *testing.T) { svc, root := newService(t) sp := newSpace(t, root, 1) err := svc.applyRepair(context.Background(), sp, Repair{Kind: "invented"}) if err == nil { t.Fatal("an unknown repair kind was silently ignored") } } func TestRunReconcilerStopsWithItsContext(t *testing.T) { svc, _ := newService(t) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) var passes int go func() { defer close(done) svc.RunReconciler(ctx, time.Hour, func(*ReconcileReport, error) { passes++ cancel() }) }() select { case <-done: case <-time.After(10 * time.Second): t.Fatal("RunReconciler did not return after its context was cancelled") } // The startup pass is the half that matters: a daemon killed mid-merge // repairs itself on the next boot with no manual intervention. if passes != 1 { t.Errorf("passes = %d, want the one startup pass", passes) } }