Files
silo-server/internal/autoscan/rewrite_test.go
Quick104andClaude Fable 5 65d94459f4 fix(autoscan): preserve trailing separator through path rewrites
Review follow-up: normalizePath strips a trailing slash, but the slash is
semantic for legacy-scope changes — filepath.Dir("/x/Show/") is the
directory itself while filepath.Dir("/x/Show") is its parent, so dropping
it widened targeted directory notifications into parent/library scans.
applyRewrites now records whether the incoming path ended with a separator
(either form) and restores it on the returned path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:40:38 -04:00

152 lines
6.5 KiB
Go

package autoscan
import "testing"
func TestApplyRewrites(t *testing.T) {
rw := []PathRewrite{{From: "/data/media", To: "/mnt/media"}}
cases := []struct{ in, want string }{
{"/data/media/Movies/Dune/Dune.mkv", "/mnt/media/Movies/Dune/Dune.mkv"},
{"/other/path/file.mkv", "/other/path/file.mkv"}, // no-match pass-through
}
for _, tc := range cases {
if got := applyRewrites(tc.in, rw); got != tc.want {
t.Fatalf("applyRewrites(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// Exact match (no trailing segment) rewrites the whole path.
if got := applyRewrites("/data/media", rw); got != "/mnt/media" {
t.Fatalf("exact match: got %q want %q", got, "/mnt/media")
}
// nil/empty rewrites pass through unchanged.
if got := applyRewrites("/data/media/x", nil); got != "/data/media/x" {
t.Fatalf("nil rewrites: got %q", got)
}
// Segment-boundary matching: a sibling dir sharing the prefix must NOT match.
boundary := []PathRewrite{{From: "/data/media", To: "/mnt"}}
if got := applyRewrites("/data/media2/x", boundary); got != "/data/media2/x" {
t.Fatalf("boundary: /data/media2/x must not rewrite, got %q", got)
}
}
// TestApplyRewritesNormalizesStoredFrom verifies that a Windows-style / dup-slash
// stored From is normalized the same way coveredBy/normalizePath does, so a
// rewrite the suggester reports as "covered" actually matches at poll time.
func TestApplyRewritesNormalizesStoredFrom(t *testing.T) {
// Backslash From: a Windows-hosted arr root stored verbatim.
winFrom := []PathRewrite{{From: `D:\data\tv`, To: "/mnt/media/tv"}}
if got := applyRewrites("D:/data/tv/Show/S01/E01.mkv", winFrom); got != "/mnt/media/tv/Show/S01/E01.mkv" {
t.Fatalf("backslash From should match normalized path, got %q", got)
}
// coveredBy must agree with applyRewrites: the normalized root is covered.
if !coveredBy("D:/data/tv", winFrom) {
t.Fatalf("coveredBy should report the backslash From as covering the root")
}
// Dup-slash From collapses too.
dupFrom := []PathRewrite{{From: "/data//tv/", To: "/mnt/media/tv"}}
if got := applyRewrites("/data/tv/Show/E.mkv", dupFrom); got != "/mnt/media/tv/Show/E.mkv" {
t.Fatalf("dup-slash From should match collapsed path, got %q", got)
}
}
// TestApplyRewritesUNCPath verifies a Windows UNC root (\\NAS\Media\TV) from a
// Windows-hosted arr matches its rewrite rule. Separator swapping alone turns
// the incoming path into //NAS/... while the From normalizes to /NAS/..., so
// the prefix never matched — both sides must go through normalizePath.
func TestApplyRewritesUNCPath(t *testing.T) {
incoming := `\\NAS\Media\TV\Show\S01\E01.mkv`
for _, from := range []string{`\\NAS\Media\TV`, "//NAS/Media/TV", "/NAS/Media/TV"} {
rw := []PathRewrite{{From: from, To: "/mnt/media/tv"}}
if got := applyRewrites(incoming, rw); got != "/mnt/media/tv/Show/S01/E01.mkv" {
t.Fatalf("UNC path with From=%q: got %q", from, got)
}
}
// An unmatched UNC path still comes back normalized (collapsed slashes),
// consistent with what the resolver sees for matched paths.
if got := applyRewrites(incoming, nil); got != "/NAS/Media/TV/Show/S01/E01.mkv" {
t.Fatalf("unmatched UNC path: got %q", got)
}
// A trailing-slash To must not produce a doubled separator at the join.
slashTo := []PathRewrite{{From: `\\NAS\Media\TV`, To: "/mnt/media/tv/"}}
if got := applyRewrites(incoming, slashTo); got != "/mnt/media/tv/Show/S01/E01.mkv" {
t.Fatalf("trailing-slash To: got %q", got)
}
}
// TestApplyRewritesPreservesTrailingSlash verifies a trailing separator on the
// incoming path survives normalization and rewriting. It is semantic for
// legacy-scope changes: filepath.Dir("/x/Show/") is the directory itself while
// filepath.Dir("/x/Show") is its parent, so dropping it would widen a targeted
// directory notification into a parent/library scan.
func TestApplyRewritesPreservesTrailingSlash(t *testing.T) {
rw := []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}}
if got := applyRewrites("/data/tv/Show/", rw); got != "/mnt/media/tv/Show/" {
t.Fatalf("rewritten dir: got %q", got)
}
// Unmatched paths keep it too.
if got := applyRewrites("/other/Show/", rw); got != "/other/Show/" {
t.Fatalf("unmatched dir: got %q", got)
}
// Windows separator form: trailing backslash counts as a trailing separator.
unc := []PathRewrite{{From: `\\NAS\Media\TV`, To: "/mnt/media/tv"}}
if got := applyRewrites(`\\NAS\Media\TV\Show\`, unc); got != "/mnt/media/tv/Show/" {
t.Fatalf("UNC dir: got %q", got)
}
// Files without a trailing separator stay without one.
if got := applyRewrites("/data/tv/Show/E01.mkv", rw); got != "/mnt/media/tv/Show/E01.mkv" {
t.Fatalf("file: got %q", got)
}
// Bare root never doubles.
if got := applyRewrites("/", nil); got != "/" {
t.Fatalf("root: got %q", got)
}
}
// TestApplyRewritesMostSpecificWins verifies the longest matching From wins
// regardless of slice ordering: a broad rule must not shadow a nested one.
func TestApplyRewritesMostSpecificWins(t *testing.T) {
// Broad rule listed FIRST: a first-match strategy would (wrongly) pick /data
// and yield "/A/media/x". Most-specific must pick /data/media -> "/B/x".
broadFirst := []PathRewrite{
{From: "/data", To: "/A"},
{From: "/data/media", To: "/B"},
}
if got := applyRewrites("/data/media/x", broadFirst); got != "/B/x" {
t.Fatalf("broad-first: got %q want %q", got, "/B/x")
}
// Same rules, specific listed first: result must be identical (order-independent).
specificFirst := []PathRewrite{
{From: "/data/media", To: "/B"},
{From: "/data", To: "/A"},
}
if got := applyRewrites("/data/media/x", specificFirst); got != "/B/x" {
t.Fatalf("specific-first: got %q want %q", got, "/B/x")
}
// A path under the broad rule but NOT the specific one still uses the broad rule.
if got := applyRewrites("/data/other/x", broadFirst); got != "/A/other/x" {
t.Fatalf("broad fallthrough: got %q want %q", got, "/A/other/x")
}
}
func TestNormalizeSeparators(t *testing.T) {
if got := normalizeSeparators(`C:\Media\Movies\Dune\Dune.mkv`); got != "C:/Media/Movies/Dune/Dune.mkv" {
t.Fatalf("normalizeSeparators(windows) = %q", got)
}
// POSIX paths are unchanged.
if got := normalizeSeparators("/mnt/media/x.mkv"); got != "/mnt/media/x.mkv" {
t.Fatalf("normalizeSeparators(posix) = %q", got)
}
// A normalized Windows path then rewrites on the Linux host.
rw := []PathRewrite{{From: "C:/Media", To: "/mnt/media"}}
if got := applyRewrites(normalizeSeparators(`C:\Media\ShowA\S01\E01.mkv`), rw); got != "/mnt/media/ShowA/S01/E01.mkv" {
t.Fatalf("windows rewrite = %q", got)
}
}