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
+74 -36
View File
@@ -416,7 +416,7 @@ func TestSchedule_ReassigningAPartlyTakenWeekAsksOnce(t *testing.T) {
mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam")
}
// ── Ntfy topic ────────────────────────────────────────────────────────────
// ── Notification channels ─────────────────────────────────────────────────
// onUsers puts the model in the Users section with a loaded table.
func onUsers(users []api.User) Model {
@@ -428,42 +428,96 @@ func onUsers(users []api.User) Model {
}
func userFixtures() []api.User {
topic := "terdut-niklas"
return []api.User{
{ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic},
{ID: 1, Username: "niklas", Email: "niklas@example.com",
Channels: []api.NotificationChannel{{ID: 3, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}},
{ID: 2, Username: "alex", Email: "alex@example.com"},
}
}
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) {
func TestChannels_TOpensTheMenuForTheUserUnderTheCursor(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
if m.mode != modeUserNotifyEdit {
t.Fatalf("expected the topic editor, got mode %v", m.mode)
if m.mode != modeChannelMenu {
t.Fatalf("expected the channel menu, got mode %v", m.mode)
}
if m.selectedUser.ID != 1 {
t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID)
}
// Prefilled, so editing a topic does not mean retyping it from scratch.
if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" {
t.Errorf("expected the current topic prefilled, got %q", got)
}
}
// A user with no topic opens an empty field rather than the previous user's.
func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) {
m := onUsers(userFixtures())
m, _ = press(t, m, "t")
func TestChannels_AddStartsEmptyEveryTime(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
if m.mode != modeChannelAdd {
t.Fatalf("expected the add form, got mode %v", m.mode)
}
m = typeInto(t, m, "ntfy:abandoned")
m, _ = press(t, m, "esc")
m.userManageTable.SetCursor(1)
if m.mode != modeChannelMenu {
t.Fatalf("escape should go back to the menu, got mode %v", m.mode)
}
m, _ = press(t, m, "t")
if got := m.ntfyTopicInput.Value(); got != "" {
t.Errorf("expected an empty field for a user with no topic, got %q", got)
m, _ = press(t, m, "a")
if got := m.channelAddInput.Value(); got != "" {
t.Errorf("expected an empty field, got %q", got)
}
}
func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
func TestChannels_AddRejectsASpecWithoutTypeAndAddress(t *testing.T) {
for _, spec := range []string{"", "ntfy", "ntfy:", ":topic"} {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
m.channelAddInput.SetValue(spec)
m, cmd := press(t, m, "enter")
if m.mode != modeChannelAdd {
t.Errorf("%q: expected to stay on the form, got mode %v", spec, m.mode)
}
if m.statusMsg == "" {
t.Errorf("%q: expected an explanation", spec)
}
_ = cmd // only the status-clearing tick
}
}
func TestChannels_AddSubmitsAndReturnsToTheDashboard(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
m.channelAddInput.SetValue("ntfy:my-topic")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected adding a channel to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestChannels_RemoveNeedsAPositiveID(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "r")
if m.mode != modeChannelRemoveByID {
t.Fatalf("expected the remove form, got mode %v", m.mode)
}
for _, bad := range []string{"", "abc", "0", "-3"} {
m.channelRemoveIn.SetValue(bad)
next, _ := press(t, m, "enter")
if next.mode != modeChannelRemoveByID {
t.Errorf("%q: expected to stay on the form, got mode %v", bad, next.mode)
}
}
m.channelRemoveIn.SetValue("3")
m, cmd := press(t, m, "enter")
if cmd == nil || m.mode != modeDashboard {
t.Errorf("expected a valid ID to call the server and return, mode %v", m.mode)
}
}
func TestChannels_EscapeAbandonsWithoutSaving(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, cmd := press(t, m, "esc")
@@ -475,23 +529,7 @@ func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
}
}
// Clearing a topic is a real action, not a no-op: it is how a user is taken off
// their own topic and back onto the shared fallback. Contrast the snooze prompt,
// where an empty value means "I changed my mind".
func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m.ntfyTopicInput.SetValue("")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected clearing the topic to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) {
func TestChannels_IsUsersSectionOnly(t *testing.T) {
m := sized()
m.activeSection = sectionIncidents
if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard {