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