Files
playlink/internal/engine/engine_test.go
T
cdnutterandClaude Opus 5.5 75e0516434 Drop Artist/Album/Title depth assumptions from layout checks
Matching has always been by relative path, so any folder layout works as
long as both libraries use the same one. Remove the depth and loose-root-file
warnings, which flagged valid layouts, and reword the UI and README to say
"same folder layout on both sides" with Artist/Album/Title.ext only as an
example. The "elsewhere" fallback is now described as same top-level folder,
different subfolder. Add a test covering flat and deeply nested layouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 16:48:55 -07:00

507 lines
14 KiB
Go

package engine
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
)
type env struct {
t *testing.T
root string
host string
slave string
st *store.Store
e *Engine
pair store.Pair
}
func setup(t *testing.T, dryRun bool) *env {
t.Helper()
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
ev := &env{t: t, root: root, host: filepath.Join(root, "Albums"), slave: filepath.Join(root, "Singles")}
for _, d := range []string{ev.host, ev.slave, filepath.Join(root, "db")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
st, err := store.Open(filepath.Join(root, "db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.DB.Close() })
v := "0"
if dryRun {
v = "1"
}
if err := st.SetSetting(context.Background(), "dry_run", v); err != nil {
t.Fatal(err)
}
ev.st = st
ev.e = New(st, probe.Prober{}, []string{root}, slog.New(slog.NewTextHandler(io.Discard, nil)))
ev.pair = store.Pair{Name: "test", HostRoot: ev.host, SlaveRoot: ev.slave, RetentionDays: 30}
if _, err := ev.e.NormalizePair(&ev.pair); err != nil {
t.Fatal(err)
}
if err := st.SavePair(context.Background(), &ev.pair); err != nil {
t.Fatal(err)
}
return ev
}
func (ev *env) write(base, rel, content string) string {
ev.t.Helper()
p := filepath.Join(base, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
ev.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
ev.t.Fatal(err)
}
return p
}
func (ev *env) read(p string) string {
ev.t.Helper()
b, err := os.ReadFile(p)
if err != nil {
ev.t.Fatal(err)
}
return string(b)
}
func (ev *env) scan() {
ev.t.Helper()
if _, err := ev.e.StartScan(ev.pair.ID, "test"); err != nil {
ev.t.Fatal(err)
}
ev.wait()
}
func (ev *env) wait() *Job {
ev.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if j := ev.e.Job(ev.pair.ID); j != nil && j.State != "running" {
if j.State != "done" {
ev.t.Fatalf("job %s: %s", j.State, j.Error)
}
return j
}
time.Sleep(10 * time.Millisecond)
}
ev.t.Fatal("job did not finish")
return nil
}
func (ev *env) match(rel string) store.Match {
ev.t.Helper()
m, err := ev.st.MatchBySlave(context.Background(), ev.pair.ID, rel)
if err != nil {
ev.t.Fatalf("match %s: %v", rel, err)
}
return m
}
func sameInode(t *testing.T, a, b string) bool {
t.Helper()
ia, err := fsops.Lstat(a)
if err != nil {
t.Fatal(err)
}
ib, err := fsops.Lstat(b)
if err != nil {
t.Fatal(err)
}
return ia.SameInode(ib)
}
var ctx = context.Background()
func TestIdenticalLinkAndUndo(t *testing.T) {
ev := setup(t, false)
rel := "bbno$/Bag or Die/edamame.flac"
h := ev.write(ev.host, rel, "same-bytes")
s := ev.write(ev.slave, rel, "same-bytes")
ev.scan()
m := ev.match(rel)
if m.Status != store.StatusIdentical || m.Kind != store.KindPath {
t.Fatalf("want identical/path, got %s/%s", m.Status, m.Kind)
}
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
if err != nil {
t.Fatal(err)
}
if op.QuarantinePath != "" {
t.Fatal("identical link must not quarantine")
}
if !sameInode(t, h, s) {
t.Fatal("slave is not a hardlink of host")
}
if ev.match(rel).Status != store.StatusLinked {
t.Fatal("match not marked linked")
}
if err := ev.e.Undo(ctx, op.ID); err != nil {
t.Fatal(err)
}
if sameInode(t, h, s) || ev.read(s) != "same-bytes" {
t.Fatal("undo should leave an independent identical copy")
}
}
func TestDifferentLinkQuarantinesAndUndoRestores(t *testing.T) {
ev := setup(t, false)
rel := "Artist/Album/song.flac"
h := ev.write(ev.host, rel, "host-version-longer")
s := ev.write(ev.slave, rel, "slave-version")
ev.scan()
m := ev.match(rel)
if m.Status != store.StatusDifferent {
t.Fatalf("want different, got %s", m.Status)
}
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
if err != nil {
t.Fatal(err)
}
if !sameInode(t, h, s) {
t.Fatal("not linked")
}
if op.QuarantinePath == "" || ev.read(op.QuarantinePath) != "slave-version" {
t.Fatal("original slave file not quarantined")
}
if err := ev.e.Undo(ctx, op.ID); err != nil {
t.Fatal(err)
}
if ev.read(s) != "slave-version" || ev.read(h) != "host-version-longer" {
t.Fatal("undo did not restore the original")
}
if _, err := os.Stat(op.QuarantinePath); !errors.Is(err, os.ErrNotExist) {
t.Fatal("quarantine entry should be consumed by the restore")
}
if err := ev.e.Undo(ctx, op.ID); err == nil {
t.Fatal("double undo must fail")
}
}
func TestSimilarExtensionLinkRenamesAndUndo(t *testing.T) {
ev := setup(t, false)
h := ev.write(ev.host, "A/B/01 - Song.flac", "flac-data")
s := ev.write(ev.slave, "A/B/01 - Song.mp3", "mp3")
ev.scan()
m := ev.match("A/B/01 - Song.mp3")
if m.Status != store.StatusSimilar || m.Kind != store.KindExtension || m.RelHost != "A/B/01 - Song.flac" {
t.Fatalf("unexpected match %+v", m)
}
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
if err != nil {
t.Fatal(err)
}
newSlave := filepath.Join(ev.slave, "A/B/01 - Song.flac")
if !sameInode(t, h, newSlave) {
t.Fatal("slave should now hold the host's .flac as a hardlink")
}
if _, err := os.Stat(s); !errors.Is(err, os.ErrNotExist) {
t.Fatal("old .mp3 should be moved to quarantine")
}
if err := ev.e.Undo(ctx, op.ID); err != nil {
t.Fatal(err)
}
if ev.read(s) != "mp3" {
t.Fatal(".mp3 not restored")
}
if _, err := os.Stat(newSlave); !errors.Is(err, os.ErrNotExist) {
t.Fatal("linked .flac should be removed on undo")
}
if ev.read(h) != "flac-data" {
t.Fatal("host must be untouched")
}
}
func TestFuzzyTitleMatch(t *testing.T) {
ev := setup(t, false)
ev.write(ev.host, "A/B/03. My Song!.flac", "x")
ev.write(ev.slave, "A/B/My Song.mp3", "y")
ev.write(ev.slave, "A/B/My Song (Live).mp3", "z")
ev.write(ev.host, "A/Other Album/Lonely.flac", "x")
ev.write(ev.slave, "A/Single/Lonely.flac", "x")
ev.scan()
if m := ev.match("A/B/My Song.mp3"); m.Kind != store.KindFuzzy {
t.Fatalf("want fuzzy, got %s", m.Kind)
}
if m := ev.match("A/B/My Song (Live).mp3"); m.Status != store.StatusUnmatched {
t.Fatalf("live version must not match studio version, got %s", m.Status)
}
if m := ev.match("A/Single/Lonely.flac"); m.Kind != store.KindElsewhere || m.Status != store.StatusSimilar {
t.Fatalf("want elsewhere/similar, got %s/%s", m.Kind, m.Status)
}
}
func TestDowngradeGuardAndPromote(t *testing.T) {
ev := setup(t, false)
rel := "A/B/song.flac"
h := ev.write(ev.host, rel, "lossy-host")
s := ev.write(ev.slave, rel, "lossless-slave!")
ev.scan()
m := ev.match(rel)
// Simulate the probe verdict that the slave is better.
v, _ := json.Marshal(probe.Verdict{Better: "slave"})
m.Verdict = v
if err := ev.st.UpsertMatch(ctx, &m); err != nil {
t.Fatal(err)
}
if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); !errors.Is(err, ErrDowngrade) {
t.Fatalf("want ErrDowngrade, got %v", err)
}
if _, err := ev.e.Promote(ctx, m.ID, false); !errors.Is(err, ErrConfirm) {
t.Fatalf("promote without confirm must fail, got %v", err)
}
op, err := ev.e.Promote(ctx, m.ID, true)
if err != nil {
t.Fatal(err)
}
if !sameInode(t, h, s) || ev.read(h) != "lossless-slave!" {
t.Fatal("host should now be the slave's file")
}
if ev.read(op.QuarantinePath) != "lossy-host" {
t.Fatal("host original not quarantined")
}
if err := ev.e.Undo(ctx, op.ID); err != nil {
t.Fatal(err)
}
if ev.read(h) != "lossy-host" || ev.read(s) != "lossless-slave!" || sameInode(t, h, s) {
t.Fatal("promote undo failed")
}
}
func TestChangedSinceScanIsRefused(t *testing.T) {
ev := setup(t, false)
rel := "A/B/c.flac"
ev.write(ev.host, rel, "aaa")
s := ev.write(ev.slave, rel, "bbb")
ev.scan()
m := ev.match(rel)
time.Sleep(10 * time.Millisecond)
if err := os.WriteFile(s, []byte("user edited this"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); !errors.Is(err, fsops.ErrChanged) {
t.Fatalf("want ErrChanged, got %v", err)
}
if ev.read(s) != "user edited this" {
t.Fatal("file must be untouched")
}
}
func TestDryRunChangesNothing(t *testing.T) {
ev := setup(t, true)
rel := "A/B/d.flac"
h := ev.write(ev.host, rel, "same")
s := ev.write(ev.slave, rel, "same")
ev.scan()
op, err := ev.e.Link(ctx, ev.match(rel).ID, LinkOptions{})
if err != nil {
t.Fatal(err)
}
if !op.DryRun || op.Status != store.OpPlanned {
t.Fatalf("expected planned dry-run op, got %+v", op)
}
if sameInode(t, h, s) {
t.Fatal("dry run must not link")
}
}
func TestAutoLinkRespectsSettleTime(t *testing.T) {
ev := setup(t, false)
ev.pair.AutoLinkIdentical = true
ev.pair.SettleMinutes = 60
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
h := ev.write(ev.host, "A/B/e.flac", "same")
s := ev.write(ev.slave, "A/B/e.flac", "same")
ev.scan()
if sameInode(t, h, s) {
t.Fatal("freshly written files must not be auto-linked")
}
ev.pair.SettleMinutes = 0
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
ev.scan()
if !sameInode(t, h, s) {
t.Fatal("settled identical file should be auto-linked")
}
}
func TestBuildAndRemove(t *testing.T) {
ev := setup(t, false)
h1 := ev.write(ev.host, "bbno$/Album/one.flac", "1")
ev.write(ev.host, "bbno$/Album/two.flac", "2")
ev.write(ev.host, "bbno$/Album/cover.jpg", "img")
ev.write(ev.slave, "bbno$/Album/two.flac", "user's own different file")
if _, err := ev.e.StartBuild(ev.pair.ID, []string{"bbno$"}); err != nil {
t.Fatal(err)
}
j := ev.wait()
if j.Results["linked"] != 1 || j.Results["skipped"] != 1 {
t.Fatalf("unexpected results %+v", j.Results)
}
s1 := filepath.Join(ev.slave, "bbno$/Album/one.flac")
if !sameInode(t, h1, s1) {
t.Fatal("build did not hardlink")
}
if ev.read(filepath.Join(ev.slave, "bbno$/Album/two.flac")) != "user's own different file" {
t.Fatal("build must never overwrite an existing slave file")
}
if _, err := os.Stat(filepath.Join(ev.slave, "bbno$/Album/cover.jpg")); err == nil {
t.Fatal("non-audio files must not be linked")
}
if _, err := ev.e.StartRemove(ev.pair.ID, []string{"bbno$"}); err != nil {
t.Fatal(err)
}
j = ev.wait()
if j.Results["removed"] != 1 || j.Results["skipped"] != 1 {
t.Fatalf("unexpected remove results %+v", j.Results)
}
if _, err := os.Stat(s1); !errors.Is(err, os.ErrNotExist) {
t.Fatal("linked file should be removed")
}
if ev.read(h1) != "1" {
t.Fatal("host must be untouched")
}
if ev.read(filepath.Join(ev.slave, "bbno$/Album/two.flac")) != "user's own different file" {
t.Fatal("remove must never delete a non-linked file")
}
}
func TestSymlinkEscapeIgnored(t *testing.T) {
ev := setup(t, false)
outside := t.TempDir()
os.WriteFile(filepath.Join(outside, "secret.flac"), []byte("x"), 0o644)
os.MkdirAll(filepath.Join(ev.slave, "Evil"), 0o755)
if err := os.Symlink(outside, filepath.Join(ev.slave, "Evil", "Album")); err != nil {
t.Fatal(err)
}
ev.write(ev.host, "Evil/Album/secret.flac", "x")
ev.scan()
if _, err := ev.st.MatchBySlave(ctx, ev.pair.ID, "Evil/Album/secret.flac"); err == nil {
t.Fatal("files behind a symlinked directory must not be scanned")
}
root, _ := fsops.NewRoot(ev.slave)
if _, err := root.Resolve("Evil/Album/secret.flac"); !errors.Is(err, fsops.ErrOutsideRoot) {
t.Fatalf("resolve through an escaping symlink must fail, got %v", err)
}
if _, err := root.Resolve("../Albums/x.flac"); !errors.Is(err, fsops.ErrOutsideRoot) {
t.Fatalf("dot-dot must fail, got %v", err)
}
}
func TestPurgeAndOverlapValidation(t *testing.T) {
ev := setup(t, false)
rel := "A/B/f.flac"
ev.write(ev.host, rel, "one")
ev.write(ev.slave, rel, "two!")
ev.scan()
op, err := ev.e.Link(ctx, ev.match(rel).ID, LinkOptions{})
if err != nil {
t.Fatal(err)
}
if err := ev.e.Purge(ctx, op.ID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(op.QuarantinePath); !errors.Is(err, os.ErrNotExist) {
t.Fatal("purge did not delete quarantine")
}
if err := ev.e.Undo(ctx, op.ID); err == nil {
t.Fatal("undo after purge must fail")
}
bad := store.Pair{Name: "bad", HostRoot: ev.host, SlaveRoot: filepath.Join(ev.host, "A")}
if _, err := ev.e.NormalizePair(&bad); err == nil {
t.Fatal("nested libraries must be rejected")
}
outside := store.Pair{Name: "bad", HostRoot: ev.host, SlaveRoot: t.TempDir()}
if _, err := ev.e.NormalizePair(&outside); err == nil {
t.Fatal("paths outside allowed roots must be rejected")
}
}
func TestSameContent(t *testing.T) {
dir := t.TempDir()
big := bytes.Repeat([]byte("abcdefgh"), 300_000) // spans several chunks
a, b, c := filepath.Join(dir, "a"), filepath.Join(dir, "b"), filepath.Join(dir, "c")
os.WriteFile(a, big, 0o644)
os.WriteFile(b, big, 0o644)
alt := append([]byte(nil), big...)
alt[len(alt)-1] = 'X'
os.WriteFile(c, alt, 0o644)
if same, err := fsops.SameContent(a, b); err != nil || !same {
t.Fatalf("a,b should match: %v %v", same, err)
}
if same, _ := fsops.SameContent(a, c); same {
t.Fatal("a,c differ in the last byte")
}
}
func TestNormTitle(t *testing.T) {
cases := map[string]string{
"01 - Song.flac": "song",
"1-03 Song.mp3": "song",
"03. Song!.flac": "song",
"22.flac": "22",
"Song (Live).flac": "songlive",
"07 Song (Remix).flac": "songremix",
}
for in, want := range cases {
if got := normTitle(in); got != want {
t.Errorf("normTitle(%q) = %q, want %q", in, got, want)
}
}
}
func TestAnyLayoutMatchesWithoutWarnings(t *testing.T) {
ev := setup(t, false)
// Flat files, deeper Genre/Artist/Album/Disc nesting: all fine as long as both sides agree.
for _, rel := range []string{"loose.flac", "Rock/Band/Album/Disc 1/01 - Song.flac", "Band/Song.flac"} {
ev.write(ev.host, rel, "same:"+rel)
ev.write(ev.slave, rel, "same:"+rel)
}
ev.scan()
for _, rel := range []string{"loose.flac", "Rock/Band/Album/Disc 1/01 - Song.flac", "Band/Song.flac"} {
if m := ev.match(rel); m.Status != store.StatusIdentical || m.Kind != store.KindPath {
t.Fatalf("%s: want identical/path, got %s/%s", rel, m.Status, m.Kind)
}
}
p, err := ev.st.Pair(ctx, ev.pair.ID)
if err != nil {
t.Fatal(err)
}
for _, w := range p.LastScanWarnings {
if strings.Contains(w, "layout") || strings.Contains(w, "folder") || strings.Contains(w, "depth") {
t.Fatalf("matching layouts must not produce layout warnings, got %q", w)
}
}
}