Artifact efa6ef13d7c33f8832d0d4c050352ccfb337ac07cee6efe6b0e9239cf923d290:
- File
internal/webhook/fanout_test.go
— part of check-in
[f2ea5f5c1a]
at
2026-08-24 17:13:12
on branch hooks-vocabulary
— Webhooks fan out from the vocabulary, not from the ops layer
A ticket closed by push notified nobody, because only ops queued webhooks. One consumer on quarry.evt.> turns every raised event into deliveries, so arrival path stops mattering.
Deletes kindOf: the delivery's kind was sliced off an idempotency key. (user: dmestas, size: 3732) [annotate] [blame] [check-ins using]
package webhook import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "craftdesign.group/quarry/internal/events" "craftdesign.group/quarry/internal/evt" ) // The whole chain: something raises a vocabulary event and a third-party // endpoint receives it, without the raiser knowing an endpoint exists. // // The raiser here is internal/events directly, which is what BOTH the // fold and the ops layer call — so this proves the property the change // was for: a delivery no longer depends on how the change arrived. func TestVocabularyEventReachesAnEndpoint(t *testing.T) { bodies := make(chan []byte, 4) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) bodies <- b w.WriteHeader(http.StatusOK) })) defer srv.Close() ev, err := events.Start(filepath.Join(t.TempDir(), "nats")) if err != nil { t.Fatal(err) } t.Cleanup(ev.Close) subs := &fakeSubs{subs: map[int64]Sub{ 1: {ID: 1, URL: srv.URL, Secret: "k", Active: true}, }} d, err := New(ev.JS(), subs) if err != nil { t.Fatal(err) } d.Now = func() string { return "2026-08-24T00:00:00Z" } ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) if err := d.Start(ctx); err != nil { t.Fatal(err) } if err := d.Fanout(ctx); err != nil { t.Fatal(err) } // The fan-out consumer starts at the tip, so a raise must not race // its subscription. time.Sleep(300 * time.Millisecond) if err := ev.Raise(evt.TicketClosed, "alpha", map[string]any{ "ticket": "abcdef0123456789aa", "actor": "dan", "hash": "ffee0011223344556677", "date": "2026-08-24T10:00:00Z", }); err != nil { t.Fatal(err) } select { case body := <-bodies: var e Event if err := json.Unmarshal(body, &e); err != nil { t.Fatal(err) } if e.Kind != "ticket.closed" { t.Errorf("kind = %q, want ticket.closed — the subject carries it now", e.Kind) } if e.Repo != "alpha" || e.Actor != "dan" { t.Errorf("repo/actor = %q/%q", e.Repo, e.Actor) } if e.Link != "/r/alpha/ticket/abcdef0123456789" { t.Errorf("link = %q", e.Link) } if e.At != "2026-08-24T10:00:00Z" { t.Errorf("at = %q, want the event's own date", e.At) } if e.ID != "ticket.closed:ffee0011223344556677" { t.Errorf("id = %q; it must be stable across redeliveries", e.ID) } case <-time.After(20 * time.Second): t.Fatal("a raised event never reached the endpoint") } } func TestLinkAndSummaryPerNoun(t *testing.T) { for _, tc := range []struct { kind string payload map[string]any link string summary string }{ {"checkin.created", map[string]any{"hash": "aabbccddeeff00112233", "summary": "fix it"}, "/r/alpha/checkin/aabbccddeeff0011", "fix it"}, {"branch.moved", map[string]any{"hash": "aabbccddeeff00112233", "branch": "feature"}, "/r/alpha/checkin/aabbccddeeff0011", "branch feature moved"}, {"wiki.created", map[string]any{"page": "Home"}, "/r/alpha/wiki/Home", "wiki page Home created"}, {"tag.created", map[string]any{"tag": "release", "target": "aabbccddeeff00112233"}, "/r/alpha/checkin/aabbccddeeff0011", "tag release created"}, {"landing.accepted", map[string]any{"landing": "1122334455667788aa"}, "/r/alpha/landing/1122334455667788", "landing.accepted"}, // A noun with no page gets no link rather than a broken one. {"clone.cloned", map[string]any{}, "", "clone.cloned"}, } { e := eventOf(raised{Kind: tc.kind, Repo: "alpha", Payload: tc.payload}, "now") if e.Link != tc.link { t.Errorf("%s link = %q, want %q", tc.kind, e.Link, tc.link) } if e.Summary != tc.summary { t.Errorf("%s summary = %q, want %q", tc.kind, e.Summary, tc.summary) } } }