git/intake/main
internal/execute/execute_test.go
401 lines13 KB last change raw · history
package execute

import (
	"context"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"intake/internal/plan"
	"intake/internal/scan"
	"intake/internal/tags"
)

// Every test here needs a real flac: execute copies it, tags it with metaflac,
// verifies it with ffprobe and flac --test, and the whole point is that the file
// that comes out is checked the same way a real one is. The tones are a tenth of
// a second long, so the cost is milliseconds.
type fixture struct {
	root    string
	staging string
	album   string
}

func newFixture(t *testing.T) *fixture {
	t.Helper()
	for _, tool := range []string{"ffmpeg", "ffprobe", "metaflac", "flac"} {
		if _, err := exec.LookPath(tool); err != nil {
			t.Skipf("%s is needed to build a fixture", tool)
		}
	}
	root := t.TempDir()
	f := &fixture{
		root:    root,
		staging: filepath.Join(root, "staging"),
		album:   filepath.Join(root, "staging", "Artist - Album"),
	}
	for _, d := range []string{f.album, filepath.Join(root, "archive"), filepath.Join(root, "library")} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	return f
}

// tone writes a flac and gives it just enough tags that a filed copy verifies.
func (f *fixture) tone(t *testing.T, name, title string, freq int) string {
	t.Helper()
	path := filepath.Join(f.album, name)
	cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
		"-f", "lavfi", "-i", sine(freq), path)
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("building %s: %v %s", name, err, out)
	}
	f.tag(t, path, title)
	return path
}

func sine(freq int) string {
	return "sine=frequency=" + itoa(freq) + ":duration=0.1"
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for n > 0 {
		b = append([]byte{byte('0' + n%10)}, b...)
		n /= 10
	}
	return string(b)
}

func (f *fixture) tag(t *testing.T, path, title string) {
	t.Helper()
	lines := "TITLE=" + title + "\nALBUM=Album\nARTIST=Artist\nALBUMARTIST=Artist\nTRACKNUMBER=1\n"
	cmd := exec.Command("metaflac", "--remove-all-tags", "--import-tags-from=-", path)
	cmd.Stdin = strings.NewReader(lines)
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("tagging %s: %v %s", filepath.Base(path), err, out)
	}
}

func (f *fixture) plan(tracks ...plan.TrackPlan) plan.Plan {
	return plan.Plan{
		Album:       scan.Album{Dir: f.album},
		AlbumName:   "Album",
		AlbumArt:    "Artist",
		ArtistDir:   "artist",
		AlbumDir:    "album",
		ArchiveRoot: filepath.Join(f.root, "archive"),
		LibraryRoot: filepath.Join(f.root, "library"),
		ArchiveDir:  filepath.Join(f.root, "archive", "artist", "album"),
		LibraryDir:  filepath.Join(f.root, "library", "artist", "album"),
		Tracks:      tracks,
	}
}

func trackPlan(src, target string) plan.TrackPlan {
	return plan.TrackPlan{
		Source: src,
		Target: target,
		Ext:    ".flac",
		Tags: []tags.Note{
			{Field: tags.FieldTitle, Value: strings.TrimSuffix(target, ".flac")},
			{Field: tags.FieldAlbumArtist, Value: "Artist"},
		},
	}
}

func (f *fixture) options() Options {
	return Options{VerifyDecode: "sample", StagingRoot: f.staging}
}

// Two tracks that share a number and a title produce the same target. copyFile
// truncates, so without a guard the second write replaces the first, the album
// is filed a track short, verify passes -- it compares each written file with the
// plan entry in the same position -- and nothing anywhere reports a problem.
func TestRunRefusesTwoTracksWithTheSameTarget(t *testing.T) {
	f := newFixture(t)
	a := f.tone(t, "01 intro.flac", "Intro", 440)
	b := f.tone(t, "01 intro (1).flac", "Intro", 660)
	p := f.plan(trackPlan(a, "1-01_intro.flac"), trackPlan(b, "1-01_intro.flac"))

	res, err := Run(context.Background(), p, f.options())
	if err == nil {
		t.Fatal("running a plan with two identical targets should be refused")
	}
	if !strings.Contains(err.Error(), "1-01_intro.flac") {
		t.Errorf("the refusal should name the target: %v", err)
	}
	if len(res.Files) != 0 {
		t.Errorf("nothing should have been written, got %v", res.Files)
	}
	if _, err := os.Stat(p.ArchiveDir); !os.IsNotExist(err) {
		t.Error("the archive directory was created before the plan was refused")
	}
	for _, src := range []string{a, b} {
		if _, err := os.Stat(src); err != nil {
			t.Errorf("staged file %s was touched: %v", filepath.Base(src), err)
		}
	}
}

func TestRunRefusesAnEmptyPlan(t *testing.T) {
	f := newFixture(t)
	if _, err := Run(context.Background(), f.plan(), f.options()); err == nil {
		t.Fatal("a plan with no tracks should be refused rather than panic")
	}
}

