Files
yellowjacket/backend/download/importer_test.go
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.

Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.

Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 14:35:57 -04:00

449 lines
9.4 KiB
Go

package download
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"yellowjacket/backend/tagwriter"
)
// recordingTagWriter captures tag writes instead of touching files, so
// importer tests do not need real audio.
type recordingTagWriter struct {
mu sync.Mutex
writes map[string]tagwriter.TagChanges
failFor string
}
func newRecordingTagWriter() *recordingTagWriter {
return &recordingTagWriter{writes: map[string]tagwriter.TagChanges{}}
}
var errTagWriteFailed = errors.New("tag write failed")
func (r *recordingTagWriter) WriteUntrackedFileTags(
path string,
changes tagwriter.TagChanges,
) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.failFor != "" && strings.Contains(path, r.failFor) {
return errTagWriteFailed
}
r.writes[filepath.Base(path)] = changes
return nil
}
// stubLibrary records scan requests.
type stubLibrary struct {
mu sync.Mutex
scanned []int64
path string
}
func (s *stubLibrary) ScanLibrary(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.scanned = append(s.scanned, id)
return nil
}
func (s *stubLibrary) LibraryPath(int64) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.path, nil
}
// importFixture stages a set of files and returns the pieces an import
// needs.
type importFixture struct {
staging *Staging
importer *Importer
tags *recordingTagWriter
lib *stubLibrary
dir string
root string
files []string
}
func newImportFixture(t *testing.T, names ...string) importFixture {
t.Helper()
staging := newTestStaging(t)
tags := newRecordingTagWriter()
lib := &stubLibrary{}
imp := NewImporter(slogDiscard(), staging, tags, lib)
dir, err := staging.Reserve("item-1")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
files := make([]string, 0, len(names))
for _, n := range names {
p := filepath.Join(dir, n)
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(p, []byte("audio-data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
files = append(files, p)
}
return importFixture{
staging: staging,
importer: imp,
tags: tags,
lib: lib,
dir: dir,
root: t.TempDir(),
files: files,
}
}
func fourTrackDownload() Download {
return Download{
ID: "dl-1",
LibraryID: 1,
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
Album: "OK Computer",
Expected: []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
{Position: 3, Title: "Subterranean Homesick Alien"},
{Position: 4, Title: "Exit Music (For a Film)"},
},
}
}
func TestImportPlacesAndTagsFiles(t *testing.T) {
t.Parallel()
f := newImportFixture(t,
"01 - Airbag.flac",
"02 - Paranoid Android.flac",
"03 - Subterranean Homesick Alien.flac",
"04 - Exit Music (For a Film).flac",
)
got, err := f.importer.Import(
context.Background(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
if len(got.Paths) != 4 {
t.Fatalf("imported %d files, want 4", len(got.Paths))
}
if got.Tagged != 4 {
t.Errorf("tagged %d files, want 4", got.Tagged)
}
want := filepath.Join(
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
)
if got.Paths[0] != want {
t.Errorf("first path = %s, want %s", got.Paths[0], want)
}
for _, p := range got.Paths {
if _, err := os.Stat(p); err != nil {
t.Errorf("imported file missing: %v", err)
}
}
}
// Tags must be written while the file is still staged: if the library
// ever sees an untagged file, the scanner ingests it and the user
// watches it correct itself.
func TestImportTagsBeforeMoving(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
if _, err := f.importer.Import(
context.Background(),
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
); err != nil {
t.Fatalf("Import: %v", err)
}
changes, ok := f.tags.writes["01 - Airbag.flac"]
if !ok {
t.Fatalf(
"tags were not written to the staged filename; got writes for %v",
keysOf(f.tags.writes),
)
}
if changes[tagwriter.FieldTitle] != "Airbag" {
t.Errorf("title = %v, want Airbag", changes[tagwriter.FieldTitle])
}
if changes[tagwriter.FieldAlbum] != "OK Computer" {
t.Errorf("album = %v, want OK Computer", changes[tagwriter.FieldAlbum])
}
}
// One unwritable file should not cost the whole album.
func TestImportContinuesWhenTaggingFails(t *testing.T) {
t.Parallel()
f := newImportFixture(t,
"01 - Airbag.flac",
"02 - Paranoid Android.flac",
"03 - Subterranean Homesick Alien.flac",
"04 - Exit Music (For a Film).flac",
)
f.tags.failFor = "Paranoid"
got, err := f.importer.Import(
context.Background(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
if len(got.Paths) != 4 {
t.Errorf("imported %d files, want all 4", len(got.Paths))
}
if got.Tagged != 3 {
t.Errorf("tagged %d, want 3 (one failure)", got.Tagged)
}
}
func TestImportRejectsTooIncomplete(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
_, err := f.importer.Import(
context.Background(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if !errors.Is(err, ErrTooIncomplete) {
t.Fatalf("error = %v, want ErrTooIncomplete", err)
}
entries, _ := os.ReadDir(f.root)
if len(entries) != 0 {
t.Error("failed import wrote into the library root")
}
}
func TestImportRejectsNoAudio(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "rip.log", "cover.jpg")
_, err := f.importer.Import(
context.Background(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if !errors.Is(err, ErrNoAudio) {
t.Fatalf("error = %v, want ErrNoAudio", err)
}
}
func TestImportSkipsNonAudioFiles(t *testing.T) {
t.Parallel()
f := newImportFixture(t,
"01 - Airbag.flac",
"02 - Paranoid Android.flac",
"03 - Subterranean Homesick Alien.flac",
"04 - Exit Music (For a Film).flac",
"rip.log",
"cover.jpg",
)
got, err := f.importer.Import(
context.Background(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
if got.Skipped != 2 {
t.Errorf("skipped = %d, want 2", got.Skipped)
}
if len(got.Paths) != 4 {
t.Errorf("imported %d, want 4 audio files only", len(got.Paths))
}
}
// An existing file is never overwritten: it may be a better copy the
// user already owns, and arriving later does not make a download
// authoritative.
func TestImportNeverOverwrites(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
existing := filepath.Join(
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
)
if err := os.MkdirAll(filepath.Dir(existing), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(existing, []byte("original"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := f.importer.Import(
context.Background(),
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
data, err := os.ReadFile(existing)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(data) != "original" {
t.Error("import overwrote an existing library file")
}
if got.Paths[0] == existing {
t.Errorf("imported to the occupied path %s", existing)
}
}
func TestImportCustomPathTemplate(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
got, err := f.importer.Import(
context.Background(),
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{
LibraryRoot: f.root,
PathTemplate: "{albumartist} - {album}/{track}. {title}",
WriteTags: true,
},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
want := filepath.Join(
f.root, "Radiohead - OK Computer", "01. Airbag.flac",
)
if got.Paths[0] != want {
t.Errorf("path = %s, want %s", got.Paths[0], want)
}
}
func TestSanitizePathPart(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want string
}{
{"AC/DC", "AC_DC"},
{"Where Are We Now?", "Where Are We Now_"},
{`Bad: Title*`, "Bad_ Title_"},
{"Vol. 2 ", "Vol. 2"},
{"trailing dots...", "trailing dots"},
{"", "Unknown"},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
t.Parallel()
if got := sanitizePathPart(tt.in); got != tt.want {
t.Errorf("sanitizePathPart(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestImportRequiresLibraryRoot(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
_, err := f.importer.Import(
context.Background(),
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{WriteTags: true},
)
if !errors.Is(err, ErrNotConfigured) {
t.Fatalf("error = %v, want ErrNotConfigured", err)
}
}
func keysOf(m map[string]tagwriter.TagChanges) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}