Add notification channels and a provider abstraction
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
+34
-21
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/notify"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,6 +92,15 @@ func (f *fakeNtfy) forget() {
|
||||
f.got = nil
|
||||
}
|
||||
|
||||
// providers registers the fake as the install's one ntfy provider.
|
||||
func (f *fakeNtfy) providers() *notify.Registry {
|
||||
return notify.NewRegistry(notify.NewNtfy(f.URL, ""))
|
||||
}
|
||||
|
||||
func ntfyTarget(topic string) notify.Target {
|
||||
return notify.Target{Type: notify.TypeNtfy, Address: topic}
|
||||
}
|
||||
|
||||
func (f *fakeNtfy) failWith(status int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -106,11 +116,11 @@ func (f *fakeNtfy) failWith(status int) {
|
||||
func notifyTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy) {
|
||||
t.Helper()
|
||||
f := newFakeNtfy(t)
|
||||
cfg.BaseURL = f.URL
|
||||
cfg.Providers = f.providers()
|
||||
s := newTS(t, cfg)
|
||||
|
||||
putOnCall(t, s, 1)
|
||||
setTopic(t, s, 1, "terdut-admin")
|
||||
addNtfyChannel(t, s, 1, "terdut-admin")
|
||||
return s, f
|
||||
}
|
||||
|
||||
@@ -125,13 +135,14 @@ func putOnCall(t *testing.T, s *ts, userID int) {
|
||||
}
|
||||
}
|
||||
|
||||
func setTopic(t *testing.T, s *ts, userID int, topic string) {
|
||||
// addNtfyChannel gives a user an ntfy channel, the way the Account page does.
|
||||
func addNtfyChannel(t *testing.T, s *ts, userID int, topic string) {
|
||||
t.Helper()
|
||||
resp := s.req(t, http.MethodPut,
|
||||
fmt.Sprintf("/api/users/%d/notify", userID), map[string]any{"ntfy_topic": topic})
|
||||
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID),
|
||||
map[string]any{"type": "ntfy", "address": topic})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("set notify topic returned %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("add notification channel returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,8 +350,8 @@ func TestNotify_DeliveryIsRecordedOnTheTimeline(t *testing.T) {
|
||||
if e["type"] != "notified" {
|
||||
t.Errorf("expected a notified event, got %v", e["type"])
|
||||
}
|
||||
if e["detail"] != "triggered" {
|
||||
t.Errorf("expected the kind in detail, got %v", e["detail"])
|
||||
if e["detail"] != "triggered via ntfy" {
|
||||
t.Errorf("expected the kind and the channel type in detail, got %v", e["detail"])
|
||||
}
|
||||
if e["username"] != "admin" {
|
||||
t.Errorf("expected the paged user attached, got %v", e["username"])
|
||||
@@ -370,7 +381,7 @@ func TestNotify_TimelineRecordsOneEventPerDelivery(t *testing.T) {
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("expected one event per delivery, got %v", events)
|
||||
}
|
||||
if events[0]["detail"] != "triggered" || events[1]["detail"] != "reminder" {
|
||||
if events[0]["detail"] != "triggered via ntfy" || events[1]["detail"] != "reminder via ntfy" {
|
||||
t.Errorf("expected triggered then reminder, got %v and %v",
|
||||
events[0]["detail"], events[1]["detail"])
|
||||
}
|
||||
@@ -392,7 +403,7 @@ func TestNotify_AllClearIsRecordedOnTheTimeline(t *testing.T) {
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("expected the all-clear recorded, got %v", events)
|
||||
}
|
||||
if events[1]["detail"] != "resolved" {
|
||||
if events[1]["detail"] != "resolved via ntfy" {
|
||||
t.Errorf("expected a resolved event, got %v", events[1]["detail"])
|
||||
}
|
||||
}
|
||||
@@ -402,9 +413,9 @@ func TestNotify_AllClearIsRecordedOnTheTimeline(t *testing.T) {
|
||||
func TestNotify_FallbackDeliveryHasNoUser(t *testing.T) {
|
||||
f := newFakeNtfy(t)
|
||||
s := newTS(t, api.NotifyConfig{
|
||||
BaseURL: f.URL,
|
||||
FallbackTopic: "terdut-oncall",
|
||||
PublicURL: "https://terdut.example.com",
|
||||
Providers: f.providers(),
|
||||
Fallback: ntfyTarget("terdut-oncall"),
|
||||
PublicURL: "https://terdut.example.com",
|
||||
})
|
||||
|
||||
fireCritical(t, s)
|
||||
@@ -441,7 +452,7 @@ func TestNotify_ExhaustedRetriesAreRecordedOnce(t *testing.T) {
|
||||
t.Errorf("expected notify_failed, got %v", events[0]["type"])
|
||||
}
|
||||
detail, _ := events[0]["detail"].(string)
|
||||
if !strings.HasPrefix(detail, "triggered: ") || !strings.Contains(detail, "500") {
|
||||
if !strings.HasPrefix(detail, "triggered via ntfy: ") || !strings.Contains(detail, "500") {
|
||||
t.Errorf("expected the kind and the reason in %q", detail)
|
||||
}
|
||||
}
|
||||
@@ -743,12 +754,12 @@ func TestNotify_ManualResolveSendsNothing(t *testing.T) {
|
||||
|
||||
// With nobody on call the page goes to the shared fallback, and carries no
|
||||
// Acknowledge button — there is no user to attribute the acknowledgement to.
|
||||
func TestNotify_FallbackTopicHasNoAckButton(t *testing.T) {
|
||||
func TestNotify_FallbackHasNoAckButton(t *testing.T) {
|
||||
f := newFakeNtfy(t)
|
||||
s := newTS(t, api.NotifyConfig{
|
||||
BaseURL: f.URL,
|
||||
FallbackTopic: "terdut-oncall",
|
||||
PublicURL: "https://terdut.example.com",
|
||||
Providers: f.providers(),
|
||||
Fallback: ntfyTarget("terdut-oncall"),
|
||||
PublicURL: "https://terdut.example.com",
|
||||
})
|
||||
|
||||
fireCritical(t, s)
|
||||
@@ -770,7 +781,7 @@ func TestNotify_FallbackTopicHasNoAckButton(t *testing.T) {
|
||||
// only pile up rows that can never be delivered.
|
||||
func TestNotify_NoTargetQueuesNothing(t *testing.T) {
|
||||
f := newFakeNtfy(t)
|
||||
s := newTS(t, api.NotifyConfig{BaseURL: f.URL})
|
||||
s := newTS(t, api.NotifyConfig{Providers: f.providers()})
|
||||
|
||||
fireCritical(t, s)
|
||||
|
||||
@@ -787,7 +798,9 @@ func TestNotify_NoTargetQueuesNothing(t *testing.T) {
|
||||
func TestNotify_DisabledQueuesNothing(t *testing.T) {
|
||||
s := newTS(t)
|
||||
putOnCall(t, s, 1)
|
||||
setTopic(t, s, 1, "terdut-admin")
|
||||
// Inserted rather than added through the API, which refuses a type this
|
||||
// server has no provider for.
|
||||
s.exec(t, "INSERT INTO notification_channels (user_id, type, address) VALUES (1, 'ntfy', 'terdut-admin')")
|
||||
|
||||
fireCritical(t, s)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user