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
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>
179 lines
7.9 KiB
Go
179 lines
7.9 KiB
Go
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)
|
||
}
|