Files
minstrel/internal/library/notify_test.go
T
bvandeusenandClaude Opus 5.5 854c307834
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m33s
release / android (push) Failing after 1m42s
release / integration (push) Failing after 4m42s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: notifications say what changed and alert once: a grouped notice names the tracks, grows without re-alerting, and a phone pile lists its titles (M542 #6146-#6151)
- An update to a grouped notice keeps its arrival time and email state, so the
  phone and the email no longer go off again each time it grows.
- Notices carry the names of what changed (newest ten), shown as three and
  "and N more": tracks still missing after the scan's move/replacement fold,
  duplicate groups new to a sweep, songs merged automatically, and the track
  and listener a playback error was for.
- Duplicates found counts only the groups new to that sweep; playback errors
  count reports, not the unresolved total.
- A scan failure says what stopped and links to Admin > Library.
- Android: a pile of more than three alerts lists the newest five titles.

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

223 lines
7.4 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 library
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// notifyingAdmin wires a real notifier for the test and returns an admin whose
// inbox the test reads.
func notifyingAdmin(t *testing.T, pool *pgxpool.Pool) dbq.User {
t.Helper()
admin, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "libnotify", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: true,
})
if err != nil {
t.Fatalf("admin: %v", err)
}
SetNotifier(notifications.New(pool, nil, nil))
t.Cleanup(func() { SetNotifier(nil) })
return admin
}
type notice struct {
kind string
count int64
body string
items []string
}
func unreadNotices(t *testing.T, pool *pgxpool.Pool, user dbq.User) []notice {
t.Helper()
rows, err := dbq.New(pool).ListNotifications(context.Background(), dbq.ListNotificationsParams{
UserID: user.ID, PageLimit: 50,
})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
var out []notice
for _, r := range rows {
if r.ReadAt.Valid {
continue
}
var p notifications.Payload
if err := json.Unmarshal(r.Payload, &p); err != nil {
t.Fatalf("payload: %v", err)
}
out = append(out, notice{kind: r.Kind, count: p.Count, body: p.Detail, items: p.Items})
}
return out
}
func TestNotifyScanFinished_FailuresCoalesceAndShutdownIsSilent(t *testing.T) {
pool := newPool(t)
admin := notifyingAdmin(t, pool)
ctx := context.Background()
notifyScanFinished(ctx, "")
if got := unreadNotices(t, pool, admin); len(got) != 0 {
t.Fatalf("a clean scan notified: %+v", got)
}
cancelled, cancel := context.WithCancel(ctx)
cancel()
notifyScanFinished(cancelled, "library: context canceled")
if got := unreadNotices(t, pool, admin); len(got) != 0 {
t.Fatalf("a scan cut short by shutdown notified: %+v", got)
}
notifyScanFinished(ctx, "library: root /music missing")
notifyScanFinished(ctx, "library: root /music still missing")
got := unreadNotices(t, pool, admin)
if len(got) != 1 || got[0].kind != string(notifications.KindScanFailed) || got[0].count != 2 ||
got[0].body != "library: root /music still missing" {
t.Fatalf("notices = %+v, want one scan_failed counting 2 with the latest error", got)
}
}
// M542: reconcile only records what it marked. The scan tells admins after
// it has adopted moves and folded replacements, so a file it marked and then
// healed is never reported as lost.
func TestReconcileMissing_RecordsTheMarksWithoutNotifying(t *testing.T) {
pool := newPool(t)
admin := notifyingAdmin(t, pool)
s := testScanner(t, populatedRoot(t))
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
seen := map[string]struct{}{}
for i := 0; i < 10; i++ {
p := fmt.Sprintf("/music/%02d.mp3", i)
rows = append(rows, row(byte(i), p, false))
if i >= 2 {
seen[p] = struct{}{}
}
}
var stats Stats
if err := s.reconcileMissing(context.Background(), &fakeReconciler{rows: rows}, seen, &stats); err != nil {
t.Fatalf("reconcile: %v", err)
}
if len(stats.marked) != 2 {
t.Errorf("marked %d tracks; want the 2 the walk did not see", len(stats.marked))
}
if got := unreadNotices(t, pool, admin); len(got) != 0 {
t.Fatalf("reconcile notified before the scan could heal anything: %+v", got)
}
}
func TestNotifyStillMissing_NamesOnlyTheTracksStillGone(t *testing.T) {
pool := newPool(t)
admin := notifyingAdmin(t, pool)
ctx := context.Background()
q := dbq.New(pool)
dir := t.TempDir()
first, album, artist := seedTrack(t, pool, filepath.Join(dir, "a.mp3"))
second, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "Another Track", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: filepath.Join(dir, "b.mp3"), FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatal(err)
}
// The scan marked both; the second came back (a move it adopted).
marked := []pgtype.UUID{first.ID, second.ID}
if _, err := q.MarkTracksMissing(ctx, []pgtype.UUID{first.ID}); err != nil {
t.Fatal(err)
}
s := New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
s.notifyStillMissing(ctx, marked)
got := unreadNotices(t, pool, admin)
if len(got) != 1 || got[0].kind != string(notifications.KindTracksMissing) || got[0].count != 1 ||
len(got[0].items) != 1 || got[0].items[0] != "Delete Test Artist – Delete Test Track" {
t.Fatalf("notices = %+v, want one tracks_missing naming the one track still gone", got)
}
// Nothing still gone: nothing to say.
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
t.Fatal(err)
}
s.notifyStillMissing(ctx, []pgtype.UUID{second.ID})
if got := unreadNotices(t, pool, admin); len(got) != 0 {
t.Fatalf("a healed track was reported: %+v", got)
}
}
func TestDuplicateSweep_NotifiesOnlyWhenSomethingNewIsProposed(t *testing.T) {
pool := newPool(t)
admin := notifyingAdmin(t, pool)
ctx := context.Background()
q := dbq.New(pool)
dir := t.TempDir()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
_, album, artist := seedTrack(t, pool, filepath.Join(dir, "seed.mp3"))
// Same audio-stream hash: an exact pair, whatever the prints say.
pair := func(name string, b byte, seed uint64) {
t.Helper()
for i := 1; i <= 2; i++ {
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 200000,
FilePath: filepath.Join(dir, fmt.Sprintf("%s-%d.mp3", name, i)), FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track %s: %v", name, err)
}
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
TrackID: tr.ID, AudioStreamSha256: bytes.Repeat([]byte{b}, 32),
Chromaprint: randomPrint(seed+uint64(i), printLen), FingerprintVersion: fingerprintVersion,
ChromaprintLengthSec: defaultChromaprintLengthSec,
}); err != nil {
t.Fatalf("fingerprint %s: %v", name, err)
}
}
}
sweep := func() {
t.Helper()
if _, err := runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage); err != nil {
t.Fatalf("sweep: %v", err)
}
}
markAllRead := func() {
t.Helper()
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
t.Fatalf("mark read: %v", err)
}
}
pair("first", 1, 100)
sweep()
got := unreadNotices(t, pool, admin)
if len(got) != 1 || got[0].kind != string(notifications.KindDuplicatesFound) || got[0].count != 1 ||
len(got[0].items) != 1 || got[0].items[0] != "Delete Test Artist – first" {
t.Fatalf("after the first sweep notices = %+v, want one duplicates_found naming the first pair", got)
}
// Read, then swept again with nothing new: no reminder.
markAllRead()
sweep()
if got := unreadNotices(t, pool, admin); len(got) != 0 {
t.Fatalf("a sweep that found nothing new notified: %+v", got)
}
// A new pair is news, and the notice is about it alone: the first was read.
pair("second", 2, 200)
sweep()
got = unreadNotices(t, pool, admin)
if len(got) != 1 || got[0].count != 1 || len(got[0].items) != 1 || got[0].items[0] != "Delete Test Artist – second" {
t.Fatalf("after a new pair notices = %+v, want one naming only the new pair", got)
}
}