package service import ( "context" "errors" "strings" "testing" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/db" ) // These exercise the paths that genuinely need Postgres: the global ID registry // behind push validation, and the reconciler's repairs. Everything they do not // cover is covered without a database elsewhere in this package. func TestValidatePushAcceptsAGoodPush(t *testing.T) { svc, _ := newTestService(t) ctx := context.Background() sp := mustCreateSpace(t, svc) old, err := sp.Repo.ApprovedHead(ctx) if err != nil { t.Fatalf("approved head: %v", err) } head := commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{ "specs/0007.md": doc("SPEC-0007", "Storage", "body"), }) err = svc.ValidatePush(ctx, PushRequest{ Space: fxSpace, Principal: owner(), Ref: "refs/heads/main", Old: old.String(), New: head.String(), }) if err != nil { t.Fatalf("ValidatePush: %v", err) } } func TestValidatePushRejectsAGlobalIDCollision(t *testing.T) { svc, _ := newTestService(t) ctx := context.Background() sp := mustCreateSpace(t, svc) // SPEC-0007 already lives in another space. other, err := svc.CreateSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "notes"}) if err != nil { t.Fatalf("CreateSpace: %v", err) } id, err := core.ParseDocID("SPEC-0007") if err != nil { t.Fatal(err) } if _, err := svc.Store().RegisterDocID(ctx, other.ID, db.DocRef{ID: id, Path: "rfcs/0007.md"}, "0000000000000000000000000000000000000000"); err != nil { t.Fatalf("RegisterDocID: %v", err) } old, err := sp.Repo.ApprovedHead(ctx) if err != nil { t.Fatal(err) } head := commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{ "specs/0007.md": doc("SPEC-0007", "Storage", "body"), }) err = svc.ValidatePush(ctx, PushRequest{ Space: fxSpace, Principal: owner(), Ref: "refs/heads/main", Old: old.String(), New: head.String(), }) if !errors.Is(err, ErrPushRejected) { t.Fatalf("err = %v, want ErrPushRejected", err) } var rej *PushRejection if !errors.As(err, &rej) { t.Fatalf("err is not a *PushRejection: %T", err) } if len(rej.Problems) != 1 || rej.Problems[0].Kind != ProblemIDCollision { t.Fatalf("problems = %+v", rej.Problems) } msg := err.Error() for _, want := range []string{"specs/0007.md", "SPEC-0007", "~bigbes/notes", "rfcs/0007.md"} { if !strings.Contains(msg, want) { t.Errorf("message does not name %q:\n%s", want, msg) } } if !rej.Skippable { t.Error("an id collision must be skippable; the escape hatch exists for exactly this") } } // The escape hatch waives frontmatter and id validation. It never waives the // refs rule. func TestValidatePushSkipValidationCoversContentButNotTheRefsRule(t *testing.T) { svc, _ := newTestService(t) ctx := context.Background() sp := mustCreateSpace(t, svc) old, err := sp.Repo.ApprovedHead(ctx) if err != nil { t.Fatal(err) } head := commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{ "specs/0007.md": []byte("no frontmatter here\n"), }) req := PushRequest{ Space: fxSpace, Principal: owner(), Ref: "refs/heads/main", Old: old.String(), New: head.String(), } if err := svc.ValidatePush(ctx, req); !errors.Is(err, ErrPushRejected) { t.Fatalf("err = %v, want the malformed document rejected", err) } req.SkipValidation = true if err := svc.ValidatePush(ctx, req); err != nil { t.Fatalf("skip-validation did not let the push through: %v", err) } // Same flag, agent principal, approved branch: still refused. req.Principal = agent() err = svc.ValidatePush(ctx, req) if !errors.Is(err, ErrPushRejected) { t.Fatalf("err = %v, want the refs rule to refuse an agent", err) } var rej *PushRejection if !errors.As(err, &rej) { t.Fatalf("err is not a *PushRejection: %T", err) } if rej.Skippable || rej.Problems[0].Kind != ProblemRefsRule { t.Fatalf("rejection = %+v, want an unskippable refs-rule refusal", rej) } } func TestReconcileRepairsTheRepairTable(t *testing.T) { svc, _ := newTestService(t) ctx := context.Background() sp := mustCreateSpace(t, svc) // Every row inserted below is "in flight" until the grace window passes; // move the clock rather than the rows. svc.now = func() time.Time { return time.Now().Add(2 * DefaultReconcileGrace) } base, err := sp.Repo.ApprovedHead(ctx) if err != nil { t.Fatal(err) } // 1. Crash between the row insert and the branch write. rowOnly := mustOpenProposal(t, svc, sp.ID, "row only", base.String()) // 2. Crash between the merge commit and the row update: a branch with // commits of its own, already reachable from the approved head. merged := mustOpenProposal(t, svc, sp.ID, "merged", base.String()) cutBranch(t, sp, merged.Branch, sp.ApprovedBranch()) commitFiles(t, sp, merged.Branch, 1, map[string][]byte{ "specs/0007.md": doc("SPEC-0007", "Storage", "body"), }) branchHead, err := sp.Repo.BranchHead(ctx, merged.Branch) if err != nil { t.Fatal(err) } fastForwardApproved(t, sp, branchHead) // 3. An ordinary open proposal: a branch cut at its recorded base, not yet // committed to. Its tip is trivially an ancestor of the approved head, // and it must survive anyway — this is the state every propose passes // through between cutting the branch and the agent's first write. live := mustOpenProposal(t, svc, sp.ID, "live", base.String()) cutBranch(t, sp, live.Branch, live.BaseRev) // 4. An orphan ref with no row at all. cutBranch(t, sp, "proposals/9999", sp.ApprovedBranch()) rep, err := svc.Reconcile(ctx) if err != nil { t.Fatalf("Reconcile: %v", err) } if len(rep.Failures) != 0 { t.Fatalf("failures = %v", rep.Failures) } if rep.Spaces != 1 { t.Errorf("spaces = %d", rep.Spaces) } got := map[RepairKind]int{} for _, r := range rep.Repaired { got[r.Kind]++ } want := map[RepairKind]int{RepairDeleteRow: 1, RepairDeleteRef: 1, RepairMarkMerged: 1} for kind, n := range want { if got[kind] != n { t.Errorf("%s applied %d times, want %d (all: %+v)", kind, got[kind], n, rep.Repaired) } } if len(rep.Reindex) != 1 { t.Errorf("reindex = %+v, want the one never-indexed space", rep.Reindex) } if _, err := svc.Store().GetProposal(ctx, rowOnly.ID); !errors.Is(err, db.ErrNotFound) { t.Errorf("the branchless row survived: %v", err) } got2, err := svc.Store().GetProposal(ctx, merged.ID) if err != nil { t.Fatalf("GetProposal: %v", err) } if got2.State != core.StateMerged { t.Errorf("state = %q, want merged", got2.State) } if got2.Approval != core.ApprovalPolicy { t.Errorf("approval = %q; a repaired merge must never claim human approval", got2.Approval) } stillOpen, err := svc.Store().GetProposal(ctx, live.ID) if err != nil { t.Fatalf("GetProposal: %v", err) } if stillOpen.State != core.StateOpen { t.Errorf("a proposal whose branch was cut but never committed to was resolved as %q", stillOpen.State) } branches, err := sp.Repo.ListProposalBranches(ctx) if err != nil { t.Fatal(err) } var names []string for _, b := range branches { names = append(names, b.Name) } if len(names) != 2 { t.Fatalf("branches = %v, want the merged and the live one", names) } for _, name := range names { if name == "proposals/9999" { t.Error("the orphan ref survived") } } // A second pass must find nothing left to do apart from the reindex flag, // which only Phase 2 can clear. again, err := svc.Reconcile(ctx) if err != nil { t.Fatalf("second Reconcile: %v", err) } if len(again.Repaired) != 0 || len(again.Failures) != 0 { t.Errorf("second pass repaired %+v, failed %v", again.Repaired, again.Failures) } } // A proposal younger than the grace window is in flight, not abandoned: every // live propose passes through "row inserted, branch not yet written". func TestReconcileLeavesAnInFlightProposalAlone(t *testing.T) { svc, _ := newTestService(t) ctx := context.Background() sp := mustCreateSpace(t, svc) base, err := sp.Repo.ApprovedHead(ctx) if err != nil { t.Fatal(err) } p := mustOpenProposal(t, svc, sp.ID, "in flight", base.String()) rep, err := svc.Reconcile(ctx) if err != nil { t.Fatalf("Reconcile: %v", err) } for _, r := range rep.Repaired { if r.Kind == RepairDeleteRow { t.Fatalf("deleted a proposal opened moments ago: %v", r) } } if _, err := svc.Store().GetProposal(ctx, p.ID); err != nil { t.Fatalf("the in-flight row is gone: %v", err) } } func mustCreateSpace(t *testing.T, svc *Service) *Space { t.Helper() sp, err := svc.CreateSpace(context.Background(), fxSpace) if err != nil { t.Fatalf("CreateSpace: %v", err) } return sp } func mustOpenProposal(t *testing.T, svc *Service, spaceID int, title, base string) *db.Proposal { t.Helper() p, err := svc.Store().OpenProposal(context.Background(), &db.Proposal{ SpaceID: spaceID, Title: title, BaseRev: base, Agent: "claude-code/spec-writer", AgentSession: "8fb9c9a4", }) if err != nil { t.Fatalf("OpenProposal %q: %v", title, err) } return p } // fastForwardApproved moves the approved branch to head, standing in for the // merge whose row update never happened. func fastForwardApproved(t *testing.T, sp *Space, head plumbing.Hash) { t.Helper() repo, err := git.PlainOpen(sp.Repo.Dir()) if err != nil { t.Fatalf("PlainOpen: %v", err) } name := plumbing.NewBranchReferenceName(sp.ApprovedBranch()) if err := repo.Storer.SetReference(plumbing.NewHashReference(name, head)); err != nil { t.Fatalf("set %s: %v", name, err) } }