package media import ( "fmt" "path/filepath" "strings" "testing" "unicode" "unicode/utf8" ) func TestRelativeVideoPath(t *testing.T) { got, err := RelativeVideoPath("番組名", "第1話", "ep123") if err != nil { t.Fatal(err) } want := filepath.Join("番組名", "番組名 第1話 [ep123].mp4") if got != want { t.Fatalf("path=%q want=%q", got, want) } other, err := RelativeVideoPath("番組名", "第1話", "ep456") if err != nil { t.Fatal(err) } if got == other || !strings.Contains(other, "[ep456]") { t.Fatalf("ID による衝突回避: %q %q", got, other) } } func TestRelativeVideoPathSanitizesComponents(t *testing.T) { got, err := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../") if err != nil { t.Fatal(err) } if filepath.IsAbs(got) || strings.ContainsAny(got, `:*?<>|"`) || strings.Contains(got, "..") { t.Fatalf("禁止文字が残った: %q", got) } for _, r := range got { if unicode.IsControl(r) { t.Errorf("制御文字が残った: %U in %q", r, got) } } parts := strings.Split(filepath.ToSlash(got), "/") if len(parts) != 2 || parts[0] == "" || !strings.HasSuffix(got, ".mp4") { t.Fatalf("相対パスの形: %q", got) } } func TestRelativeVideoPathHandlesEmptyAndLongNames(t *testing.T) { for _, name := range []string{"", " ", ".", "..", "..."} { got, err := RelativeVideoPath(name, name, "") if err != nil { t.Fatal(err) } if !strings.Contains(got, "名称未設定") { t.Errorf("空の代替名 %q: %q", name, got) } } long := strings.Repeat("界", 200) got, err := RelativeVideoPath(long, long, "id") if err != nil { t.Fatal(err) } parts := strings.Split(filepath.ToSlash(got), "/") if len(parts) != 2 || len([]byte(parts[0])) != maxSeriesBytes || !utf8.ValidString(parts[0]) { t.Fatalf("番組名の%dバイト境界: %q (%d bytes)", maxSeriesBytes, parts[0], len([]byte(parts[0]))) } if len([]byte(parts[1])) > maxFilenameBytes || !utf8.ValidString(parts[1]) { t.Fatalf("ファイル名の上限: %q (%d bytes)", parts[1], len([]byte(parts[1]))) } if got := sanitizeComponent(strings.Repeat("a", maxEpisodeBytes+1), maxEpisodeBytes); len([]byte(got)) != maxEpisodeBytes { t.Fatalf("ASCII の%dバイト境界: %d %q", maxEpisodeBytes, len([]byte(got)), got) } } func TestRelativeVideoPathEpisodeIDLimit(t *testing.T) { id := strings.Repeat("i", maxEpisodeIDBytes) got, err := RelativeVideoPath(strings.Repeat("s", maxSeriesBytes), strings.Repeat("t", maxEpisodeBytes), id) if err != nil { t.Fatal(err) } parts := strings.Split(filepath.ToSlash(got), "/") if len(parts) != 2 || len([]byte(parts[1])) > maxFilenameBytes || !strings.Contains(parts[1], "["+id+"]") { t.Fatalf("64バイトIDのファイル名=%q", got) } if _, err := RelativeVideoPath("series", "episode", strings.Repeat("i", maxEpisodeIDBytes+1)); err == nil { t.Fatal("65バイトのエピソードIDを受理した") } } func TestRelativeVideoPathUsesFilenameBudgetInRequiredOrder(t *testing.T) { series := strings.Repeat("s", maxSeriesBytes) title := strings.Repeat("t", maxEpisodeBytes) for _, tt := range []struct { name string idBytes int wantSeriesInFile int wantEpisodeInFile int wantFilenameBytes int }{ {"ちょうど255バイト", 17, 90, 140, 255}, {"回タイトルから1バイト削る", 18, 90, 139, 255}, {"64バイトIDでも番組名を維持", 64, 90, 93, 255}, } { t.Run(tt.name, func(t *testing.T) { id := strings.Repeat("i", tt.idBytes) path, err := RelativeVideoPath(series, title, id) if err != nil { t.Fatal(err) } parts := strings.Split(filepath.ToSlash(path), "/") if len(parts) != 2 || parts[0] != series { t.Fatalf("ディレクトリ名=%q", parts) } wantFilename := strings.Repeat("s", tt.wantSeriesInFile) + " " + strings.Repeat("t", tt.wantEpisodeInFile) + " [" + id + "].mp4" if parts[1] != wantFilename || len([]byte(parts[1])) != tt.wantFilenameBytes { t.Fatalf("filename=%q (%d bytes) want=%q (%d bytes)", parts[1], len([]byte(parts[1])), wantFilename, tt.wantFilenameBytes) } }) } } func TestRelativeVideoPathEpisodeIDNearAndBeyondFilenameLimit(t *testing.T) { for _, idBytes := range []int{64, 65, 248, 255, 256} { t.Run(fmt.Sprintf("id_%d_bytes", idBytes), func(t *testing.T) { id := strings.Repeat("i", idBytes) path, err := RelativeVideoPath("series", "episode", id) if idBytes > 64 { if err == nil || path != "" { t.Fatalf("%dバイトID: path=%q err=%v, want error", idBytes, path, err) } if !strings.Contains(err.Error(), "64バイト以下") { t.Fatalf("%dバイトIDのエラー理由=%q", idBytes, err) } return } if err != nil { t.Fatal(err) } parts := strings.Split(filepath.ToSlash(path), "/") if len(parts) != 2 { t.Fatalf("parts=%q", parts) } if !strings.Contains(parts[1], "["+id+"]") { t.Fatalf("episode ID was changed: %q", parts[1]) } if got := len([]byte(parts[1])); got > maxFilenameBytes { t.Fatalf("episode ID %d bytes produced %d-byte filename above %d: %q", idBytes, got, maxFilenameBytes, parts[1]) } if !utf8.ValidString(parts[1]) { t.Fatalf("filename is not valid UTF-8: %q", parts[1]) } }) } } func TestRelativeVideoPathHasNoRuneLimit(t *testing.T) { series := strings.Repeat("a", 90) title := strings.Repeat("b", 100) got, err := RelativeVideoPath(series, title, "id") if err != nil { t.Fatal(err) } parts := strings.Split(filepath.ToSlash(got), "/") if parts[0] != series || !strings.Contains(parts[1], " "+title+" [id].mp4") { t.Fatalf("80 rune limit remains: %q", got) } } func TestSanitizeComponentTrimsWhitespaceBeforeLengthLimit(t *testing.T) { for _, tt := range []struct { name, input, want string }{ {"前後の空白", " \t 番組名\n ", "_ 番組名_"}, {"空白のみ", "   ", "名称未設定"}, {"制御文字のみ", "\t\n", "_"}, {"多数の先頭空白", strings.Repeat(" ", 100) + "番組名", "番組名"}, {"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 100), strings.Repeat("a", maxSeriesBytes)}, } { t.Run(tt.name, func(t *testing.T) { if got := sanitizeComponent(tt.input, maxSeriesBytes); got != tt.want { t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want) } }) } } func TestSanitizeComponentTrimsAgainAfterLengthLimit(t *testing.T) { for _, tt := range []struct { name, input, want string }{ {"90バイト目が空白", strings.Repeat("a", 89) + " " + "b", strings.Repeat("a", 89)}, {"90バイト目がドット", strings.Repeat("a", 89) + "." + "b", strings.Repeat("a", 89)}, {"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 89) + "b", "a"}, {"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 89) + "b", "a"}, {"複数バイト文字の直後がドット", strings.Repeat("界", 29) + ".続", strings.Repeat("界", 29)}, } { t.Run(tt.name, func(t *testing.T) { got := sanitizeComponent(tt.input, maxSeriesBytes) if got != tt.want { t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want) } if strings.Trim(got, " .") != got || got == "" { t.Errorf("切り詰め後の名前が不正: %q", got) } }) } } func TestSanitizeComponentSafetyProperties(t *testing.T) { var controls strings.Builder for r := rune(0); r <= 0x9f; r++ { if unicode.IsControl(r) { controls.WriteRune(r) } } inputs := []string{ "", " ", ".", "..", "...", `<>:"/\|?*`, controls.String(), " \t 番組名\n. ", "a/b\\c", strings.Repeat("a", 500), strings.Repeat("界", 500), strings.Repeat("😀", 500), strings.Repeat(".", 100) + "name" + strings.Repeat(" ", 100), string([]byte{0xff, 0xfe, 'a', 0xc0, 0xaf}), } for i, input := range inputs { got := sanitizeComponent(input, maxEpisodeBytes) if got == "" || got == "." || got == ".." { t.Errorf("case %d: 空またはdot segment: %q", i, got) } if strings.Trim(got, " .") != got { t.Errorf("case %d: 前後に空白またはdot: %q", i, got) } if len([]byte(got)) > 140 { t.Errorf("case %d: 上限超過: bytes=%d %q", i, len([]byte(got)), got) } if !utf8.ValidString(got) { t.Errorf("case %d: 不正なUTF-8: %q", i, got) } if strings.ContainsAny(got, `<>:"/\|?*`) { t.Errorf("case %d: 禁止文字が残った: %q", i, got) } for _, r := range got { if unicode.IsControl(r) { t.Errorf("case %d: 制御文字が残った: %U in %q", i, r, got) } } if again := sanitizeComponent(got, maxEpisodeBytes); again != got { t.Errorf("case %d: 再適用で変化: %q -> %q", i, got, again) } path, err := RelativeVideoPath(input, input, "id") if err != nil { t.Errorf("case %d: パス生成エラー: %v", i, err) continue } parts := strings.Split(filepath.ToSlash(path), "/") if filepath.IsAbs(path) || len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." || !strings.HasSuffix(parts[1], ".mp4") { t.Errorf("case %d: 安全でない相対パス: %q parts=%q", i, path, parts) continue } if len([]byte(parts[0])) > 90 || len([]byte(parts[1])) > 255 { t.Errorf("case %d: パス要素の上限超過: dir=%d filename=%d path=%q", i, len([]byte(parts[0])), len([]byte(parts[1])), path) } if !utf8.ValidString(path) { t.Errorf("case %d: パスが不正なUTF-8: %q", i, path) } } }