Files
minstrel/internal/notifications/digest_test.go
T
bvandeusenandClaude Opus 5.5 63709a433d
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(notifications): grouped email digest, new music as a daily summary (#5346)
Nothing is emailed per event. New music (request_completed) goes out at
most once a day, at the summary hour in each user's own timezone, grouped
by artist. Everything else is batched: one email a window after the first
un-emailed item, holding whatever accumulated.

- Migration 0074: notification_email_settings (summary hour, batch window,
  admin-configurable) and user_notification_email_state (batch start, last
  sent, failures and retry_after per user and group). Existing rows are
  stamped emailed so the upgrade sends no backlog.
- The Notifier stamps emailed_at at write time when the recipient's email
  channel is off, so turning email on later doesn't send old items.
- Read rows are never selected. A row is stamped only after the mailer
  accepts, in one transaction with the state, against the read's clock, so
  a coalesced row updated mid-send stays pending.
- A failed send backs off 5m doubling to 6h; SMTP not configured just waits.
- Links come from the public address; without one the email has none.
- The mailer now RFC 2047-encodes subjects and strips line breaks from them.
- Admin → Integrations gains a Notification emails card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:48:22 -04:00

179 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, `<a href="https://music.example/albums/al-1"`)
}
func TestRenderDigest_BatchListsEachItemAndEscapesHTML(t *testing.T) {
b, _ := json.Marshal(Payload{Name: "<script>x</script>", 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, "<script>")
one, err := renderDigest(EmailBatch, "alice", items[:1], "")
require.NoError(t, err)
require.Equal(t, "Minstrel: Request approved", one.Subject)
}