using System.Net; using Paca.Core.Config; using Paca.Core.Hls; using Paca.Core.Platform; using Xunit; namespace Paca.Tests.Core; public class DownloadFolderCleanlinessAndPacaIsolationGateTests { [Fact] public void Gate_DownloadMetaPathResolver_CreatesHiddenPacaFolder_AndSubdirectories() { var tempRoot = Path.Combine(Path.GetTempPath(), "paca_clean_test_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); try { var outputBase = Path.Combine(tempRoot, "test_video"); var partsDir = DownloadMetaPathResolver.GetPartsDirectory(outputBase); var tempPartFile = DownloadMetaPathResolver.GetTempPartFilePath(outputBase); var pacaDir = DownloadMetaPathResolver.GetPacaDirectory(tempRoot); Assert.True(Directory.Exists(pacaDir), ".paca 폴더가 생성되어야 한다."); var di = new DirectoryInfo(pacaDir); Assert.True((di.Attributes & FileAttributes.Hidden) != 0, ".paca 폴더는 Hidden 속성을 가져야 한다."); // partsDir은 .paca/parts 하위에 존재해야 함 Assert.Contains(Path.Combine(".paca", "parts"), partsDir); Assert.DoesNotContain(tempRoot + Path.DirectorySeparatorChar + "test_video.parts", partsDir); // tempPartFile은 .paca/temp 하위에 존재해야 함 Assert.Contains(Path.Combine(".paca", "temp"), tempPartFile); } finally { if (Directory.Exists(tempRoot)) { try { Directory.Delete(tempRoot, recursive: true); } catch { } } } } [Fact] public async Task Gate_DownloadFolder_HlsDownloader_StoresPartsInsidePacaHiddenFolder() { var tempRoot = Path.Combine(Path.GetTempPath(), "paca_hls_clean_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); // Fake HTTP Listener for sample m3u8 var listener = new HttpListener(); var port = 58921; listener.Prefixes.Add($"http://127.0.0.1:{port}/"); listener.Start(); var serverTask = Task.Run(async () => { while (listener.IsListening) { try { var ctx = await listener.GetContextAsync(); var path = ctx.Request.Url?.AbsolutePath ?? ""; if (path.EndsWith("playlist.m3u8")) { var m3u8 = "#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2.0,\nseg0.ts\n#EXT-X-ENDLIST\n"; var bytes = System.Text.Encoding.UTF8.GetBytes(m3u8); ctx.Response.ContentType = "application/vnd.apple.mpegurl"; ctx.Response.OutputStream.Write(bytes); ctx.Response.Close(); } else if (path.EndsWith("seg0.ts")) { var seg = new byte[1024]; new Random(42).NextBytes(seg); ctx.Response.ContentType = "video/MP2T"; ctx.Response.OutputStream.Write(seg); ctx.Response.Close(); } } catch { break; } } }); try { using var http = new HttpClient(); var config = new AppConfig { Concurrency = 1, OutputFolder = tempRoot }; var fakeMuxer = new FakeMediaMuxer(); var hls = new HlsDownloader(http, config, fakeMuxer); var outputBase = Path.Combine(tempRoot, "my_video"); var res = await hls.DownloadAsync(new Uri($"http://127.0.0.1:{port}/playlist.m3u8"), outputBase); // 1. 루트 폴더에 .parts 폴더나 .json 파일이 절대 없어야 한다! var rootDirs = Directory.GetDirectories(tempRoot).Select(Path.GetFileName).ToList(); Assert.DoesNotContain("my_video.parts", rootDirs); var rootFiles = Directory.GetFiles(tempRoot).Select(Path.GetFileName).ToList(); Assert.DoesNotContain(".chunkcache.json", rootFiles); Assert.DoesNotContain("chunkcache.json", rootFiles); // 2. 최종 mp4 파일은 루트에 존재해야 한다 Assert.True(File.Exists(outputBase + ".mp4"), "최종 mp4는 루트에 있어야 함"); } finally { listener.Stop(); try { await serverTask; } catch { } if (Directory.Exists(tempRoot)) { try { Directory.Delete(tempRoot, recursive: true); } catch { } } } } [Fact] public async Task Gate_DownloadFolder_DirectDownloader_IsolatesPartFileInPacaHiddenDir() { var tempRoot = Path.Combine(Path.GetTempPath(), "paca_direct_clean_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempRoot); try { var fakeHandler = new SimpleMockHandler(new byte[512]); using var client = new HttpClient(fakeHandler); var config = new AppConfig { OutputFolder = tempRoot }; var downloader = new DirectDownloader(client, config); var outputBase = Path.Combine(tempRoot, "direct_file"); var final = await downloader.DownloadAsync(new Uri("https://test.paca/video.mp4"), outputBase); Assert.True(File.Exists(final)); // 루트 폴더에 .part 파일이 없어야 함 Assert.False(File.Exists(outputBase + ".part"), "루트 폴더에 .part 파일이 존재해서는 안 됨"); var rootFiles = Directory.GetFiles(tempRoot).Select(Path.GetFileName).ToList(); Assert.DoesNotContain("direct_file.part", rootFiles); } finally { if (Directory.Exists(tempRoot)) { try { Directory.Delete(tempRoot, recursive: true); } catch { } } } } private sealed class SimpleMockHandler : HttpMessageHandler { private readonly byte[] _data; public SimpleMockHandler(byte[] data) => _data = data; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var resp = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(_data) }; resp.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("video/mp4"); return Task.FromResult(resp); } } private sealed class FakeMediaMuxer : IMediaMuxer { public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct) { File.Copy(input, output, overwrite: true); return Task.CompletedTask; } public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, HttpClient http, IProgress? progress, CancellationToken ct) => Task.CompletedTask; public string? ResolveFfmpeg(AppConfig config) => "fake-ffmpeg"; } }