Artifact 04c334875ff298a9322b093c74be5d0bdb7e59925b409d04ebae5c3ff3284b8f:
- File
internal/web/hooks_test.go
— part of check-in
[1c5d758256]
at
2026-08-24 17:20:25
on branch hooks-vocabulary
— Webhook settings on the repository's own admin screen
Subscriptions were CLI-only. The settings page now lists them, takes an endpoint plus its event patterns, pauses, resumes and deletes — gated on 'a' like everything else there. Patterns are checked before saving.
The section is gated on the store being attached, not on the list being non-empty, or the first subscription could never be created. (user: dmestas, size: 2848) [annotate] [blame] [check-ins using] [more...]
package web import ( "strings" "testing" ) func renderSettings(t *testing.T, d pageData) string { t.Helper() var sb strings.Builder if err := settingsPage.Execute(&sb, d); err != nil { t.Fatalf("render: %v", err) } return sb.String() } // The section is gated on the PLANE, not on the list. A repository with // no subscriptions must still show the form that creates the first one — // gating on the list is how a settings screen becomes impossible to use. func TestWebhookFormRendersWithNoSubscriptions(t *testing.T) { got := renderSettings(t, pageData{Repo: "alpha", HasHooks: true}) if !strings.Contains(got, `action="/r/alpha/settings/hooks"`) { t.Error("no webhook form on a repository with zero subscriptions") } if !strings.Contains(got, `value="add"`) { t.Error("no add action") } } // No store attached is not the same fact as no subscriptions, and only // one of them invites a click. func TestWebhookSectionAbsentWithoutAStore(t *testing.T) { got := renderSettings(t, pageData{Repo: "alpha"}) if strings.Contains(got, "/settings/hooks") { t.Error("drew a webhook form with no store behind it") } } func TestWebhookRowsCarryTheirActions(t *testing.T) { got := renderSettings(t, pageData{Repo: "alpha", HasHooks: true, Hooks: []HookSub{ {ID: 7, URL: "https://example.test/h", Events: "ticket", Active: true}, {ID: 8, URL: "https://example.test/j", Events: "*", Active: false}, }}) for _, want := range []string{ `value="7"`, `value="8"`, `value="ticket"`, // the selection is editable in place `value="pause"`, // an active subscription offers pause `value="resume"`, // a paused one offers resume `value="delete"`, `value="events"`, } { if !strings.Contains(got, want) { t.Errorf("missing %s", want) } } } // The secret exists in exactly one render and is never stored readable, // so the one render has to actually show it. func TestNewSecretShownOnce(t *testing.T) { got := renderSettings(t, pageData{Repo: "alpha", HasHooks: true, NewHookSecret: "deadbeef"}) if !strings.Contains(got, "deadbeef") { t.Error("a freshly minted secret was not shown") } if strings.Contains(renderSettings(t, pageData{Repo: "alpha", HasHooks: true}), "deadbeef") { t.Error("the secret survived into a later render") } } func TestCheckPatternsRefusesWhatCannotMatch(t *testing.T) { for _, ok := range []string{"", "*", "ticket", "ticket.closed", "ticket landing.accepted"} { if err := checkPatterns(ok); err != nil { t.Errorf("checkPatterns(%q) = %v, want nil", ok, err) } } // "tickets" is the typo that would otherwise deliver nothing and // read as a broken forge. for _, bad := range []string{"tickets", "ticket.exploded", "landing.merged"} { if err := checkPatterns(bad); err == nil { t.Errorf("checkPatterns(%q) accepted a pattern no event matches", bad) } } }