package library import ( "context" "errors" "testing" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) type fakeAdopter struct { byMbid []dbq.FindMissingTrackByMbidRow byFingerprint []dbq.FindMissingTrackByFingerprintRow mbidErr error fingerprintErr error adoptErr error adoptRows int64 mbidQueried []string fingerprintQueried []dbq.FindMissingTrackByFingerprintParams adopted []dbq.AdoptTrackPathParams } func (f *fakeAdopter) FindMissingTrackByMbid(_ context.Context, mbid string) ([]dbq.FindMissingTrackByMbidRow, error) { f.mbidQueried = append(f.mbidQueried, mbid) return f.byMbid, f.mbidErr } func (f *fakeAdopter) FindMissingTrackByFingerprint( _ context.Context, arg dbq.FindMissingTrackByFingerprintParams, ) ([]dbq.FindMissingTrackByFingerprintRow, error) { f.fingerprintQueried = append(f.fingerprintQueried, arg) return f.byFingerprint, f.fingerprintErr } func (f *fakeAdopter) AdoptTrackPath(_ context.Context, arg dbq.AdoptTrackPathParams) (int64, error) { f.adopted = append(f.adopted, arg) if f.adoptErr != nil { return 0, f.adoptErr } return f.adoptRows, nil } // The narrowed interface must not drift from the real queries. var _ trackAdopter = (*dbq.Queries)(nil) func mbidRow(n byte, path string) dbq.FindMissingTrackByMbidRow { return dbq.FindMissingTrackByMbidRow{ID: testUUID(n), FilePath: path} } func fpRow(n byte, path string) dbq.FindMissingTrackByFingerprintRow { return dbq.FindMissingTrackByFingerprintRow{ID: testUUID(n), FilePath: path} } const ( oldPath = "/music/Linkin Park/Minutes to Midnight/02 - Bleed It Out.mp3" newPath = "/music/Linkin Park/Minutes to Midnight/04 - Bleed It Out.mp3" ) func TestAdoptMovedTrack_MatchesByMbid(t *testing.T) { s := testScanner(t) q := &fakeAdopter{byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(7, oldPath)}, adoptRows: 1} if !s.adoptMovedTrack(context.Background(), q, newPath, 5_000_000, 200_000, "rec-mbid") { t.Fatal("expected the moved track to be adopted") } if len(q.adopted) != 1 { t.Fatalf("adopted %d rows, want 1", len(q.adopted)) } if q.adopted[0].ID != testUUID(7) { t.Errorf("adopted the wrong row: %v", q.adopted[0].ID) } if q.adopted[0].FilePath != newPath { t.Errorf("adopted FilePath = %q, want %q", q.adopted[0].FilePath, newPath) } // MBID matched, so the weaker signal should not have been consulted. if len(q.fingerprintQueried) != 0 { t.Errorf("queried the fingerprint despite an MBID match") } } func TestAdoptMovedTrack_FallsBackToFingerprint(t *testing.T) { s := testScanner(t) q := &fakeAdopter{byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(3, oldPath)}, adoptRows: 1} // No MBID: an untagged file, which is exactly what the fallback is for. if !s.adoptMovedTrack(context.Background(), q, newPath, 4_200_000, 187_000, "") { t.Fatal("expected adoption via fingerprint") } if len(q.mbidQueried) != 0 { t.Errorf("queried by MBID with no MBID available") } if len(q.fingerprintQueried) != 1 { t.Fatalf("fingerprint queried %d times, want 1", len(q.fingerprintQueried)) } got := q.fingerprintQueried[0] if got.FileSize != 4_200_000 || got.DurationMs != 187_000 { t.Errorf("fingerprint = %+v, want size 4200000 duration 187000", got) } if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(3) { t.Errorf("adopted = %+v, want row 3", q.adopted) } } // Two missing rows carrying the same recording MBID means real duplicates. // Adopting one arbitrarily would attach this file's future history to a coin // flip, so it must insert fresh instead. func TestAdoptMovedTrack_RefusesAmbiguousMbidMatch(t *testing.T) { s := testScanner(t) q := &fakeAdopter{byMbid: []dbq.FindMissingTrackByMbidRow{ mbidRow(1, "/music/a.mp3"), mbidRow(2, "/music/b.mp3"), }, adoptRows: 1} if s.adoptMovedTrack(context.Background(), q, newPath, 0, 0, "rec-mbid") { t.Fatal("expected refusal on an ambiguous MBID match") } if len(q.adopted) != 0 { t.Errorf("adopted despite ambiguity: %+v", q.adopted) } } // An ambiguous MBID may still be resolvable by the fingerprint, which is a // narrower signal — so falling through is allowed to succeed. func TestAdoptMovedTrack_AmbiguousMbidFallsThroughToFingerprint(t *testing.T) { s := testScanner(t) q := &fakeAdopter{ byMbid: []dbq.FindMissingTrackByMbidRow{ mbidRow(1, "/music/a.mp3"), mbidRow(2, "/music/b.mp3"), }, byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(2, "/music/b.mp3")}, adoptRows: 1, } if !s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") { t.Fatal("expected the fingerprint to disambiguate") } if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(2) { t.Errorf("adopted = %+v, want row 2", q.adopted) } } func TestAdoptMovedTrack_RefusesAmbiguousFingerprintMatch(t *testing.T) { s := testScanner(t) q := &fakeAdopter{byFingerprint: []dbq.FindMissingTrackByFingerprintRow{ fpRow(1, "/music/a.mp3"), fpRow(2, "/music/b.mp3"), }, adoptRows: 1} if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "") { t.Fatal("expected refusal on an ambiguous fingerprint match") } if len(q.adopted) != 0 { t.Errorf("adopted despite ambiguity: %+v", q.adopted) } } // duration_ms is 0 when ffprobe failed. Matching 0 against 0 would pair up // unrelated broken files, so the fingerprint must not be attempted. func TestAdoptMovedTrack_SkipsFingerprintWithoutRealValues(t *testing.T) { tests := []struct { name string size int64 duration int32 }{ {"no duration", 1000, 0}, {"no size", 0, 2000}, {"neither", 0, 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s := testScanner(t) q := &fakeAdopter{ byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(1, oldPath)}, adoptRows: 1, } if s.adoptMovedTrack(context.Background(), q, newPath, tc.size, tc.duration, "") { t.Error("adopted on an unusable fingerprint") } if len(q.fingerprintQueried) != 0 { t.Error("queried the fingerprint with unusable values") } }) } } func TestAdoptMovedTrack_NoCandidates(t *testing.T) { s := testScanner(t) q := &fakeAdopter{adoptRows: 1} if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") { t.Fatal("expected no adoption when nothing matches") } if len(q.adopted) != 0 { t.Errorf("adopted with no candidates: %+v", q.adopted) } } // The row's mark was cleared between lookup and update — another file adopted it // first. AdoptTrackPath's `missing_since IS NOT NULL` predicate reports 0 rows. func TestAdoptMovedTrack_LostRaceReportsNotAdopted(t *testing.T) { s := testScanner(t) q := &fakeAdopter{ byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(5, oldPath)}, adoptRows: 0, } if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") { t.Fatal("expected not-adopted when the update matched no rows") } } // Failing to detect a move must never fail the file: the caller falls back to // inserting a fresh row, which is the pre-#2528 behaviour. func TestAdoptMovedTrack_ToleratesQueryErrors(t *testing.T) { sentinel := errors.New("db down") tests := []struct { name string q *fakeAdopter }{ {"mbid lookup fails", &fakeAdopter{mbidErr: sentinel}}, {"fingerprint lookup fails", &fakeAdopter{fingerprintErr: sentinel}}, {"adopt fails", &fakeAdopter{ byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(1, oldPath)}, adoptErr: sentinel, }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s := testScanner(t) if s.adoptMovedTrack(context.Background(), tc.q, newPath, 1000, 2000, "rec-mbid") { t.Error("reported adoption despite a query error") } }) } } // A failed MBID lookup must not stop the fingerprint from being tried. func TestAdoptMovedTrack_MbidErrorStillTriesFingerprint(t *testing.T) { s := testScanner(t) q := &fakeAdopter{ mbidErr: errors.New("db hiccup"), byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(9, oldPath)}, adoptRows: 1, } if !s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") { t.Fatal("expected the fingerprint to be tried after an MBID lookup error") } if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(9) { t.Errorf("adopted = %+v, want row 9", q.adopted) } } func TestUniqueMatch(t *testing.T) { s := testScanner(t) if _, ok := s.uniqueMatch(nil, newPath, "mbid"); ok { t.Error("empty candidate set matched") } c, ok := s.uniqueMatch([]candidate{{id: testUUID(4), filePath: oldPath}}, newPath, "mbid") if !ok { t.Fatal("single candidate did not match") } if c.id != testUUID(4) || c.filePath != oldPath { t.Errorf("candidate = %+v, want id 4 at %q", c, oldPath) } if _, ok := s.uniqueMatch([]candidate{ {id: testUUID(1)}, {id: testUUID(2)}, }, newPath, "mbid"); ok { t.Error("multiple candidates matched") } } func TestRowConverters(t *testing.T) { got := rowsFromMbid([]dbq.FindMissingTrackByMbidRow{mbidRow(1, "/a"), mbidRow(2, "/b")}) if len(got) != 2 || got[0].id != testUUID(1) || got[1].filePath != "/b" { t.Errorf("rowsFromMbid = %+v", got) } got = rowsFromFingerprint([]dbq.FindMissingTrackByFingerprintRow{fpRow(3, "/c")}) if len(got) != 1 || got[0].id != testUUID(3) || got[0].filePath != "/c" { t.Errorf("rowsFromFingerprint = %+v", got) } } // pgtype.UUID zero value must not be mistaken for a real id. func TestUniqueMatch_ZeroUUIDNotValid(t *testing.T) { var zero pgtype.UUID if zero.Valid { t.Fatal("zero pgtype.UUID should not be Valid") } }