Implements the notification system foundation and all v1 delivery channels that need no external infrastructure (specs 00/01/04/05 in docs/superpowers/plans/notifications/): Foundation (spec 01): - episode_availability seeding + per-library seed markers: "newly available" means newly released to this server, so back-catalog imports and first scans never flood (verified on dev: 1.13M episodes seeded silently) - release_events -> profile_series_interest fanout worker with settling delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims, and a guarded last-notified cursor - interest index maintained via a userstore provider decorator so every favorites/watchlist/progress mutation path (REST, jellycompat, imports, playback) feeds it; progress writes only recompute on state transitions - durable per-profile inbox + read state, forward-sync cursor API, websocket channel with short-lived single-use handshake tickets - web UI: sidebar badge, inbox page, toasts, per-profile preferences - startup/daily tasks: availability seeding, interest rebuild, retention Outbound webhooks (spec 04): - Discord embeds (text-only per the v1 privacy contract) and generic JSON signed Stripe-style with per-webhook secrets - HTTPS-only + private-destination guard enforced at registration and at connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest - durable per-target outbox enqueued in the fanout transaction, lease-based claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an in-app notice (loop-guarded) Web push (spec 05): - VAPID keypair self-provisioned at startup (single atomic JSON setting, private half encrypted at rest) — no third-party accounts needed - payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe - service worker + subscribe flow in Settings -> Notifications Shared SMTP core (internal/mail): - feature-agnostic mail.Sender over live email.* settings, STARTTLS or implicit TLS, encrypted password, admin Email settings page with synchronous test send; no consumer yet by design (digest is v1.5) APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports them unavailable so clients render truthfully. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
214 lines
6.9 KiB
Go
214 lines
6.9 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func intPtr(v int) *int { return &v }
|
|
|
|
func TestEpisodeKey(t *testing.T) {
|
|
if got := EpisodeKey(2, 1); got != 2_000_001 {
|
|
t.Fatalf("EpisodeKey(2,1) = %d, want 2000001", got)
|
|
}
|
|
if got := EpisodeKey(0, 0); got != 0 {
|
|
t.Fatalf("EpisodeKey(0,0) = %d, want 0", got)
|
|
}
|
|
// Absolute-numbered anime catalogs exceed 10k episodes in one season.
|
|
if EpisodeKey(1, 11000) <= EpisodeKey(1, 10999) {
|
|
t.Fatal("episode keys must stay ordered for large episode numbers")
|
|
}
|
|
if EpisodeKey(2, 0) <= EpisodeKey(1, 999_999) {
|
|
t.Fatal("season boundary must dominate any in-season episode number")
|
|
}
|
|
}
|
|
|
|
func TestValidEpisodeOrdinals(t *testing.T) {
|
|
cases := []struct {
|
|
season, episode int
|
|
want bool
|
|
}{
|
|
{0, 0, true},
|
|
{1, 999_999, true},
|
|
{1, 1_000_000, false},
|
|
{-1, 1, false},
|
|
{1, -1, false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := ValidEpisodeOrdinals(tc.season, tc.episode); got != tc.want {
|
|
t.Errorf("ValidEpisodeOrdinals(%d,%d) = %v, want %v", tc.season, tc.episode, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluateRecipientReasons(t *testing.T) {
|
|
prefs := DefaultPreferences("p1")
|
|
episodeKey := EpisodeKey(2, 5)
|
|
|
|
t.Run("favorite matches", func(t *testing.T) {
|
|
flags, ok := EvaluateRecipient(SeriesInterest{Favorite: true}, prefs, episodeKey)
|
|
if !ok || !flags.Favorite || flags.Watchlist || flags.NextUp {
|
|
t.Fatalf("unexpected flags %+v ok=%v", flags, ok)
|
|
}
|
|
})
|
|
|
|
t.Run("multiple reasons merge", func(t *testing.T) {
|
|
interest := SeriesInterest{
|
|
Favorite: true,
|
|
ContinueWatching: true,
|
|
NextUpCandidate: true,
|
|
NextExpectedEpisodeKey: intPtr(episodeKey),
|
|
}
|
|
flags, ok := EvaluateRecipient(interest, prefs, episodeKey)
|
|
if !ok || !flags.Favorite || !flags.ContinueWatching || !flags.NextUp {
|
|
t.Fatalf("unexpected flags %+v ok=%v", flags, ok)
|
|
}
|
|
})
|
|
|
|
t.Run("next up gated by cursor", func(t *testing.T) {
|
|
interest := SeriesInterest{
|
|
NextUpCandidate: true,
|
|
NextExpectedEpisodeKey: intPtr(episodeKey + 1),
|
|
}
|
|
if _, ok := EvaluateRecipient(interest, prefs, episodeKey); ok {
|
|
t.Fatal("episode below next_expected must not notify via next_up")
|
|
}
|
|
interest.NextExpectedEpisodeKey = intPtr(episodeKey)
|
|
if _, ok := EvaluateRecipient(interest, prefs, episodeKey); !ok {
|
|
t.Fatal("episode at next_expected must notify via next_up")
|
|
}
|
|
})
|
|
|
|
t.Run("last notified suppresses repeats and older keys", func(t *testing.T) {
|
|
interest := SeriesInterest{Favorite: true, LastNotifiedEpisodeKey: intPtr(episodeKey)}
|
|
if _, ok := EvaluateRecipient(interest, prefs, episodeKey); ok {
|
|
t.Fatal("already-notified key must suppress")
|
|
}
|
|
if _, ok := EvaluateRecipient(interest, prefs, episodeKey-1); ok {
|
|
t.Fatal("older key must suppress")
|
|
}
|
|
if _, ok := EvaluateRecipient(interest, prefs, episodeKey+1); !ok {
|
|
t.Fatal("newer key must notify")
|
|
}
|
|
})
|
|
|
|
t.Run("preferences are a hard gate", func(t *testing.T) {
|
|
disabled := DefaultPreferences("p1")
|
|
disabled.NotifyFavorites = false
|
|
if _, ok := EvaluateRecipient(SeriesInterest{Favorite: true}, disabled, episodeKey); ok {
|
|
t.Fatal("disabled reason must not produce a delivery")
|
|
}
|
|
killSwitch := DefaultPreferences("p1")
|
|
killSwitch.Enabled = false
|
|
interest := SeriesInterest{Favorite: true, Watchlist: true, ContinueWatching: true}
|
|
if _, ok := EvaluateRecipient(interest, killSwitch, episodeKey); ok {
|
|
t.Fatal("master toggle must suppress everything")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplyBurstCap(t *testing.T) {
|
|
event := func(library int, series string, key int) ReleaseEvent {
|
|
return ReleaseEvent{
|
|
ID: series + "-" + time.Duration(key).String(),
|
|
LibraryID: library,
|
|
SeriesID: series,
|
|
EpisodeKey: key,
|
|
}
|
|
}
|
|
|
|
t.Run("caps per series keeping highest keys", func(t *testing.T) {
|
|
events := []ReleaseEvent{
|
|
event(1, "a", 1), event(1, "a", 2), event(1, "a", 3), event(1, "a", 4), event(1, "a", 5),
|
|
event(1, "b", 10),
|
|
}
|
|
fanout, suppressed := ApplyBurstCap(events, 3)
|
|
if len(fanout) != 4 || len(suppressed) != 2 {
|
|
t.Fatalf("got %d fanned out, %d suppressed; want 4/2", len(fanout), len(suppressed))
|
|
}
|
|
for _, ev := range suppressed {
|
|
if ev.SeriesID != "a" || ev.EpisodeKey > 2 {
|
|
t.Fatalf("suppressed wrong event: %+v", ev)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("distinct libraries are distinct groups", func(t *testing.T) {
|
|
events := []ReleaseEvent{
|
|
event(1, "a", 1), event(1, "a", 2),
|
|
event(2, "a", 1), event(2, "a", 2),
|
|
}
|
|
fanout, suppressed := ApplyBurstCap(events, 2)
|
|
if len(fanout) != 4 || len(suppressed) != 0 {
|
|
t.Fatalf("got %d/%d; same series in two libraries must not share a cap", len(fanout), len(suppressed))
|
|
}
|
|
})
|
|
|
|
t.Run("under cap passes through", func(t *testing.T) {
|
|
events := []ReleaseEvent{event(1, "a", 1), event(1, "b", 2)}
|
|
fanout, suppressed := ApplyBurstCap(events, 3)
|
|
if len(fanout) != 2 || len(suppressed) != 0 {
|
|
t.Fatalf("got %d/%d; want 2/0", len(fanout), len(suppressed))
|
|
}
|
|
})
|
|
|
|
t.Run("kept events emit in ascending key order", func(t *testing.T) {
|
|
// Fanout raises last_notified_episode_key as it processes each event
|
|
// inside one transaction; emitting a higher key first would make
|
|
// EvaluateRecipient suppress every remaining event in the group.
|
|
events := []ReleaseEvent{
|
|
event(1, "a", 5), event(1, "a", 3), event(1, "a", 4), event(1, "a", 1),
|
|
}
|
|
fanout, suppressed := ApplyBurstCap(events, 3)
|
|
if len(fanout) != 3 || len(suppressed) != 1 {
|
|
t.Fatalf("got %d/%d; want 3/1", len(fanout), len(suppressed))
|
|
}
|
|
if fanout[0].EpisodeKey != 3 || fanout[1].EpisodeKey != 4 || fanout[2].EpisodeKey != 5 {
|
|
t.Fatalf("want keys [3 4 5] in order, got %+v", fanout)
|
|
}
|
|
if suppressed[0].EpisodeKey != 1 {
|
|
t.Fatalf("want lowest key suppressed, got %+v", suppressed[0])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCursorRoundTrip(t *testing.T) {
|
|
cursor := Cursor{CreatedAt: time.Date(2026, 6, 11, 10, 30, 0, 123456789, time.UTC), ID: "01ABC"}
|
|
decoded, err := DecodeCursor(cursor.Encode())
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if !decoded.CreatedAt.Equal(cursor.CreatedAt) || decoded.ID != cursor.ID {
|
|
t.Fatalf("round trip mismatch: %+v vs %+v", decoded, cursor)
|
|
}
|
|
if _, err := DecodeCursor("not-a-cursor"); err == nil {
|
|
t.Fatal("garbage cursor must fail to decode")
|
|
}
|
|
if _, err := DecodeCursor(""); err == nil {
|
|
t.Fatal("empty cursor must fail to decode")
|
|
}
|
|
}
|
|
|
|
func TestMemoryTicketStore(t *testing.T) {
|
|
store := NewTicketStore(nil)
|
|
ctx := context.Background()
|
|
|
|
ticket, ttl, err := store.Mint(ctx, 7, "profile-1")
|
|
if err != nil || ticket == "" || ttl <= 0 {
|
|
t.Fatalf("mint failed: %q %v %v", ticket, ttl, err)
|
|
}
|
|
|
|
userID, profileID, ok := store.Consume(ctx, ticket)
|
|
if !ok || userID != 7 || profileID != "profile-1" {
|
|
t.Fatalf("consume returned %d %q %v", userID, profileID, ok)
|
|
}
|
|
|
|
if _, _, ok := store.Consume(ctx, ticket); ok {
|
|
t.Fatal("tickets must be single-use")
|
|
}
|
|
if _, _, ok := store.Consume(ctx, "unknown"); ok {
|
|
t.Fatal("unknown tickets must be rejected")
|
|
}
|
|
}
|