package library import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "path/filepath" "testing" "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 } 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}) } 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) } } func TestReconcileMissing_NotifiesAdminsWithARunningCount(t *testing.T) { pool := newPool(t) admin := notifyingAdmin(t, pool) s := testScanner(t, populatedRoot(t)) // Two scans, each losing 2 of 10 tracks (under the mark cap). for pass := 0; pass < 2; pass++ { rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10) seen := map[string]struct{}{} for i := 0; i < 10; i++ { p := fmt.Sprintf("/music/pass-%d-%02d.mp3", pass, i) rows = append(rows, row(byte(pass*10+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 pass %d: %v", pass, err) } } got := unreadNotices(t, pool, admin) if len(got) != 1 || got[0].kind != string(notifications.KindTracksMissing) || got[0].count != 4 { t.Fatalf("notices = %+v, want one tracks_missing counting 4", 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 { t.Fatalf("after the first sweep notices = %+v, want one duplicates_found counting 1", 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 count is everything awaiting review. pair("second", 2, 200) sweep() got = unreadNotices(t, pool, admin) if len(got) != 1 || got[0].count != 2 { t.Fatalf("after a new pair notices = %+v, want one counting 2", got) } }