2026-06-11 14:24:52 -04:00
|
|
|
package notifications
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestBuildWebPushPayload(t *testing.T) {
|
|
|
|
|
t.Run("episode available", func(t *testing.T) {
|
|
|
|
|
raw, err := buildWebPushPayload(webhookTestRow(), "https://cdn.example.com/poster.jpg")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("build failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var payload webPushPayload
|
|
|
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-07-01 17:25:16 -04:00
|
|
|
if payload.Title != "The latest episode of Severance S02E01 just dropped!" {
|
2026-06-11 14:24:52 -04:00
|
|
|
t.Fatalf("unexpected title %q", payload.Title)
|
|
|
|
|
}
|
2026-07-01 17:25:16 -04:00
|
|
|
if payload.Body != "Hello, Ms. Cobel" {
|
2026-06-11 14:24:52 -04:00
|
|
|
t.Fatalf("unexpected body %q", payload.Body)
|
|
|
|
|
}
|
|
|
|
|
if payload.URL != "/item/episode-456" {
|
|
|
|
|
t.Fatalf("unexpected url %q", payload.URL)
|
|
|
|
|
}
|
|
|
|
|
if payload.Icon != "https://cdn.example.com/poster.jpg" || payload.DeliveryID != "01DELIVERY" {
|
|
|
|
|
t.Fatalf("unexpected payload: %+v", payload)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-11 16:04:22 -04:00
|
|
|
t.Run("request fulfilled deep-links to the matched item", func(t *testing.T) {
|
|
|
|
|
raw, err := buildWebPushPayload(requestFulfilledTestRow(), "https://cdn.example.com/poster.jpg")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var payload webPushPayload
|
|
|
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if payload.Title != "Dune is now available" {
|
|
|
|
|
t.Fatalf("unexpected title %q", payload.Title)
|
|
|
|
|
}
|
|
|
|
|
if payload.URL != "/item/movie-123" {
|
|
|
|
|
t.Fatalf("unexpected url %q", payload.URL)
|
|
|
|
|
}
|
|
|
|
|
if payload.Icon != "https://cdn.example.com/poster.jpg" {
|
|
|
|
|
t.Fatalf("unexpected icon %q", payload.Icon)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-11 14:24:52 -04:00
|
|
|
t.Run("webhook auto-disable routes to settings", func(t *testing.T) {
|
|
|
|
|
row := DeliveryRow{Delivery: Delivery{ID: "01X", Type: DeliveryTypeWebhookAutoDisabled}}
|
|
|
|
|
raw, err := buildWebPushPayload(row, "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var payload webPushPayload
|
|
|
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if payload.URL != "/settings/notifications" {
|
|
|
|
|
t.Fatalf("unexpected url %q", payload.URL)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("unknown types render generically", func(t *testing.T) {
|
|
|
|
|
row := DeliveryRow{Delivery: Delivery{ID: "01Y", Type: "future.type"}}
|
|
|
|
|
raw, err := buildWebPushPayload(row, "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var payload webPushPayload
|
|
|
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if payload.Title != "Silo notification" || payload.URL != "/notifications" {
|
|
|
|
|
t.Fatalf("unexpected payload: %+v", payload)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 17:25:16 -04:00
|
|
|
func TestBuildNotificationDisplay(t *testing.T) {
|
|
|
|
|
t.Run("episode available", func(t *testing.T) {
|
|
|
|
|
display := BuildNotificationDisplay(webhookTestRow())
|
|
|
|
|
if display.DeliveryID != "01DELIVERY" ||
|
|
|
|
|
display.Title != "The latest episode of Severance S02E01 just dropped!" ||
|
|
|
|
|
display.Body != "Hello, Ms. Cobel" ||
|
|
|
|
|
display.ThreadID != "series:series-123" ||
|
|
|
|
|
display.Category != "episode_available" ||
|
|
|
|
|
display.URL != "/item/episode-456" {
|
|
|
|
|
t.Fatalf("display = %+v", display)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("request declined", func(t *testing.T) {
|
|
|
|
|
row := requestDeclinedTestRow()
|
|
|
|
|
display := BuildNotificationDisplay(row)
|
|
|
|
|
if display.Title != "Dune was declined" ||
|
|
|
|
|
display.Body != "Reason: Already available in 4K" ||
|
|
|
|
|
display.ThreadID != "request:01REQ" ||
|
|
|
|
|
display.Category != "request_declined" ||
|
|
|
|
|
display.URL != "/notifications" {
|
|
|
|
|
t.Fatalf("display = %+v", display)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("unknown type", func(t *testing.T) {
|
|
|
|
|
display := BuildNotificationDisplay(DeliveryRow{Delivery: Delivery{ID: "01X", Type: "future.type"}})
|
|
|
|
|
if display.Title != genericNotificationTitle ||
|
|
|
|
|
display.Category != "future_type" ||
|
|
|
|
|
display.URL != "/notifications" {
|
|
|
|
|
t.Fatalf("display = %+v", display)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 14:24:52 -04:00
|
|
|
func TestWebPushRetrySchedule(t *testing.T) {
|
|
|
|
|
total := time.Duration(0)
|
|
|
|
|
for attempt := 1; attempt < webPushMaxAttempts; attempt++ {
|
|
|
|
|
delay, ok := webPushRetryDelay(attempt)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("schedule ended early at attempt %d", attempt)
|
|
|
|
|
}
|
|
|
|
|
total += delay
|
|
|
|
|
}
|
|
|
|
|
if total != 30*time.Minute {
|
|
|
|
|
t.Fatalf("schedule must span 30m, got %v", total)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := webPushRetryDelay(webPushMaxAttempts); ok {
|
|
|
|
|
t.Fatal("attempt 5 must exhaust the schedule")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWebPushSubscribeValidation(t *testing.T) {
|
|
|
|
|
// Validation failures reject before any repository access, so a nil repo
|
|
|
|
|
// is safe here.
|
|
|
|
|
service := newWebPushService(nil, NewSettings(nil), nil)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
endpoint string
|
|
|
|
|
p256dh string
|
|
|
|
|
auth string
|
|
|
|
|
}{
|
|
|
|
|
{"missing endpoint", "", "k", "a"},
|
|
|
|
|
{"missing keys", "https://push.example.com/x", "", ""},
|
|
|
|
|
{"plain http", "http://push.example.com/x", "k", "a"},
|
|
|
|
|
{"loopback", "https://127.0.0.1/x", "k", "a"},
|
|
|
|
|
{"v4-mapped loopback", "https://[::ffff:127.0.0.1]/x", "k", "a"},
|
|
|
|
|
{"private network", "https://192.168.1.10/x", "k", "a"},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
_, err := service.Subscribe(ctx, 1, "p1", tc.endpoint, tc.p256dh, tc.auth, "test")
|
|
|
|
|
if !errors.Is(err, ErrWebPushInvalid) {
|
|
|
|
|
t.Fatalf("Subscribe = %v, want ErrWebPushInvalid", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|