package library import ( "bytes" "context" "errors" "testing" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // fakeAdopter answers the audio-hash lookup only for the hash it holds, the way // the real query does. A fake that returned its rows for any hash could not tell // adoption by identity apart from adoption by coincidence. type fakeAdopter struct { byMbid []dbq.FindMissingTrackByMbidRow hash []byte byHash []dbq.FindMissingTrackByAudioHashRow mbidErr error hashErr error adoptErr error adoptRows int64 mbidQueried []string hashQueried [][]byte 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) FindMissingTrackByAudioHash( _ context.Context, audioStreamSha256 []byte, ) ([]dbq.FindMissingTrackByAudioHashRow, error) { f.hashQueried = append(f.hashQueried, audioStreamSha256) if f.hashErr != nil { return nil, f.hashErr } if !bytes.Equal(audioStreamSha256, f.hash) { return nil, nil } return f.byHash, nil } 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 hashRow(n byte, path string) dbq.FindMissingTrackByAudioHashRow { return dbq.FindMissingTrackByAudioHashRow{ID: testUUID(n), FilePath: path} } func audioHash(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } 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, audioHash(1), "rec-mbid") { t.Fatal("expected the moved track to be adopted") } if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(7) || q.adopted[0].FilePath != newPath { t.Fatalf("adopted = %+v, want row 7 at %q", q.adopted, newPath) } // MBID matched, so the weaker signal should not have been consulted. if len(q.hashQueried) != 0 { t.Errorf("queried the audio hash despite an MBID match") } } func TestAdoptMovedTrack_FallsBackToAudioHash(t *testing.T) { s := testScanner(t) q := &fakeAdopter{hash: audioHash(3), byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(3, oldPath)}, adoptRows: 1} // No MBID: an untagged file, which is exactly what the fallback is for. if !s.adoptMovedTrack(context.Background(), q, newPath, audioHash(3), "") { t.Fatal("expected adoption via the audio hash") } if len(q.mbidQueried) != 0 { t.Errorf("queried by MBID with no MBID available") } if len(q.hashQueried) != 1 || !bytes.Equal(q.hashQueried[0], audioHash(3)) { t.Fatalf("hash queried = %x, want exactly the file's hash", q.hashQueried) } if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(3) { t.Errorf("adopted = %+v, want row 3", q.adopted) } } // The case the old (file_size, duration_ms) pair got wrong: an unrelated file // that happens to share a size and a duration with a missing track. Size and // duration are no longer inputs at all; only the audio itself can match, and a // different recording has a different hash. func TestAdoptMovedTrack_DifferentAudioIsNotAdopted(t *testing.T) { s := testScanner(t) q := &fakeAdopter{hash: audioHash(0xAA), byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(1, oldPath)}, adoptRows: 1} if s.adoptMovedTrack(context.Background(), q, newPath, audioHash(0xBB), "") { t.Fatal("adopted a missing track whose audio differs") } if len(q.adopted) != 0 { t.Errorf("adopted = %+v, want none", 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, nil, "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 audio hash, which is a // narrower signal — so falling through is allowed to succeed. func TestAdoptMovedTrack_AmbiguousMbidFallsThroughToAudioHash(t *testing.T) { s := testScanner(t) q := &fakeAdopter{ byMbid: []dbq.FindMissingTrackByMbidRow{ mbidRow(1, "/music/a.mp3"), mbidRow(2, "/music/b.mp3"), }, hash: audioHash(2), byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(2, "/music/b.mp3")}, adoptRows: 1, } if !s.adoptMovedTrack(context.Background(), q, newPath, audioHash(2), "rec-mbid") { t.Fatal("expected the audio hash to disambiguate") } if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(2) { t.Errorf("adopted = %+v, want row 2", q.adopted) } } // Two missing tracks with identical audio are duplicates of each other; a new // file matching both cannot be assigned to either. func TestAdoptMovedTrack_RefusesAmbiguousAudioHashMatch(t *testing.T) { s := testScanner(t) q := &fakeAdopter{hash: audioHash(5), byHash: []dbq.FindMissingTrackByAudioHashRow{ hashRow(1, "/music/a.mp3"), hashRow(2, "/music/b.mp3"), }, adoptRows: 1} if s.adoptMovedTrack(context.Background(), q, newPath, audioHash(5), "") { t.Fatal("expected refusal on an ambiguous audio-hash match") } if len(q.adopted) != 0 { t.Errorf("adopted despite ambiguity: %+v", q.adopted) } } // No hash means it could not be taken. An empty hash must never be looked up: // every unhashable file would pair with every other. func TestAdoptMovedTrack_SkipsAudioHashWhenAbsent(t *testing.T) { for name, hash := range map[string][]byte{"nil": nil, "empty": {}} { t.Run(name, func(t *testing.T) { s := testScanner(t) q := &fakeAdopter{hash: hash, byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(1, oldPath)}, adoptRows: 1} if s.adoptMovedTrack(context.Background(), q, newPath, hash, "") { t.Error("adopted without an audio hash") } if len(q.hashQueried) != 0 { t.Error("looked up an absent audio hash") } }) } } func TestAdoptMovedTrack_NoCandidates(t *testing.T) { s := testScanner(t) q := &fakeAdopter{adoptRows: 1} if s.adoptMovedTrack(context.Background(), q, newPath, audioHash(9), "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, audioHash(5), "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}}, {"audio hash lookup fails", &fakeAdopter{hashErr: 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, audioHash(1), "rec-mbid") { t.Error("reported adoption despite a query error") } }) } } // A failed MBID lookup must not stop the audio hash from being tried. func TestAdoptMovedTrack_MbidErrorStillTriesAudioHash(t *testing.T) { s := testScanner(t) q := &fakeAdopter{ mbidErr: errors.New("db hiccup"), hash: audioHash(9), byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(9, oldPath)}, adoptRows: 1, } if !s.adoptMovedTrack(context.Background(), q, newPath, audioHash(9), "rec-mbid") { t.Fatal("expected the audio hash 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 = rowsFromAudioHash([]dbq.FindMissingTrackByAudioHashRow{hashRow(3, "/c")}) if len(got) != 1 || got[0].id != testUUID(3) || got[0].filePath != "/c" { t.Errorf("rowsFromAudioHash = %+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") } }