package db import ( "context" "errors" "testing" "sourcecraft.dev/bigbes/sr-ht-spec/core" ) func TestProposalOpenAndRead(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Add storage model section") if p.ID == 0 { t.Fatal("expected a non-zero proposal id") } branch, err := ProposalBranch(p.ID) if err != nil { t.Fatalf("ProposalBranch(%d): %v", p.ID, err) } if p.Branch != branch { t.Fatalf("branch = %q, want %q", p.Branch, branch) } if p.State != core.StateOpen { t.Fatalf("state = %q, want open", p.State) } if p.Approval != "" || p.MergedRev != "" || p.Resolved != nil { t.Fatalf("a fresh proposal must not be resolved: %+v", p) } got, err := s.GetProposal(ctx, p.ID) if err != nil { t.Fatalf("get proposal: %v", err) } if got.Title != p.Title || got.Rationale != "because" || got.BaseRev != p.BaseRev { t.Fatalf("round-trip mismatch: %+v", got) } if got.Agent != "claude-code/spec-writer" || got.AgentSession == "" { t.Fatalf("provenance lost: %+v", got) } if got.Branch != p.Branch || got.State != core.StateOpen { t.Fatalf("unexpected proposal: %+v", got) } if _, err := s.GetProposal(ctx, 99999); !errors.Is(err, ErrNotFound) { t.Fatalf("missing proposal = %v, want ErrNotFound", err) } // An empty rationale round-trips as "" rather than surfacing a NULL. bare, err := s.OpenProposal(ctx, &Proposal{ SpaceID: space.ID, Title: "no rationale", BaseRev: "abc", Agent: "a", AgentSession: "s", }) if err != nil { t.Fatalf("open bare proposal: %v", err) } got, err = s.GetProposal(ctx, bare.ID) if err != nil { t.Fatalf("get bare proposal: %v", err) } if got.Rationale != "" { t.Fatalf("rationale = %q, want empty", got.Rationale) } } func TestProposalListByState(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") first := mkProposal(t, s, ctx, space.ID, "first") second := mkProposal(t, s, ctx, space.ID, "second") third := mkProposal(t, s, ctx, space.ID, "third") open, err := s.ListProposalsByState(ctx, core.StateOpen, 0) if err != nil { t.Fatalf("list open: %v", err) } if len(open) != 3 { t.Fatalf("expected 3 open proposals, got %d", len(open)) } // Newest first. if open[0].ID != third.ID || open[2].ID != first.ID { t.Fatalf("expected newest first, got %d, %d, %d", open[0].ID, open[1].ID, open[2].ID) } limited, err := s.ListProposalsByState(ctx, core.StateOpen, 2) if err != nil { t.Fatalf("list limited: %v", err) } if len(limited) != 2 || limited[0].ID != third.ID { t.Fatalf("unexpected limited list: %+v", limited) } if err := s.RejectProposal(ctx, second.ID); err != nil { t.Fatalf("reject: %v", err) } rejected, err := s.ListProposalsByState(ctx, core.StateRejected, 0) if err != nil { t.Fatalf("list rejected: %v", err) } if len(rejected) != 1 || rejected[0].ID != second.ID { t.Fatalf("unexpected rejected list: %+v", rejected) } if rejected[0].Resolved == nil { t.Fatal("a rejected proposal must carry a resolved timestamp") } if rejected[0].Approval != "" || rejected[0].MergedRev != "" { t.Fatalf("a rejection must not record approval or a merged rev: %+v", rejected[0]) } merged, err := s.ListProposalsByState(ctx, core.StateMerged, 0) if err != nil { t.Fatalf("list merged: %v", err) } if len(merged) != 0 { t.Fatalf("expected no merged proposals, got %d", len(merged)) } } func TestProposalTransitionsAreGuarded(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") merged := mkProposal(t, s, ctx, space.ID, "to merge") if err := s.MarkProposalMerged(ctx, merged.ID, core.ApprovalHuman, "deadbeef"); err != nil { t.Fatalf("merge: %v", err) } got, err := s.GetProposal(ctx, merged.ID) if err != nil { t.Fatalf("get merged: %v", err) } if got.State != core.StateMerged || got.Approval != core.ApprovalHuman || got.MergedRev != "deadbeef" { t.Fatalf("unexpected merged proposal: %+v", got) } if got.Resolved == nil { t.Fatal("a merged proposal must carry a resolved timestamp") } // merged -> merged and merged -> rejected must both fail. if err := s.MarkProposalMerged(ctx, merged.ID, core.ApprovalHuman, "cafe"); !errors.Is(err, core.ErrInvalidTransition) { t.Fatalf("re-merge = %v, want ErrInvalidTransition", err) } if err := s.RejectProposal(ctx, merged.ID); !errors.Is(err, core.ErrInvalidTransition) { t.Fatalf("merged->rejected = %v, want ErrInvalidTransition", err) } // The row did not move. got, err = s.GetProposal(ctx, merged.ID) if err != nil { t.Fatalf("get merged: %v", err) } if got.State != core.StateMerged || got.MergedRev != "deadbeef" { t.Fatalf("illegal transition modified the row: %+v", got) } rejected := mkProposal(t, s, ctx, space.ID, "to reject") if err := s.RejectProposal(ctx, rejected.ID); err != nil { t.Fatalf("reject: %v", err) } if err := s.RejectProposal(ctx, rejected.ID); !errors.Is(err, core.ErrInvalidTransition) { t.Fatalf("re-reject = %v, want ErrInvalidTransition", err) } if err := s.MarkProposalMerged(ctx, rejected.ID, core.ApprovalPolicy, "cafe"); !errors.Is(err, core.ErrInvalidTransition) { t.Fatalf("rejected->merged = %v, want ErrInvalidTransition", err) } if err := s.RejectProposal(ctx, 99999); !errors.Is(err, ErrNotFound) { t.Fatalf("resolving a missing proposal = %v, want ErrNotFound", err) } if err := s.MarkProposalMerged(ctx, 99999, core.ApprovalHuman, "cafe"); !errors.Is(err, ErrNotFound) { t.Fatalf("merging a missing proposal = %v, want ErrNotFound", err) } } // TestDeleteOpenProposal covers the reconciler's one destructive repair: the // row of a proposal whose branch never appeared. The guard is the whole point — // a merge landing between the reconciler's decision and its write must not let // it delete a proposal that had just succeeded. func TestDeleteOpenProposal(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "never got a branch") if err := s.DeleteOpenProposal(ctx, p.ID); err != nil { t.Fatalf("delete open proposal: %v", err) } if _, err := s.GetProposal(ctx, p.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("get after delete = %v, want ErrNotFound", err) } // A row that is already gone is ErrNotFound, distinct from a row that moved // on: the reconciler has to tell "nothing to repair" from "the repair no // longer applies". if err := s.DeleteOpenProposal(ctx, p.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("second delete = %v, want ErrNotFound", err) } if err := s.DeleteOpenProposal(ctx, 99999); !errors.Is(err, ErrNotFound) { t.Fatalf("delete of a missing proposal = %v, want ErrNotFound", err) } merged := mkProposal(t, s, ctx, space.ID, "merged under the reconciler") if err := s.MarkProposalMerged(ctx, merged.ID, core.ApprovalHuman, "deadbeef"); err != nil { t.Fatalf("merge: %v", err) } if err := s.DeleteOpenProposal(ctx, merged.ID); !errors.Is(err, ErrProposalNotOpen) { t.Fatalf("delete of a merged proposal = %v, want ErrProposalNotOpen", err) } got, err := s.GetProposal(ctx, merged.ID) if err != nil { t.Fatalf("get merged: %v", err) } if got.State != core.StateMerged || got.MergedRev != "deadbeef" { t.Fatalf("the guarded delete modified the row: %+v", got) } rejected := mkProposal(t, s, ctx, space.ID, "rejected") if err := s.RejectProposal(ctx, rejected.ID); err != nil { t.Fatalf("reject: %v", err) } if err := s.DeleteOpenProposal(ctx, rejected.ID); !errors.Is(err, ErrProposalNotOpen) { t.Fatalf("delete of a rejected proposal = %v, want ErrProposalNotOpen", err) } if _, err := s.GetProposal(ctx, rejected.ID); err != nil { t.Fatalf("the guarded delete removed a rejected proposal: %v", err) } } // TestMergeProposalIsAtomic is the reason the transaction exists: a merge that // cannot re-point the registry must not leave the proposal reading merged. func TestMergeProposalIsAtomic(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() specs := mkSpace(t, s, ctx, "bigbes", "rfcs") notes := mkSpace(t, s, ctx, "bigbes", "notes") foreign := docID(t, "NOTE-1") if _, err := s.RegisterDocID(ctx, notes.ID, DocRef{ID: foreign, Path: "n.md"}, "rev1"); err != nil { t.Fatalf("register: %v", err) } p := mkProposal(t, s, ctx, specs.ID, "claims a foreign id") err := s.MergeProposal(ctx, Merge{ ProposalID: p.ID, SpaceID: specs.ID, Approval: core.ApprovalHuman, MergedRev: "deadbeef", Docs: []DocRef{ {ID: docID(t, "SPEC-1"), Path: "a.md"}, {ID: foreign, Path: "b.md"}, }, }) if !errors.Is(err, ErrDocIDTaken) { t.Fatalf("merge with a colliding id = %v, want ErrDocIDTaken", err) } got, err := s.GetProposal(ctx, p.ID) if err != nil { t.Fatalf("get proposal: %v", err) } if got.State != core.StateOpen { t.Fatalf("failed merge left the proposal %s, want open", got.State) } // The whole batch rolled back, including the ID that would have applied. if _, err := s.DocByID(ctx, docID(t, "SPEC-1")); !errors.Is(err, ErrNotFound) { t.Fatalf("failed merge registered SPEC-1 anyway: %v", err) } // The same merge without the collision commits both halves. if err := s.MergeProposal(ctx, Merge{ ProposalID: p.ID, SpaceID: specs.ID, Approval: core.ApprovalPolicy, MergedRev: "deadbeef", Docs: []DocRef{{ID: docID(t, "SPEC-1"), Path: "a.md"}}, }); err != nil { t.Fatalf("merge: %v", err) } got, err = s.GetProposal(ctx, p.ID) if err != nil { t.Fatalf("get proposal: %v", err) } if got.State != core.StateMerged || got.Approval != core.ApprovalPolicy { t.Fatalf("unexpected merged proposal: %+v", got) } doc, err := s.DocByID(ctx, docID(t, "SPEC-1")) if err != nil { t.Fatalf("lookup after merge: %v", err) } if doc.SpaceID != specs.ID || doc.Path != "a.md" || doc.UpdatedRev != "deadbeef" { t.Fatalf("unexpected registry row after merge: %+v", doc) } } // TestProposalCheckConstraints proves the invariants hold against SQL that does // not go through this package — a stray UPDATE cannot produce a merged row with // no approval, or an unknown state. func TestProposalCheckConstraints(t *testing.T) { s, pool, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "constrained") for _, tc := range []struct { name string q string args []any }{ {"unknown state", `UPDATE proposal SET state = 'abandoned' WHERE id = $1`, []any{p.ID}}, {"unknown approval", `UPDATE proposal SET approval = 'vibes' WHERE id = $1`, []any{p.ID}}, { "merged without approval", `UPDATE proposal SET state = 'merged', merged_rev = 'abc', resolved = now() WHERE id = $1`, []any{p.ID}, }, { "merged without a merged rev", `UPDATE proposal SET state = 'merged', approval = 'human', resolved = now() WHERE id = $1`, []any{p.ID}, }, { "open but resolved", `UPDATE proposal SET resolved = now() WHERE id = $1`, []any{p.ID}, }, { "approval on an open proposal", `UPDATE proposal SET approval = 'human' WHERE id = $1`, []any{p.ID}, }, { "empty provenance", `UPDATE proposal SET agent = '' WHERE id = $1`, []any{p.ID}, }, } { t.Run(tc.name, func(t *testing.T) { if _, err := pool.ExecContext(ctx, tc.q, tc.args...); err == nil { t.Fatalf("%s: expected a constraint violation, got none", tc.name) } }) } }