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) } } }