Files
minstrel/internal/library/scanner_test.go
T
bvandeusen d212621eaa test: fix 4 integration-suite root causes surfaced by CI (C+A+B+F)
Latent failures exposed now that integration tests run in CI (#339).
All test/test-harness only — no production code changes.

A (dbtest.ResetDB): also truncate cover_art_provider_settings +
cover_art_sources_meta. The monotonic source-version counter (seeded 1
by 0018) accumulated across internal/coverart tests → CurrentVersion=4
want 1, key-only-bump assertions, enricher source skips. SettingsService
re-seeds both at boot, so a truncated start is the correct fresh state.

B (playlists system_test.seedPlayEvent): inserted play_events with a
random session_id → play_events_session_id_fkey violation (7 tests).
Create the parent play_sessions row in the same statement (CTE).

C (similarity worker_integration_test.newTestWorker): built the client
with only BaseURL. Post-4fca0e6 similarity hits the Labs API via
LabsBaseURL, so an unset LabsBaseURL fell through to the real labs.api
and the stub never ran → 0 similarity rows. Set LabsBaseURL to the stub.

F (library scanner_test): NOT a flake — deterministic. Synthetic
ID3-only MP3s have no decodable duration; the scanner intentionally
won't skip duration_ms=0 rows (retries duration backfill), so every
re-scan reported Updated not Skipped. Seed duration_ms>0 before the
second scan so the mtime-based incremental-skip path under test is
actually exercised. Production scanner behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:32:42 -04:00

169 lines
5.1 KiB
Go

package library
import (
"bytes"
"context"
"encoding/binary"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func TestReleaseDateFromYear(t *testing.T) {
cases := []struct {
in int
ok bool
label string
}{
{0, false, "missing tag"},
{-1, false, "negative"},
{1, true, "lower bound"},
{1999, true, "ordinary"},
{9999, true, "upper bound"},
{10000, false, "five digits"},
{99999999, false, "tag corruption"},
}
for _, c := range cases {
got, ok := releaseDateFromYear(c.in)
if ok != c.ok {
t.Errorf("%s: ok = %v, want %v", c.label, ok, c.ok)
}
if ok && (!got.Valid || got.Time.Year() != c.in) {
t.Errorf("%s: date = %+v, want year=%d", c.label, got, c.in)
}
}
}
// TestScanner_Integration exercises walk → tag-parse → upsert → incremental
// skip against a real Postgres. Gated on MINSTREL_TEST_DATABASE_URL.
func TestScanner_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping scanner integration in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := db.Migrate(dsn, logger); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(ctx, "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
root := t.TempDir()
writeTestMP3(t, filepath.Join(root, "artistX/albumA/01.mp3"), map[string]string{
"TIT2": "Track One", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "1", "TYER": "2020",
})
writeTestMP3(t, filepath.Join(root, "artistX/albumA/02.mp3"), map[string]string{
"TIT2": "Track Two", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "2", "TYER": "2020",
})
writeTestMP3(t, filepath.Join(root, "artistX/albumB/01.mp3"), map[string]string{
"TIT2": "B Song", "TPE1": "Artist X", "TALB": "Album B", "TRCK": "1",
})
writeTestMP3(t, filepath.Join(root, "artistY/01.mp3"), map[string]string{
"TIT2": "Solo", "TPE1": "The Artist Y", "TALB": "Y Album", "TRCK": "1",
})
scanner := New(pool, logger, []string{root})
stats, err := scanner.Scan(ctx, nil)
if err != nil {
t.Fatalf("first scan: %v", err)
}
if stats.Scanned != 4 || stats.Added != 4 {
t.Errorf("first scan stats = %+v, want Scanned=4 Added=4", stats)
}
q := dbq.New(pool)
artists, err := q.ListArtists(ctx)
if err != nil {
t.Fatalf("ListArtists: %v", err)
}
if len(artists) != 2 {
t.Fatalf("artist rows = %d (%+v), want 2", len(artists), artists)
}
// "The Artist Y" should sort under "Artist Y" (article stripped).
if artists[0].SortName != "Artist X" || artists[1].SortName != "Artist Y" {
t.Errorf("artist sort = [%q, %q], want [Artist X, Artist Y]", artists[0].SortName, artists[1].SortName)
}
// The synthetic MP3s carry only an ID3 tag — no decodable audio —
// so ffprobe yields duration 0. The scanner deliberately refuses to
// skip zero-duration rows (it re-runs them so a later scan can
// backfill duration once probing works), which is orthogonal to the
// mtime-based incremental-skip this test covers. Simulate a
// normally-probed library so the skip path is actually exercised;
// updated_at is left untouched (still ≥ file mtime).
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
t.Fatalf("seed durations: %v", err)
}
stats2, err := scanner.Scan(ctx, nil)
if err != nil {
t.Fatalf("second scan: %v", err)
}
if stats2.Added != 0 || stats2.Updated != 0 || stats2.Skipped != 4 {
t.Errorf("incremental scan stats = %+v, want Skipped=4", stats2)
}
}
// writeTestMP3 emits a file with only an ID3v2.3 tag payload plus a few
// bytes of MPEG-sync trailer. dhowden/tag reads tags without decoding audio,
// so this is enough for scanner round-trip tests without shipping binaries.
func writeTestMP3(t *testing.T, path string, frames map[string]string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
var framesBuf bytes.Buffer
for id, value := range frames {
if len(id) != 4 {
t.Fatalf("frame id %q not 4 bytes", id)
}
payload := append([]byte{0x03}, []byte(value)...)
framesBuf.WriteString(id)
_ = binary.Write(&framesBuf, binary.BigEndian, uint32(len(payload)))
framesBuf.WriteByte(0x00)
framesBuf.WriteByte(0x00)
framesBuf.Write(payload)
}
total := framesBuf.Len()
header := []byte{
'I', 'D', '3', 0x03, 0x00, 0x00,
byte((total >> 21) & 0x7F),
byte((total >> 14) & 0x7F),
byte((total >> 7) & 0x7F),
byte(total & 0x7F),
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = f.Close() }()
if _, err := f.Write(header); err != nil {
t.Fatal(err)
}
if _, err := f.Write(framesBuf.Bytes()); err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte{0xFF, 0xFB, 0x90, 0x00}); err != nil {
t.Fatal(err)
}
}