package library import ( "bytes" "context" "errors" "fmt" "io" "log/slog" "path/filepath" "sync" "testing" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // TestFingerprintBackfill_Integration pins which tracks a pass touches, that a // pass ends, and that the coverage gauge counts what the pass wrote. func TestFingerprintBackfill_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) dir := t.TempDir() _, album, artist := seedTrack(t, pool, filepath.Join(dir, "unfingerprinted.mp3")) addTrack := func(name string) dbq.Track { t.Helper() tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: name, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatalf("track %s: %v", name, err) } return tr } current := addTrack("current") stale := addTrack("stale") missing := addTrack("missing") sum := bytes.Repeat([]byte{0xCD}, 32) for _, seed := range []struct { track dbq.Track version int16 }{ {current, fingerprintVersion}, {stale, fingerprintVersion - 1}, } { if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{ TrackID: seed.track.ID, AudioStreamSha256: sum, Chromaprint: []int32{1}, FingerprintVersion: seed.version, }); err != nil { t.Fatalf("seed fingerprint: %v", err) } } if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", missing.ID); err != nil { t.Fatalf("mark missing: %v", err) } var mu sync.Mutex calls := map[string]int{} w := NewFingerprintBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil))) // A batch of one forces the keyset cursor across several queries in a pass. w.batch = 1 w.fingerprint = func(_ context.Context, path string) fingerprintResult { name := filepath.Base(path) mu.Lock() calls[name]++ mu.Unlock() switch name { case "stall.mp3": return fingerprintResult{streamSHA256: sum, printErr: fmt.Errorf("fpcalc: %w", errFingerprintTimeout)} case "rejected.mp3": return fingerprintResult{hashErr: errors.New("ffmpeg exited 1"), printErr: errors.New("fpcalc exited 2")} default: return fingerprintResult{streamSHA256: sum, chromaprint: []int32{7, -7}} } } callCount := func(name string) int { mu.Lock() defer mu.Unlock() return calls[name] } // 1. Only the track with no row and the stale one are fingerprinted — never // the current one, never the missing one. res, err := w.pass(ctx) if err != nil { t.Fatalf("first pass: %v", err) } if res.Processed != 2 || res.Fingerprinted != 2 { t.Fatalf("first pass = %+v, want 2 processed, 2 fingerprinted", res) } for name, want := range map[string]int{ "unfingerprinted.mp3": 1, "stale.mp3": 1, "current.mp3": 0, "missing.mp3": 0, } { if got := callCount(name); got != want { t.Errorf("%s fingerprinted %d times, want %d", name, got, want) } } // 2. A pass after a complete one is a no-op. A backfill that redoes its work // every hour is the expensive way this could be wrong. res, err = w.pass(ctx) if err != nil { t.Fatalf("second pass: %v", err) } if res.Processed != 0 { t.Fatalf("second pass processed %d tracks, want 0", res.Processed) } // 3. An inconclusive file is tried exactly once and the pass ENDS. Without the // keyset cursor it would be re-listed immediately and this call would never // return. addTrack("stall") addTrack("rejected") res, err = w.pass(ctx) if err != nil { t.Fatalf("third pass: %v", err) } if res.Processed != 2 || res.Inconclusive != 1 || res.Rejected != 1 { t.Fatalf("third pass = %+v, want 2 processed, 1 inconclusive, 1 rejected", res) } if got := callCount("stall.mp3"); got != 1 { t.Fatalf("stalling file tried %d times in one pass, want exactly 1", got) } // 4. The gauge counts what the passes wrote, and its buckets add up. cov, err := FingerprintCoverage(ctx, pool) if err != nil { t.Fatalf("coverage: %v", err) } // Five present tracks: unfingerprinted, current, stale, stall, rejected. // The missing track is not counted. if cov.Total != 5 || cov.Fingerprinted != 3 || cov.Rejected != 1 || cov.Pending != 1 { t.Errorf("coverage = %+v, want total 5, fingerprinted 3, rejected 1, pending 1", cov) } if cov.Fingerprinted+cov.Rejected+cov.Pending != cov.Total { t.Errorf("coverage buckets %+v do not sum to the total", cov) } } func TestBackfillFingerprintsResult_Add(t *testing.T) { var r BackfillFingerprintsResult for _, o := range []fingerprintOutcome{ outcomeFingerprinted, outcomeFingerprinted, outcomeRejected, outcomeInconclusive, outcomeStoreFailed, } { r.add(o) } // A failed write stored nothing, so like an inconclusive attempt it is // tried again next pass — and counts as such. want := BackfillFingerprintsResult{Processed: 5, Fingerprinted: 2, Rejected: 1, Inconclusive: 2} if r != want { t.Errorf("tally = %+v, want %+v", r, want) } }