Files
minstrel/internal/library/scanner_test.go
T
bvandeusen dcd19c0143 fix(library): drop out-of-range years instead of failing the album
Tag-supplied years were being passed straight to Postgres' date column
without validation. Files with corrupt or 5+ digit years (seen in the
wild on a couple of dozen albums) tripped SQLSTATE 22008 and the entire
album upsert failed, dropping every track on those albums from the
library.

Validate the year is within 1..9999 before constructing the date. If
it's outside that window, log a warning naming the album and year, and
insert the album with no release_date — a soft field shouldn't take
down the whole row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 00:14:51 -04:00

158 lines
4.5 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)
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)
}
stats2, err := scanner.Scan(ctx)
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)
}
}