Files
bvandeusenandClaude Opus 5.5 c94c09aeae
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
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: Settings is one page per section down a left rail, Account first, every setting a row; the profile form no longer saves blanks over your name and email (M503 #5595 #5596 #5597)
- /settings lands on Account: the Android app, profile, password, signed-in
  devices. Then Playback, Appearance, Notifications, Connections, About.
- Each setting is a row (name, one short line, control at the end) in a
  capped-width column; one-of-N choices are a shared Segmented control.
- Collection and Settings share one SectionRail.
- Connections leads ListenBrainz with a status line: not connected,
  connected and not sending, or sending with the last scrobble's age.
- Notifications prints its column labels once; the digest email's
  settings link points at /settings/notifications.
- #5596: the profile form now loads what is stored and sends only the
  fields that changed. It used to start blank, and the server reads a
  blank as "clear", so one Save wiped the display name and email.
- Recommendation metrics moves to Library › Your listening, and Hidden
  tracks gets a Library tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:51 -04:00

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