Files
minstrel/internal/library/moved_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

306 lines
10 KiB
Go

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)
}
}
// An unrelated file that happens to share a size and a duration with a missing
// track is not adopted. Size and duration are not 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.
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")
}
}