diff --git a/internal/library/scanner_test.go b/internal/library/scanner_test.go new file mode 100644 index 00000000..ce653775 --- /dev/null +++ b/internal/library/scanner_test.go @@ -0,0 +1,132 @@ +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" +) + +// 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) + } +}