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)
}
}