Files
playlink/internal/engine/engine_test.go
T
cdnutterandClaude Opus 5.5 5b088bb4a6 Rename host library to master throughout
The two libraries are now "master" and "slave" everywhere: UI text, API
JSON fields (master_root, rel_master, master_size, ...), Go identifiers,
database columns, quality verdicts, quarantine subfolders and docs.

Existing databases are upgraded by a new migration that renames the
columns in place and rewrites stored "host" values, so pairs, history,
cached verdicts and restorable quarantine entries carry over. Network
"host" usages (SplitHostPort, the Origin/Host check) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 16:50: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
master 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, master: filepath.Join(root, "Albums"), slave: filepath.Join(root, "Singles")}
for _, d := range []string{ev.master, 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", MasterRoot: ev.master, 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.master, 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 master")
}
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.master, rel, "master-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) != "master-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.master, "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.RelMaster != "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 master'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("master must be untouched")
}
}
func TestFuzzyTitleMatch(t *testing.T) {
ev := setup(t, false)
ev.write(ev.master, "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.master, "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.master, rel, "lossy-master")
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("master should now be the slave's file")
}
if ev.read(op.QuarantinePath) != "lossy-master" {
t.Fatal("master original not quarantined")
}
if err := ev.e.Undo(ctx, op.ID); err != nil {
t.Fatal(err)
}
if ev.read(h) != "lossy-master" || 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.master, 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.master, 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.master, "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.master, "bbno$/Album/one.flac", "1")
ev.write(ev.master, "bbno$/Album/two.flac", "2")
ev.write(ev.master, "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("master 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.master, "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.master, 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", MasterRoot: ev.master, SlaveRoot: filepath.Join(ev.master, "A")}
if _, err := ev.e.NormalizePair(&bad); err == nil {
t.Fatal("nested libraries must be rejected")
}
outside := store.Pair{Name: "bad", MasterRoot: ev.master, 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.master, 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)
}
}
}