Manage notification channels instead of an ntfy topic

Mirrors the server's channels API: add/remove channels per user from the Users
section. Requires the matching server release.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 77f2d6775b
commit ec5df0449b
10 changed files with 405 additions and 174 deletions
+43 -9
View File
@@ -184,14 +184,17 @@ func TestEventLabel_KnownTypes(t *testing.T) {
"Notified bo (triggered)"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"},
"Notified bo (reminder)"},
// On a notification, no user means the shared fallback topic — not that
// The server names the channel type after the kind.
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered via ntfy"},
"Notified bo (triggered via ntfy)"},
// On a notification, no user means the shared fallback — not that
// the server acted on its own.
{api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"},
"Notified the fallback topic (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"},
"Notified the fallback (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered via ntfy: ntfy returned 502"},
"Notification to bo failed"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"},
"Notification to the fallback topic failed"},
"Notification to the fallback failed"},
}
for _, tt := range tests {
t.Run(tt.event.Type, func(t *testing.T) {
@@ -216,14 +219,45 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
}
func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) {
func TestChannelMenu_ListsKindsAndSaysWhenThereAreNone(t *testing.T) {
m := sized()
m.mode = modeUserNotifyEdit
m.mode = modeChannelMenu
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic")
// The footer has to repeat it: that is where the reader looks for what a key does.
mustContain(t, m.renderFooter(), "empty clears the topic")
mustContain(t, m.View(), "niklas", "None", "fallback", "add a channel", "remove a channel")
mustContain(t, m.renderFooter(), "add a channel", "remove by ID")
// An administrator sees the kind and ID of somebody else's channel, never where it goes.
m.selectedUser.Channels = []api.NotificationChannel{{ID: 7, Type: "ntfy", Enabled: false}}
mustContain(t, m.View(), "#7", "ntfy", "(address hidden)", "(off)")
}
func TestChannelAdd_ExplainsTheSpec(t *testing.T) {
m := sized()
m.mode = modeChannelAdd
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "type", "colon")
mustContain(t, m.renderFooter(), "type:address")
}
func TestParseChannelSpec(t *testing.T) {
for spec, want := range map[string][2]string{
"ntfy:my-topic": {"ntfy", "my-topic"},
" ntfy : my-topic ": {"ntfy", "my-topic"},
"mail:a@b.example": {"mail", "a@b.example"},
"ntfy:a:b": {"ntfy", "a:b"},
} {
typ, address, ok := parseChannelSpec(spec)
if !ok || typ != want[0] || address != want[1] {
t.Errorf("%q: got %q, %q, %v", spec, typ, address, ok)
}
}
for _, bad := range []string{"", "ntfy", "ntfy:", ":topic", " "} {
if _, _, ok := parseChannelSpec(bad); ok {
t.Errorf("%q should not parse", bad)
}
}
}
func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {