package notifications import ( "encoding/json" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/require" ) var ( digestCfg = EmailSettings{SummaryHour: 9, BatchWindowMinutes: 60} t0 = time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) ) func item(k Kind, at time.Time) pendingItem { return pendingItem{ID: pgtype.UUID{Bytes: [16]byte{byte(at.Minute()), byte(len(k))}, Valid: true}, Kind: k, CreatedAt: at} } func allEmail() map[Kind]bool { m := map[Kind]bool{} for _, k := range Kinds() { m[k] = true } return m } func TestPlanUser_BatchWindow(t *testing.T) { first := item(KindRequestApproved, t0) later := item(KindRequestRejected, t0.Add(40*time.Minute)) cases := []struct { name string now time.Time state groupState items []pendingItem wantDue bool wantOpen time.Time }{ {"opens at the first item and is not due inside the window", t0.Add(59 * time.Minute), groupState{}, []pendingItem{first, later}, false, t0}, {"due a window after the first item, carrying everything since", t0.Add(60 * time.Minute), groupState{}, []pendingItem{first, later}, true, t0}, {"a recorded start wins over an item that moved later", t0.Add(61 * time.Minute), groupState{BatchOpenedAt: t0}, []pendingItem{later}, true, t0}, {"a failed send waits out its retry", t0.Add(90 * time.Minute), groupState{BatchOpenedAt: t0, RetryAfter: t0.Add(95 * time.Minute)}, []pendingItem{first}, false, t0}, {"and goes once the retry has passed", t0.Add(96 * time.Minute), groupState{BatchOpenedAt: t0, RetryAfter: t0.Add(95 * time.Minute)}, []pendingItem{first}, true, t0}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { p := planUser(c.now, digestCfg, time.UTC, c.items, allEmail(), c.state, groupState{}) require.Equal(t, c.wantDue, p.BatchDue) require.Equal(t, c.wantOpen, p.BatchOpenedAt) require.Len(t, p.Batch, len(c.items)) }) } } func TestPlanUser_QuietBatchSendsNothing(t *testing.T) { p := planUser(t0.Add(3*time.Hour), digestCfg, time.UTC, nil, allEmail(), groupState{BatchOpenedAt: t0}, groupState{}) require.False(t, p.BatchDue) require.True(t, p.BatchOpenedAt.IsZero(), "nothing pending leaves no batch open") } func TestPlanUser_SummaryHourInUserTimezone(t *testing.T) { ny, err := time.LoadLocation("America/New_York") require.NoError(t, err) arrived := []pendingItem{item(KindRequestCompleted, time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC))} // 09:00 in New York in July is 13:00 UTC (EDT, UTC-4). cases := []struct { name string now time.Time lastSent time.Time want bool }{ {"before the local hour", time.Date(2026, 7, 1, 12, 59, 0, 0, time.UTC), time.Time{}, false}, {"at the local hour", time.Date(2026, 7, 1, 13, 0, 0, 0, time.UTC), time.Time{}, true}, {"once a day: already sent after today's hour", time.Date(2026, 7, 1, 18, 0, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), false}, {"yesterday's summary does not hold today's", time.Date(2026, 7, 2, 13, 5, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), true}, // 03:30 UTC on 2 July is 23:30 on 1 July in New York: still the 1st there. {"the local day, not the UTC day", time.Date(2026, 7, 2, 3, 30, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { p := planUser(c.now, digestCfg, ny, arrived, allEmail(), groupState{}, groupState{LastSentAt: c.lastSent}) require.Equal(t, c.want, p.SummaryDue) require.False(t, p.BatchDue, "new music never goes out in a batch") }) } } func TestPlanUser_SummaryAcrossDST(t *testing.T) { ny, err := time.LoadLocation("America/New_York") require.NoError(t, err) arrived := []pendingItem{item(KindRequestCompleted, time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC))} // Clocks go back on 1 November 2026: 09:00 the day before is 13:00 UTC // (EDT), and 09:00 that day is 14:00 UTC (EST). before := groupState{LastSentAt: time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC)} p := planUser(time.Date(2026, 11, 1, 13, 30, 0, 0, time.UTC), digestCfg, ny, arrived, allEmail(), groupState{}, before) require.False(t, p.SummaryDue, "08:30 local after the change is not yet the hour") p = planUser(time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), digestCfg, ny, arrived, allEmail(), groupState{}, before) require.True(t, p.SummaryDue, "09:00 local after the change") // Spring forward, 8 March 2026: 02:00 does not exist. A summary hour of // 2 still goes out that day, once. gap := EmailSettings{SummaryHour: 2, BatchWindowMinutes: 60} spring := []pendingItem{item(KindRequestCompleted, time.Date(2026, 3, 8, 0, 0, 0, 0, time.UTC))} at := time.Date(2026, 3, 8, 9, 0, 0, 0, time.UTC) // 05:00 EDT p = planUser(at, gap, ny, spring, allEmail(), groupState{}, groupState{LastSentAt: time.Date(2026, 3, 7, 7, 0, 0, 0, time.UTC)}) require.True(t, p.SummaryDue) p = planUser(at.Add(time.Hour), gap, ny, spring, allEmail(), groupState{}, groupState{LastSentAt: at}) require.False(t, p.SummaryDue) } func TestPlanUser_EmailOffAndStaleItemsAreSkippedNotSent(t *testing.T) { off := allEmail() off[KindRequestRejected] = false items := []pendingItem{ item(KindRequestRejected, t0), item(KindRequestApproved, t0.Add(-8*24*time.Hour)), } p := planUser(t0.Add(2*time.Hour), digestCfg, time.UTC, items, off, groupState{}, groupState{}) require.Len(t, p.Skip, 2) require.Empty(t, p.Batch) require.False(t, p.BatchDue, "nothing left after filtering: no email") require.False(t, p.SummaryDue) } func TestRetryDelay_DoublesToACap(t *testing.T) { require.Equal(t, 5*time.Minute, retryDelay(1)) require.Equal(t, 10*time.Minute, retryDelay(2)) require.Equal(t, 40*time.Minute, retryDelay(4)) require.Equal(t, 6*time.Hour, retryDelay(20)) } func TestRenderDigest_SummaryGroupsByArtist(t *testing.T) { arrival := func(p Payload) pendingItem { b, _ := json.Marshal(p.Map()) return pendingItem{Kind: KindRequestCompleted, Payload: b} } items := []pendingItem{ arrival(Payload{Name: "Moe Shop – WWW", Artist: "Moe Shop", Title: "WWW", AlbumID: "al-1"}), arrival(Payload{Name: "Boards of Canada", Artist: "Boards of Canada", ArtistID: "ar-2"}), arrival(Payload{Name: "Moe Shop – Pure", Artist: "Moe Shop", Title: "Pure", AlbumID: "al-3"}), arrival(Payload{Name: "Tycho – Awake"}), // stored before Artist was recorded } e, err := renderDigest(EmailSummary, "alice", items, "https://music.example/") require.NoError(t, err) require.Equal(t, "Minstrel: new music in your library", e.Subject) moe := strings.Index(e.Text, "Moe Shop") require.GreaterOrEqual(t, moe, 0) require.Equal(t, moe, strings.LastIndex(e.Text, "Moe Shop\n"), "one heading per artist") require.Less(t, moe, strings.Index(e.Text, "Boards of Canada"), "artists in the order they arrived") require.Contains(t, e.Text, " - WWW\n https://music.example/albums/al-1") require.Contains(t, e.Text, " - Pure\n https://music.example/albums/al-3") require.Contains(t, e.Text, "Boards of Canada\n https://music.example/artists/ar-2") require.Contains(t, e.Text, "Tycho\n - Awake") require.Contains(t, e.Text, "https://music.example/settings#notifications") require.Contains(t, e.HTML, `x", Reason: "dup"}.Map()) items := []pendingItem{ {Kind: KindRequestApproved, Payload: []byte(`{"name":"WWW"}`)}, {Kind: KindRequestRejected, Payload: b}, } e, err := renderDigest(EmailBatch, "alice", items, "") require.NoError(t, err) require.Equal(t, "Minstrel: 2 updates", e.Subject) require.Contains(t, e.Text, "- Request approved\n WWW is on its way.") require.NotContains(t, e.Text, "http", "no public address: no links") require.Contains(t, e.Text, "Settings → Notifications") require.NotContains(t, e.HTML, "