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")
}
if p.Branch != ProposalBranch(p.ID) {
t.Fatalf("branch = %q, want %q", p.Branch, ProposalBranch(p.ID))
}
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)
}
})
}
}