package library import ( "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) } } // Admins hear about a duplicate group once, when a resolver pass first leaves // it for them. A group the resolver settles or links is never news, and a // group already seen by an earlier pass is not news again. func TestResolveDuplicates_TellsAdminsOnlyOfNewGroupsLeftForThem(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")) if _, err := pool.Exec(ctx, `UPDATE tracks SET title = 'Alpha' WHERE id = $1`, first.ID); err != nil { t.Fatal(err) } second, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: "Beta", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: filepath.Join(dir, "b.mp3"), FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatal(err) } // One album, one recording under two song titles, no track list to decide. var groupID pgtype.UUID if err := pool.QueryRow(ctx, `INSERT INTO duplicate_groups (member_key, tier) VALUES ('notify-review', 'exact') RETURNING id`, ).Scan(&groupID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, groupID, first.ID, second.ID); err != nil { t.Fatal(err) } if _, err := ResolveDuplicates(ctx, pool, nil, "", true); err != nil { t.Fatal(err) } 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] == "" { t.Fatalf("after the first pass notices = %+v, want one duplicates_found naming the group", got) } if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil { t.Fatal(err) } if _, err := ResolveDuplicates(ctx, pool, nil, "", true); err != nil { t.Fatal(err) } if got := unreadNotices(t, pool, admin); len(got) != 0 { t.Fatalf("a group an earlier pass saw was told again: %+v", got) } }