Files
minstrel/internal/library/notify_test.go
T
bvandeusenandClaude Opus 5.5 7e6228c13f
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.

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

208 lines
6.9 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 (
"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)
}
}
// 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)
}
}