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
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>
306 lines
10 KiB
Go
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")
|
|
}
|
|
}
|