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": doc("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)
}
}