// "both" deliberately puts the album somewhere other than the path that was
// asked for. Everything downstream has to follow it there: the reported archive
// directory, the file list, the byte count, and the directory a caller goes on to
// read the masters from.
func TestBothCollisionReportsWhereTheAlbumActuallyWent(t *testing.T) {
	f := newFixture(t)
	existing := f.plan()
	if err := os.MkdirAll(existing.ArchiveDir, 0o755); err != nil {
		t.Fatal(err)
	}
	old := filepath.Join(existing.ArchiveDir, "1-01_old.flac")
	if err := os.WriteFile(old, []byte("the album that was already there"), 0o644); err != nil {
		t.Fatal(err)
	}

	src := f.tone(t, "01 new.flac", "New", 880)
	p := f.plan(trackPlan(src, "1-01_new.flac"))

	opts := f.options()
	opts.Collision = "both"
	res, err := Run(context.Background(), p, opts)
	if err != nil {
		t.Fatal(err)
	}
	want := p.ArchiveDir + " (2)"
	if res.ArchiveDir != want {
		t.Errorf("reported archive dir = %q, want %q", res.ArchiveDir, want)
	}
	if len(res.Files) != 1 || filepath.Dir(res.Files[0]) != want {
		t.Errorf("reported files = %v, want one file inside %q", res.Files, want)
	}
	if fi, err := os.Stat(filepath.Join(want, "1-01_new.flac")); err != nil {
		t.Fatalf("the new album did not land where it was reported: %v", err)
	} else if res.Bytes != fi.Size() {
		t.Errorf("reported %d bytes, the new album is %d", res.Bytes, fi.Size())
	}
	if _, err := os.Stat(old); err != nil {
		t.Errorf("the album that was already there was disturbed: %v", err)
	}
	if _, err := os.Stat(filepath.Join(p.ArchiveDir, "1-01_new.flac")); !os.IsNotExist(err) {
		t.Error("the new album was written over the old one")
	}
}

// Replacing an album used to remove the old one first and rename the new one in
// afterwards. A failure in between -- and a failed RemoveAll leaves the same
// state -- meant the album was simply gone. The old copy now survives until the
// new one is in place.
func TestReplaceKeepsTheOldAlbumUntilTheNewOneIsInPlace(t *testing.T) {
	dir := t.TempDir()
	target := filepath.Join(dir, "album")
	if err := os.MkdirAll(target, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(target, "keep.flac"), []byte("irreplaceable"), 0o644); err != nil {
		t.Fatal(err)
	}

	// A source that cannot be renamed into place.
	missing := filepath.Join(dir, "gone")
	if _, err := promote(target, missing, "replace", func(string, ...any) {}); err == nil {
		t.Fatal("promoting a directory that does not exist should fail")
	}
	got, err := os.ReadFile(filepath.Join(target, "keep.flac"))
	if err != nil {
		t.Fatalf("the old album was destroyed by a failed replace: %v", err)
	}
	if string(got) != "irreplaceable" {
		t.Errorf("the old album came back as %q", got)
	}
}

