package layout import ( "context" "errors" "os" "path/filepath" "syscall" "testing" ) func intp(n int) *int { return &n } // fixture создаёт раскладчик с временными корнями downloads/movies/series и // одним исходным файлом. type fixture struct { l *Layouter downloads string movies string series string } func newFixture(t *testing.T) fixture { t.Helper() root := t.TempDir() downloads := filepath.Join(root, "downloads") movies := filepath.Join(root, "movies") series := filepath.Join(root, "series") for _, d := range []string{downloads, movies, series} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } l, err := New(Config{MoviesDir: movies, SeriesDir: series}, nil) if err != nil { t.Fatal(err) } return fixture{l: l, downloads: downloads, movies: movies, series: series} } func (f fixture) srcFile(t *testing.T, rel, content string) string { t.Helper() p := filepath.Join(f.downloads, rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatal(err) } return p } func TestBuildLinks_Movie(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "The.Matrix/movie.mkv", "x") sub := f.srcFile(t, "The.Matrix/movie.ru.srt", "y") plan := Plan{ Type: Movie, Title: "The Matrix", Year: 1999, ProviderTag: "tmdbid-603", Files: []PlanFile{ {Src: src, Role: RoleMain}, {Src: sub, Role: RoleSubtitle, Lang: "ru"}, {Src: f.srcFile(t, "The.Matrix/sample.mkv", "z"), Role: "sample"}, }, } links, err := f.l.BuildLinks(plan) if err != nil { t.Fatalf("BuildLinks: %v", err) } if len(links) != 2 { // sample пропущен t.Fatalf("want 2 links, got %d: %+v", len(links), links) } wantMain := filepath.Join(f.movies, "The Matrix (1999) [tmdbid-603]", "The Matrix (1999).mkv") wantSub := filepath.Join(f.movies, "The Matrix (1999) [tmdbid-603]", "The Matrix (1999).ru.srt") if links[0].Dst != wantMain || links[0].Kind != KindVideo { t.Errorf("main = %+v, want %q", links[0], wantMain) } if links[1].Dst != wantSub || links[1].Kind != KindSubtitle { t.Errorf("sub = %+v, want %q", links[1], wantSub) } } func TestBuildLinks_Series(t *testing.T) { f := newFixture(t) plan := Plan{ Type: Series, Title: "Fargo", Year: 2015, Files: []PlanFile{ {Src: f.srcFile(t, "Fargo/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)}, {Src: f.srcFile(t, "Fargo/e2.mkv", "2"), Role: RoleEpisode, Season: intp(2), Episode: intp(2)}, }, } links, err := f.l.BuildLinks(plan) if err != nil { t.Fatalf("BuildLinks: %v", err) } want := filepath.Join(f.series, "Fargo (2015)", "Season 02", "Fargo (2015) S02E01.mkv") if links[0].Dst != want { t.Errorf("ep1 = %q, want %q", links[0].Dst, want) } } func TestBuildLinks_SeriesEpisodeWithoutNumber(t *testing.T) { f := newFixture(t) plan := Plan{ Type: Series, Title: "X", Year: 2020, Files: []PlanFile{{Src: f.srcFile(t, "x/e.mkv", "1"), Role: RoleEpisode, Season: intp(1)}}, } if _, err := f.l.BuildLinks(plan); err == nil { t.Fatal("want error for episode without number") } } func TestBuildLinks_EmptyPlanRejected(t *testing.T) { f := newFixture(t) plan := Plan{Type: Movie, Title: "X", Year: 2020, Files: []PlanFile{{Src: "/x/sample.mkv", Role: "sample"}}} if _, err := f.l.BuildLinks(plan); err == nil { t.Fatal("want error when no linkable files") } } func TestBuildLinks_TraversalTitleStaysInside(t *testing.T) { f := newFixture(t) // Враждебное название не должно вывести за пределы библиотеки. plan := Plan{Type: Movie, Title: "../../etc/passwd", Year: 2020, Files: []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}}} links, err := f.l.BuildLinks(plan) if err != nil { t.Fatalf("BuildLinks: %v", err) } if !underRoot(f.movies, links[0].Dst) { t.Errorf("dst escaped library: %q", links[0].Dst) } } func TestApply_CreatesHardlink(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "m/film.mkv", "data") links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, Files: []PlanFile{{Src: src, Role: RoleMain}}}) res, err := f.l.Apply(context.Background(), links) if err != nil { t.Fatalf("Apply: %v", err) } if len(res) != 1 || res[0].Status != StatusLinked { t.Fatalf("res = %+v", res) } // Тот же inode, источник цел. si, _ := os.Stat(src) di, _ := os.Stat(links[0].Dst) if !os.SameFile(si, di) { t.Error("dst is not a hardlink of src") } if _, err := os.Stat(src); err != nil { t.Errorf("source must remain: %v", err) } } func TestApply_Idempotent(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "m/film.mkv", "data") links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, Files: []PlanFile{{Src: src, Role: RoleMain}}}) if _, err := f.l.Apply(context.Background(), links); err != nil { t.Fatalf("first apply: %v", err) } res, err := f.l.Apply(context.Background(), links) if err != nil { t.Fatalf("second apply: %v", err) } if res[0].Status != StatusExists { t.Errorf("status = %q, want exists (idempotent)", res[0].Status) } } func TestApply_CollisionNotOverwritten(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "m/film.mkv", "original") links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, Files: []PlanFile{{Src: src, Role: RoleMain}}}) // Занимаем цель посторонним файлом. if err := os.MkdirAll(filepath.Dir(links[0].Dst), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(links[0].Dst, []byte("foreign"), 0o644); err != nil { t.Fatal(err) } _, err := f.l.Apply(context.Background(), links) if !errors.Is(err, ErrCollision) { t.Fatalf("err = %v, want ErrCollision", err) } // Посторонний файл не тронут. b, _ := os.ReadFile(links[0].Dst) if string(b) != "foreign" { t.Errorf("foreign file overwritten: %q", b) } } func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) { f := newFixture(t) links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021, Files: []PlanFile{ {Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, }}) if _, err := f.l.Apply(context.Background(), links); err != nil { t.Fatal(err) } n, err := f.l.Undo(context.Background(), links) if err != nil { t.Fatalf("Undo: %v", err) } if n != 1 { t.Errorf("removed = %d, want 1", n) } if _, err := os.Stat(links[0].Dst); !errors.Is(err, os.ErrNotExist) { t.Errorf("link still exists: %v", err) } // Пустые каталоги сезона и сериала подчищены, корень цел. if _, err := os.Stat(filepath.Join(f.series, "Show (2021)")); !errors.Is(err, os.ErrNotExist) { t.Errorf("show dir not pruned: %v", err) } if _, err := os.Stat(f.series); err != nil { t.Errorf("series root must remain: %v", err) } // Источник цел. if _, err := os.Stat(links[0].Src); err != nil { t.Errorf("source removed by undo: %v", err) } } func TestUndo_Idempotent(t *testing.T) { f := newFixture(t) links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "1"), Role: RoleMain}}}) if _, err := f.l.Apply(context.Background(), links); err != nil { t.Fatal(err) } if _, err := f.l.Undo(context.Background(), links); err != nil { t.Fatal(err) } // Повторный undo — не ошибка (цель уже снята). n, err := f.l.Undo(context.Background(), links) if err != nil { t.Fatalf("second undo: %v", err) } if n != 0 { t.Errorf("removed = %d, want 0", n) } } func TestCopyFile_DuplicatesContentAndKeepsSource(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "src.mkv") dst := filepath.Join(dir, "sub", "dst.mkv") if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(src, []byte("payload"), 0o640); err != nil { t.Fatal(err) } if err := copyFile(src, dst); err != nil { t.Fatalf("copyFile: %v", err) } got, err := os.ReadFile(dst) if err != nil || string(got) != "payload" { t.Fatalf("dst content = %q, err = %v", got, err) } // Источник цел и это отдельный inode (копия, не хардлинк). si, _ := os.Stat(src) di, _ := os.Stat(dst) if os.SameFile(si, di) { t.Error("dst must be a distinct copy, not a hardlink") } if di.Mode().Perm() != 0o640 { t.Errorf("dst mode = %v, want source mode 0640", di.Mode().Perm()) } // Временные файлы копирования подчищены. entries, _ := os.ReadDir(filepath.Dir(dst)) for _, e := range entries { if len(e.Name()) >= 14 && e.Name()[:14] == ".jellybit-copy" { t.Errorf("leftover temp file: %s", e.Name()) } } } func TestHardlinkUnsupported(t *testing.T) { cases := []struct { err error want bool }{ {syscall.EXDEV, true}, {syscall.ENOTSUP, true}, {syscall.EOPNOTSUPP, true}, {syscall.EPERM, true}, {syscall.ENOENT, false}, {os.ErrExist, false}, {errors.New("random"), false}, } for _, tc := range cases { if got := hardlinkUnsupported(tc.err); got != tc.want { t.Errorf("hardlinkUnsupported(%v) = %v, want %v", tc.err, got, tc.want) } } } func TestUndo_RefusesOutsideLibrary(t *testing.T) { f := newFixture(t) outside := filepath.Join(f.downloads, "victim.mkv") if _, err := f.l.Undo(context.Background(), []Link{{Dst: outside}}); err == nil { t.Fatal("undo must refuse paths outside libraries") } }