Skip to content
quarry Artifact [da664b6d76]
Login

Artifact [da664b6d76]

Artifact da664b6d7647345581a6ecf62878f1ee34265dec3ff3b855b666d6dd9295a979:


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

// A subscription that named patterns gets those and nothing else, and a
// subscription that named none gets everything — including events added
// to the vocabulary after it was written.
func TestSubscriptionSelectsItsEvents(t *testing.T) {
	for _, tc := range []struct {
		events string
		kind   string
		want   bool
	}{
		{"", "ticket.closed", true},           // asked for nothing = everything
		{"*", "landing.accepted", true},       // asked for everything
		{"ticket", "ticket.closed", true},     // a bare noun takes every action
		{"ticket", "ticket.reopened", true},   // including ones added later
		{"ticket", "landing.accepted", false}, // and nothing else
		{"landing.accepted", "landing.accepted", true},
		{"landing.accepted", "landing.opened", false}, // an action is exact
		{"ticket landing.accepted", "landing.accepted", true},
		{"ticket landing.accepted", "wiki.edited", false},
		// A selective endpoint must not receive a surprise it cannot name.
		{"ticket", "something.new", false},
		{"*", "something.new", true},
	} {
		s := Sub{Events: tc.events}
		if got := s.wants(tc.kind); got != tc.want {
			t.Errorf("Sub{Events: %q}.wants(%q) = %v, want %v",
				tc.events, tc.kind, got, tc.want)
		}
	}
}

// The filter has to hold at the fan-out, not only in the predicate: an
// endpoint that asked for tickets must see no delivery at all for a
// landing, rather than one it has to ignore.
func TestUnselectedEventIsNeverQueued(t *testing.T) {
	hit := make(chan string, 4)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		hit <- r.Header.Get("X-Quarry-Event")
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	subs := &fakeSubs{subs: map[int64]Sub{
		1: {ID: 1, URL: srv.URL, Secret: "k", Active: true, Events: "ticket"},
	}}
	d := testDispatcher(t, subs)

	if err := d.Raise(Event{ID: "a", Repo: "alpha", Kind: "landing.accepted"}); err != nil {
		t.Fatal(err)
	}
	if err := d.Raise(Event{ID: "b", Repo: "alpha", Kind: "ticket.closed"}); err != nil {
		t.Fatal(err)
	}
	select {
	case got := <-hit:
		if got != "ticket.closed" {
			t.Fatalf("delivered %q; the landing was not selected", got)
		}
	case <-time.After(15 * time.Second):
		t.Fatal("the selected event was not delivered")
	}
	select {
	case got := <-hit:
		t.Errorf("a second delivery arrived: %q", got)
	case <-time.After(500 * time.Millisecond):
	}
}