package service
import (
"context"
"testing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// recordedEvent is one call the fake sink captured.
type recordedEvent struct {
kind ProposalEventKind
prop Proposal
}
// fakeSink records the proposal events the service emits, so a test can assert
// the kinds and the proposals carried. It does not block, matching the EventSink
// contract.
type fakeSink struct{ events []recordedEvent }
func (f *fakeSink) ProposalEvent(kind ProposalEventKind, p Proposal) {
f.events = append(f.events, recordedEvent{kind: kind, prop: p})
}
func (f *fakeSink) kinds() []ProposalEventKind {
out := make([]ProposalEventKind, len(f.events))
for i, e := range f.events {
out[i] = e.kind
}
return out
}
// TestProposeEmitsOpened proves a newly opened proposal (no auto_merge policy)
// fires exactly one PROPOSAL_OPENED carrying that proposal, open.
func TestProposeEmitsOpened(t *testing.T) {
svc, _ := newTestService(t)
sink := &fakeSink{}
svc.SetEventSink(sink)
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")
}
if got := sink.kinds(); len(got) != 1 || got[0] != EventProposalOpened {
t.Fatalf("events = %v, want exactly [PROPOSAL_OPENED]", got)
}
ev := sink.events[0]
if ev.prop.ID != res.Proposal.ID {
t.Errorf("opened proposal id = %d, want %d", ev.prop.ID, res.Proposal.ID)
}
if ev.prop.State != core.StateOpen {
t.Errorf("opened proposal state = %s, want open", ev.prop.State)
}
}
// TestProposeAddDoesNotEmitOpened proves adding to an existing proposal is not
// a fresh PROPOSAL_OPENED — only the first write announces the proposal.
func TestProposeAddDoesNotEmitOpened(t *testing.T) {
svc, _ := newTestService(t)
sink := &fakeSink{}
svc.SetEventSink(sink)
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"))
if _, 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")}},
}); err != nil {
t.Fatalf("Propose add: %v", err)
}
if got := sink.kinds(); len(got) != 1 || got[0] != EventProposalOpened {
t.Fatalf("events = %v, want the single open from the first write only", got)
}
}
// TestMergeEmitsMerged proves a human merge fires PROPOSAL_MERGED carrying the
// merged proposal.
func TestMergeEmitsMerged(t *testing.T) {
svc, _ := newTestService(t)
sink := &fakeSink{}
svc.SetEventSink(sink)
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 _, err := svc.Merge(ctx, fxSpace, res.Proposal.ID, core.ApprovalHuman); err != nil {
t.Fatalf("Merge: %v", err)
}
if got := sink.kinds(); len(got) != 2 ||
got[0] != EventProposalOpened || got[1] != EventProposalMerged {
t.Fatalf("events = %v, want [PROPOSAL_OPENED PROPOSAL_MERGED]", got)
}
merged := sink.events[1].prop
if merged.ID != res.Proposal.ID {
t.Errorf("merged proposal id = %d, want %d", merged.ID, res.Proposal.ID)
}
if merged.State != core.StateMerged {
t.Errorf("merged proposal state = %s, want merged", merged.State)
}
}
// TestRejectEmitsRejected proves rejecting a proposal fires PROPOSAL_REJECTED
// carrying the rejected proposal.
func TestRejectEmitsRejected(t *testing.T) {
svc, _ := newTestService(t)
sink := &fakeSink{}
svc.SetEventSink(sink)
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 _, err := svc.Reject(ctx, fxSpace, res.Proposal.ID); err != nil {
t.Fatalf("Reject: %v", err)
}
if got := sink.kinds(); len(got) != 2 ||
got[0] != EventProposalOpened || got[1] != EventProposalRejected {
t.Fatalf("events = %v, want [PROPOSAL_OPENED PROPOSAL_REJECTED]", got)
}
rejected := sink.events[1].prop
if rejected.ID != res.Proposal.ID {
t.Errorf("rejected proposal id = %d, want %d", rejected.ID, res.Proposal.ID)
}
if rejected.State != core.StateRejected {
t.Errorf("rejected proposal state = %s, want rejected", rejected.State)
}
}
// TestAutoMergeEmitsOpenedThenMerged proves an auto-merged proposal fires
// PROPOSAL_OPENED then PROPOSAL_MERGED, in that order, with the merge recorded
// as policy-approved.
func TestAutoMergeEmitsOpenedThenMerged(t *testing.T) {
svc, _ := newTestService(t)
sink := &fakeSink{}
svc.SetEventSink(sink)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
// Auto-merge everything under notes/.
commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{
".spec.yml": []byte("review:\n auto_merge: [notes/**]\n"),
})
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: "firehose note",
IfMatch: base.String(),
Message: "add notes/a.md",
Writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "body")}},
})
if err != nil {
t.Fatalf("Propose: %v", err)
}
if !res.Merged {
t.Fatalf("Merged = false, want the policy to have landed it")
}
if got := sink.kinds(); len(got) != 2 ||
got[0] != EventProposalOpened || got[1] != EventProposalMerged {
t.Fatalf("events = %v, want [PROPOSAL_OPENED PROPOSAL_MERGED]", got)
}
if opened := sink.events[0].prop; opened.State != core.StateOpen {
t.Errorf("opened event state = %s, want open (before the merge landed)", opened.State)
}
merged := sink.events[1].prop
if merged.State != core.StateMerged || merged.Approval != core.ApprovalPolicy {
t.Errorf("merged event = state %s approval %s, want merged/policy", merged.State, merged.Approval)
}
}