// A merge is all-or-nothing. Checking for conflicts as the files move left the
// destination holding part of one album and part of another, which is worse than
// either album on its own and cannot be put right by running again.
func TestMergeMovesNothingWhenAnythingClashes(t *testing.T) {
	dir := t.TempDir()
	dst := filepath.Join(dir, "dst")
	src := filepath.Join(dir, "src")
	for _, d := range []string{dst, src} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	for _, f := range []string{"01_new.flac", "02_old.flac"} {
		if err := os.WriteFile(filepath.Join(src, f), []byte("incoming"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.WriteFile(filepath.Join(dst, "02_old.flac"), []byte("already here"), 0o644); err != nil {
		t.Fatal(err)
	}

	if err := mergeInto(src, dst); err == nil {
		t.Fatal("a clash should stop the merge")
	}
	entries, err := os.ReadDir(dst)
	if err != nil {
		t.Fatal(err)
	}
	if len(entries) != 1 || entries[0].Name() != "02_old.flac" {
		var names []string
		for _, e := range entries {
			names = append(names, e.Name())
		}
		t.Errorf("the destination gained files from a merge that failed: %v", names)
	}
	left, err := os.ReadDir(src)
	if err != nil {
		t.Fatal(err)
	}
	if len(left) != 2 {
		t.Errorf("the incoming album was consumed by a merge that failed: %d file(s) left", len(left))
	}
}

// Staging cleanup used to walk up to four empty directories, which reached past
// the album, past staging, and out into the library root. An empty staging
// directory is the normal state after the last album is filed, and deleting it
// makes the next scan fail.
func TestStagingCleanupNeverClimbsPastStaging(t *testing.T) {
	root := t.TempDir()
	staging := filepath.Join(root, "staging")
	album := filepath.Join(staging, "album")
	if err := os.MkdirAll(album, 0o755); err != nil {
		t.Fatal(err)
	}
	if _, err := cleanStaging(album, staging); err != nil {
		t.Fatal(err)
	}
	for _, keep := range []string{root, staging} {
		if _, err := os.Stat(keep); err != nil {
			t.Errorf("%s was removed by staging cleanup: %v", filepath.Base(keep), err)
		}
	}
	if _, err := os.Stat(album); !os.IsNotExist(err) {
		t.Error("the empty album folder should have been removed")
	}

	// With no staging root it must not climb at all.
	album2 := filepath.Join(staging, "album2")
	if err := os.MkdirAll(album2, 0o755); err != nil {
		t.Fatal(err)
	}
	if _, err := cleanStaging(album2, ""); err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(staging); err != nil {
		t.Errorf("cleanup climbed out of the album with no staging root: %v", err)
	}
}

// A CD1 / CD2 rip has the album one level up from the tracks. Clearing only the
// disc folder left the empty second disc, the album's own sidecars and the album
// folder itself in staging, so the finished album stayed in the list for ever.
func TestStagingCleanupClearsWholeMultiDiscAlbum(t *testing.T) {
	root := t.TempDir()
	staging := filepath.Join(root, "staging")
	album := filepath.Join(staging, "Artist - Album")
	for _, d := range []string{"CD1", "CD2"} {
		if err := os.MkdirAll(filepath.Join(album, d), 0o755); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.WriteFile(filepath.Join(album, "album.cue"), []byte("PERFORMER\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	removed, err := cleanStaging(album, staging)
	if err != nil {
		t.Fatal(err)
	}
	if len(removed) != 1 || removed[0] != "album.cue" {
		t.Errorf("leftovers reported as %v, want [album.cue]", removed)
	}
	if _, err := os.Stat(album); !os.IsNotExist(err) {
		t.Error("the album folder was left in staging")
	}
	if _, err := os.Stat(staging); err != nil {
		t.Errorf("staging itself was removed: %v", err)
	}
}

// Once the album is promoted it is archived, and failing to delete the staged
// originals afterwards does not change that. Reporting it as a failure made the
// caller drop the journal record for an album that is sitting on disk, so verify
// and audit could no longer see it and a later run would file it again.
func TestStagedFilesThatCannotBeRemovedDoNotFailTheRun(t *testing.T) {
	f := newFixture(t)
	src := f.tone(t, "01 one.flac", "One", 440)
	p := f.plan(trackPlan(src, "1-01_one.flac"))

	// Read-only album folder: the copy reads from it, the removal from it fails.
	if err := os.Chmod(f.album, 0o555); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { os.Chmod(f.album, 0o755) })

	res, err := Run(context.Background(), p, f.options())
	if err != nil {
		t.Fatalf("a complete archive was reported as a failure: %v", err)
	}
	if _, err := os.Stat(filepath.Join(p.ArchiveDir, "1-01_one.flac")); err != nil {
		t.Fatalf("the album was not archived: %v", err)
	}
	if len(res.StagingStuck) != 1 {
		t.Errorf("StagingStuck = %v, want the one file that could not be removed", res.StagingStuck)
	}
	if res.RemovedStaging {
		t.Error("the run claimed staging was cleared when it was not")
	}
}

// An artist folder is created so the album can go inside it. A run that fails --
// a truncated download, a decode that will not verify -- used to leave the empty
// folder behind, where it reads as an artist whose albums have gone missing.
func TestFailedRunLeavesNoEmptyArtistFolder(t *testing.T) {
	f := newFixture(t)
	p := f.plan(trackPlan(filepath.Join(f.album, "not-here.flac"), "1-01_one.flac"))

	if _, err := Run(context.Background(), p, f.options()); err == nil {
		t.Fatal("a plan whose source file is missing should fail")
	}
	if _, err := os.Stat(filepath.Dir(p.ArchiveDir)); !os.IsNotExist(err) {
		t.Errorf("an empty artist folder was left in the archive: %v", err)
	}
}

// A plan that is refused before anything is copied must not disturb an artist
// folder that was already there with albums in it.
func TestFailedRunKeepsAnArtistFolderThatWasAlreadyThere(t *testing.T) {
	f := newFixture(t)
	artistDir := filepath.Dir(f.plan().ArchiveDir)
	if err := os.MkdirAll(filepath.Join(artistDir, "older-album"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(artistDir, "older-album", "track.flac"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}

	p := f.plan(trackPlan(filepath.Join(f.album, "not-here.flac"), "1-01_one.flac"))
	if _, err := Run(context.Background(), p, f.options()); err == nil {
		t.Fatal("a plan whose source file is missing should fail")
	}
	if _, err := os.Stat(filepath.Join(artistDir, "older-album", "track.flac")); err != nil {
		t.Errorf("an existing artist folder was disturbed: %v", err)
	}
}