package service
import (
"context"
"errors"
"testing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// openProposalFor opens a proposal against the current approved head and returns
// its result. It is the common setup for the merge tests, which then act on the
// proposal the design's review plane would.
func openProposalFor(t *testing.T, svc *Service, sp *Space, path, id string, content []byte) ProposeResult {
t.Helper()
ctx := context.Background()
base, err := sp.Repo.ApprovedHead(ctx)
if err != nil {
t.Fatalf("ApprovedHead: %v", err)
}
res, err := svc.Propose(ctx, ProposeRequest{
Space: fxSpace,
Principal: agentPrincipal(),
Title: "proposal for " + path,
IfMatch: base.String(),
Message: "write " + path,
Writes: []DocumentWrite{{Path: path, Content: content}},
})
if err != nil {
t.Fatalf("Propose(%s): %v", path, err)
}
return res
}
// TestMergeLandsProposal proves the human approve path: an open proposal that no
// policy auto-merges is merged on request, recorded as human-approved, and its
// document is then readable at the approved head.
func TestMergeLandsProposal(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
res := openProposalFor(t, svc, sp, "specs/a.md", "S-1", mdDoc("S-1", "A", "body"))
if res.Merged {
t.Fatalf("specs/ proposal auto-merged without a policy")
}
merged, err := svc.Merge(ctx, fxSpace, res.Proposal.ID, core.ApprovalHuman)
if err != nil {
t.Fatalf("Merge: %v", err)
}
if merged.State != core.StateMerged || merged.Approval != core.ApprovalHuman {
t.Fatalf("merged = %+v, want state=merged approval=human", merged)
}
// The document is now on the approved head.
doc, err := svc.ReadDocument(ctx, sp, ApprovedRev, "specs/a.md")
if err != nil {
t.Fatalf("ReadDocument after merge: %v", err)
}
if len(doc.Data) == 0 {
t.Fatalf("merged document reads empty")
}
// A merged proposal is no longer open, so a second resolution is refused.
if _, err := svc.Merge(ctx, fxSpace, res.Proposal.ID, core.ApprovalHuman); !errors.Is(err, ErrProposalNotOpen) {
t.Fatalf("re-merge: err = %v, want ErrProposalNotOpen", err)
}
}
// TestRejectResolvesProposal proves reject moves an open proposal to rejected
// and leaves it listable there, its URL still resolving.
func TestRejectResolvesProposal(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
res := openProposalFor(t, svc, sp, "specs/a.md", "S-1", mdDoc("S-1", "A", "body"))
rejected, err := svc.Reject(ctx, fxSpace, res.Proposal.ID)
if err != nil {
t.Fatalf("Reject: %v", err)
}
if rejected.State != core.StateRejected {
t.Fatalf("state = %s, want rejected", rejected.State)
}
got, err := svc.GetProposal(ctx, res.Proposal.ID)
if err != nil {
t.Fatalf("GetProposal after reject: %v", err)
}
if got.State != core.StateRejected {
t.Fatalf("GetProposal state = %s, want rejected", got.State)
}
}
// TestMergeStaleWhenDocumentChangedUnderIt proves the 409: a document the
// proposal edits, changed on the approved branch since the proposal's base,
// cannot merge — the loser refetches and re-proposes.
func TestMergeStaleWhenDocumentChangedUnderIt(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
// The approved head carries v1 of the document the proposal will edit.
commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{
"specs/a.md": mdDoc("S-1", "A", "v1"),
})
res := openProposalFor(t, svc, sp, "specs/a.md", "S-1", mdDoc("S-1", "A", "v2-from-agent"))
// A human pushes v3 of the same document onto the approved head, after the
// proposal's base.
commitFiles(t, sp, sp.ApprovedBranch(), 2, map[string][]byte{
"specs/a.md": mdDoc("S-1", "A", "v3-from-human"),
})
_, err = svc.Merge(ctx, fxSpace, res.Proposal.ID, core.ApprovalHuman)
if !errors.Is(err, ErrStale) {
t.Fatalf("Merge of a proposal whose document moved under it: err = %v, want ErrStale", err)
}
}
// TestProposeAddToExistingThenMerge proves the X-Proposal path: a second write
// against a proposal's fixed base adds to it, and merging then lands both
// documents at once.
func TestProposeAddToExistingThenMerge(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
first := openProposalFor(t, svc, sp, "specs/a.md", "S-1", mdDoc("S-1", "A", "one"))
// Add a second document to the same proposal, sending the same base.
second, err := svc.Propose(ctx, ProposeRequest{
Space: fxSpace,
Principal: agentPrincipal(),
ProposalID: first.Proposal.ID,
IfMatch: first.Proposal.BaseRev,
Message: "add specs/b.md",
Writes: []DocumentWrite{{Path: "specs/b.md", Content: mdDoc("S-2", "B", "two")}},
})
if err != nil {
t.Fatalf("Propose add-to-existing: %v", err)
}
if second.Proposal.ID != first.Proposal.ID {
t.Fatalf("add opened a new proposal %d, want %d", second.Proposal.ID, first.Proposal.ID)
}
if _, err := svc.Merge(ctx, fxSpace, first.Proposal.ID, core.ApprovalHuman); err != nil {
t.Fatalf("Merge: %v", err)
}
for _, path := range []string{"specs/a.md", "specs/b.md"} {
if _, err := svc.ReadDocument(ctx, sp, ApprovedRev, path); err != nil {
t.Fatalf("ReadDocument(%s) after merge: %v", path, err)
}
}
}
// TestAddToExistingRejectsDriftedBase proves an add whose If-Match no longer
// names the proposal's base is a 409 rather than a silent write against the old
// base.
func TestAddToExistingRejectsDriftedBase(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
first := openProposalFor(t, svc, sp, "specs/a.md", "S-1", mdDoc("S-1", "A", "one"))
// The approved head moves; the agent mistakenly sends the new head as its
// base for the add.
newHead := commitFiles(t, sp, sp.ApprovedBranch(), 2, map[string][]byte{
"specs/c.md": mdDoc("S-3", "C", "unrelated"),
})
_, err = svc.Propose(ctx, ProposeRequest{
Space: fxSpace,
Principal: agentPrincipal(),
ProposalID: first.Proposal.ID,
IfMatch: newHead.String(),
Message: "add specs/b.md",
Writes: []DocumentWrite{{Path: "specs/b.md", Content: mdDoc("S-2", "B", "two")}},
})
if !errors.Is(err, ErrStale) {
t.Fatalf("add with a drifted base: err = %v, want ErrStale", err)
}
}