package notifications_test import ( "context" "errors" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/notifications" ) // emailUser is a user with an address, so the digest can reach them. func emailUser(t *testing.T, pool *pgxpool.Pool, name string, admin bool) pgtype.UUID { t.Helper() id := mkUser(t, dbq.New(pool), name, admin) _, err := pool.Exec(context.Background(), "UPDATE users SET email = $2 WHERE id = $1", id, name+"@example.com") require.NoError(t, err) return id } func digestWith(pool *pgxpool.Pool) (*notifications.Digest, *mailer.FakeSender) { fake := &mailer.FakeSender{} return notifications.NewDigest(pool, fake, nil), fake } func TestDigest_BatchGoesOutOnceAWindowAfterTheFirstItem(t *testing.T) { pool := testPool(t) ctx := context.Background() alice := emailUser(t, pool, "alice", false) n := notifications.New(pool, nil, nil) d, fake := digestWith(pool) start := time.Now() require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), notifications.Payload{Name: "Moe Shop – WWW"}.Map())) require.NoError(t, n.Notify(ctx, notifications.KindRequestRejected, notifications.ToUser(alice), notifications.Payload{Name: "Tycho – Awake", Reason: "already have it"}.Map())) d.Tick(ctx, start.Add(30*time.Minute)) require.Empty(t, fake.Sent, "inside the window nothing goes") d.Tick(ctx, start.Add(61*time.Minute)) require.Len(t, fake.Sent, 1, "both items, one email") require.Equal(t, "alice@example.com", fake.Sent[0].To) require.Equal(t, "Minstrel: 2 updates", fake.Sent[0].Subject) require.Contains(t, fake.Sent[0].TextBody, "Moe Shop – WWW is on its way.") require.Contains(t, fake.Sent[0].TextBody, "Tycho – Awake was declined: already have it") d.Tick(ctx, start.Add(3*time.Hour)) require.Len(t, fake.Sent, 1, "never sent twice") } func TestDigest_ReadBeforeTheEmailIsNotEmailed(t *testing.T) { pool := testPool(t) ctx := context.Background() q := dbq.New(pool) alice := emailUser(t, pool, "alice", false) n := notifications.New(pool, nil, nil) d, fake := digestWith(pool) start := time.Now() require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil)) _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: alice}) require.NoError(t, err) d.Tick(ctx, start.Add(2*time.Hour)) require.Empty(t, fake.Sent, "everything was read: nothing to send") } func TestDigest_EmailOffAtNotifyTimeIsNeverSent(t *testing.T) { pool := testPool(t) ctx := context.Background() q := dbq.New(pool) alice := emailUser(t, pool, "alice", false) off := false _, err := notifications.SaveSettings(ctx, q, alice, false, []notifications.SettingChange{ {Kind: notifications.KindRequestApproved, Email: &off}, }) require.NoError(t, err) n := notifications.New(pool, nil, nil) d, fake := digestWith(pool) start := time.Now() require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil)) on := true _, err = notifications.SaveSettings(ctx, q, alice, false, []notifications.SettingChange{ {Kind: notifications.KindRequestApproved, Email: &on}, }) require.NoError(t, err) d.Tick(ctx, start.Add(2*time.Hour)) require.Empty(t, fake.Sent, "turning email on later does not send what came before") } func TestDigest_NoAddressNoEmail(t *testing.T) { pool := testPool(t) ctx := context.Background() bob := mkUser(t, dbq.New(pool), "bob", false) n := notifications.New(pool, nil, nil) d, fake := digestWith(pool) require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(bob), nil)) d.Tick(ctx, time.Now().Add(2*time.Hour)) require.Empty(t, fake.Sent) } func TestDigest_CoalescedItemAppearsOnceWithItsLatestCount(t *testing.T) { pool := testPool(t) ctx := context.Background() q := dbq.New(pool) root := emailUser(t, pool, "root", true) on := true _, err := notifications.SaveSettings(ctx, q, root, true, []notifications.SettingChange{ {Kind: notifications.KindTracksMissing, Email: &on}, }) require.NoError(t, err) n := notifications.New(pool, nil, nil) d, fake := digestWith(pool) start := time.Now() for _, c := range []int64{3, 2} { require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing, notifications.ToAdmins(pgtype.UUID{}), notifications.Payload{Count: c}.Map())) } d.Tick(ctx, start.Add(2*time.Hour)) require.Len(t, fake.Sent, 1) require.Equal(t, "Minstrel: 5 tracks went missing", fake.Sent[0].Subject) } func TestDigest_MailerFailureRetriesWithBackoffAndSendsOnce(t *testing.T) { pool := testPool(t) ctx := context.Background() alice := emailUser(t, pool, "alice", false) n := notifications.New(pool, nil, nil) d, fake := digestWith(pool) start := time.Now() require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil)) fake.FailNext = errors.New("smtp: 421 try later") d.Tick(ctx, start.Add(61*time.Minute)) require.Empty(t, fake.Sent) d.Tick(ctx, start.Add(63*time.Minute)) require.Empty(t, fake.Sent, "waits out the first retry gap") d.Tick(ctx, start.Add(67*time.Minute)) require.Len(t, fake.Sent, 1, "then goes") d.Tick(ctx, start.Add(80*time.Minute)) require.Len(t, fake.Sent, 1, "and is not sent again") } func TestDigest_NewMusicWaitsForTheSummaryHour(t *testing.T) { pool := testPool(t) ctx := context.Background() alice := emailUser(t, pool, "alice", false) // timezone defaults to UTC n := notifications.New(pool, nil, nil) d, fake := digestWith(pool) now := time.Now().UTC() slot := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, time.UTC) if !slot.After(now) { slot = slot.AddDate(0, 0, 1) } for _, p := range []notifications.Payload{ {Name: "Moe Shop – WWW", Artist: "Moe Shop", Title: "WWW"}, {Name: "Moe Shop – Pure", Artist: "Moe Shop", Title: "Pure"}, } { require.NoError(t, n.Notify(ctx, notifications.KindRequestCompleted, notifications.ToUser(alice), p.Map())) } d.Tick(ctx, slot.Add(-time.Minute)) require.Empty(t, fake.Sent, "new music never goes out in an hourly batch") d.Tick(ctx, slot) require.Len(t, fake.Sent, 1) require.Equal(t, "Minstrel: new music in your library", fake.Sent[0].Subject) require.Contains(t, fake.Sent[0].TextBody, "Moe Shop\n - WWW") require.Contains(t, fake.Sent[0].TextBody, " - Pure") d.Tick(ctx, slot.Add(5*time.Hour)) require.Len(t, fake.Sent, 1, "one summary a day") } func TestEmailSettings_DefaultsMatchTheMigrationAndRoundTrip(t *testing.T) { pool := testPool(t) ctx := context.Background() q := dbq.New(pool) got, err := notifications.LoadEmailSettings(ctx, q) require.NoError(t, err) require.Equal(t, notifications.DefaultEmailSettings, got) saved, err := notifications.SaveEmailSettings(ctx, q, notifications.EmailSettings{SummaryHour: 7, BatchWindowMinutes: 30}) require.NoError(t, err) require.Equal(t, notifications.EmailSettings{SummaryHour: 7, BatchWindowMinutes: 30}, saved) got, err = notifications.LoadEmailSettings(ctx, q) require.NoError(t, err) require.Equal(t, saved, got) } func TestDigest_UsesTheConfiguredBatchWindow(t *testing.T) { pool := testPool(t) ctx := context.Background() alice := emailUser(t, pool, "alice", false) _, err := notifications.SaveEmailSettings(ctx, dbq.New(pool), notifications.EmailSettings{SummaryHour: 9, BatchWindowMinutes: 15}) require.NoError(t, err) n := notifications.New(pool, nil, nil) d, fake := digestWith(pool) start := time.Now() require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil)) d.Tick(ctx, start.Add(16*time.Minute)) require.Len(t, fake.Sent, 1) }