using System.IO; using System.IO.Compression; using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Steps; using ClaudeDo.Releases; namespace ClaudeDo.Installer.Tests; public sealed class DownloadAndExtractStepTests : IDisposable { private readonly string _tempDir; private readonly string _installDir; private readonly string _cacheDir; public DownloadAndExtractStepTests() { _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); _installDir = Path.Combine(_tempDir, "install"); Directory.CreateDirectory(_installDir); _cacheDir = Path.Combine(_tempDir, "cache"); } public void Dispose() { try { Directory.Delete(_tempDir, recursive: true); } catch { } } private sealed class FileCopyReleaseClient : IReleaseClient { private readonly Dictionary _urlToSourceFile; public GiteaRelease? Release { get; set; } public List DownloadedUrls { get; } = new(); public FileCopyReleaseClient(Dictionary urlToSourceFile) => _urlToSourceFile = urlToSourceFile; public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release); public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct) { DownloadedUrls.Add(url); File.Copy(_urlToSourceFile[url], destPath, overwrite: true); progress.Report(new FileInfo(destPath).Length); return Task.CompletedTask; } } // Builds a valid release zip + checksums.txt and a client that serves both. private (FileCopyReleaseClient Client, string ZipName) SetUpRelease() { var zipPath = Path.Combine(_tempDir, "release.zip"); using (var fs = File.Create(zipPath)) using (var zip = new ZipArchive(fs, ZipArchiveMode.Create)) { var a = zip.CreateEntry("app/a.txt"); using (var w = new StreamWriter(a.Open())) w.Write("hello-app"); var b = zip.CreateEntry("worker/b.txt"); using (var w = new StreamWriter(b.Open())) w.Write("hello-worker"); } const string zipName = "ClaudeDo-0.1.0-win-x64.zip"; var checksumsPath = Path.Combine(_tempDir, "checksums.txt"); File.WriteAllText(checksumsPath, $"{ChecksumVerifier.ComputeSha256(zipPath)} {zipName}\n"); var release = new GiteaRelease("v0.1.0", "v0.1.0", new[] { new ReleaseAsset(zipName, "fake://zip", new FileInfo(zipPath).Length), new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length), }); var client = new FileCopyReleaseClient(new() { ["fake://zip"] = zipPath, ["fake://checksums"] = checksumsPath, }) { Release = release }; return (client, zipName); } [Fact] public async Task Extracts_Zip_Into_InstallDir_App_And_Worker() { var zipPath = Path.Combine(_tempDir, "release.zip"); using (var fs = File.Create(zipPath)) using (var zip = new ZipArchive(fs, ZipArchiveMode.Create)) { var a = zip.CreateEntry("app/a.txt"); using (var w = new StreamWriter(a.Open())) w.Write("hello-app"); var b = zip.CreateEntry("worker/b.txt"); using (var w = new StreamWriter(b.Open())) w.Write("hello-worker"); } var zipHash = ChecksumVerifier.ComputeSha256(zipPath); var checksumsPath = Path.Combine(_tempDir, "checksums.txt"); File.WriteAllText(checksumsPath, $"{zipHash} ClaudeDo-0.1.0-win-x64.zip\n"); var release = new GiteaRelease("v0.1.0", "v0.1.0", new[] { new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", new FileInfo(zipPath).Length), new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length), }); var client = new FileCopyReleaseClient(new() { ["fake://zip"] = zipPath, ["fake://checksums"] = checksumsPath, }) { Release = release }; var step = new DownloadAndExtractStep(client, _cacheDir); var ctx = new InstallContext { InstallDirectory = _installDir }; var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.True(result.Success, result.ErrorMessage); Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt"))); Assert.Equal("hello-worker", File.ReadAllText(Path.Combine(_installDir, "worker", "b.txt"))); Assert.Equal("0.1.0", ctx.InstalledVersion); } [Fact] public async Task Fails_On_ChecksumMismatch_Without_Overwriting_InstallDir() { var zipPath = Path.Combine(_tempDir, "release.zip"); using (var fs = File.Create(zipPath)) using (var zip = new ZipArchive(fs, ZipArchiveMode.Create)) { var a = zip.CreateEntry("app/a.txt"); using (var w = new StreamWriter(a.Open())) w.Write("x"); } var checksumsPath = Path.Combine(_tempDir, "checksums.txt"); File.WriteAllText(checksumsPath, $"{new string('0', 64)} ClaudeDo-0.1.0-win-x64.zip\n"); File.WriteAllText(Path.Combine(_installDir, "marker.txt"), "untouched"); var release = new GiteaRelease("v0.1.0", "v0.1.0", new[] { new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", 0), new ReleaseAsset("checksums.txt", "fake://checksums", 0), }); var client = new FileCopyReleaseClient(new() { ["fake://zip"] = zipPath, ["fake://checksums"] = checksumsPath, }) { Release = release }; var step = new DownloadAndExtractStep(client, _cacheDir); var ctx = new InstallContext { InstallDirectory = _installDir }; var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.False(result.Success); Assert.Contains("checksum", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(Path.Combine(_installDir, "marker.txt"))); Assert.False(Directory.Exists(Path.Combine(_installDir, "app"))); } [Fact] public async Task Fails_When_Release_Has_No_Zip_Asset() { var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty()); var client = new FileCopyReleaseClient(new()) { Release = release }; var step = new DownloadAndExtractStep(client, _cacheDir); var ctx = new InstallContext { InstallDirectory = _installDir }; var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.False(result.Success); Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Reuses_Cached_Zip_Instead_Of_Downloading_It_Again() { var (client, zipName) = SetUpRelease(); Directory.CreateDirectory(_cacheDir); // A previous run left a fully downloaded, still-valid zip behind. File.Copy(Path.Combine(_tempDir, "release.zip"), Path.Combine(_cacheDir, zipName)); var step = new DownloadAndExtractStep(client, _cacheDir); var ctx = new InstallContext { InstallDirectory = _installDir }; var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.True(result.Success, result.ErrorMessage); Assert.DoesNotContain("fake://zip", client.DownloadedUrls); Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt"))); } [Fact] public async Task Redownloads_When_Cached_Zip_Is_Corrupt() { var (client, zipName) = SetUpRelease(); Directory.CreateDirectory(_cacheDir); // A cancelled/aborted download left a truncated file behind. File.WriteAllText(Path.Combine(_cacheDir, zipName), "not-a-zip"); var step = new DownloadAndExtractStep(client, _cacheDir); var ctx = new InstallContext { InstallDirectory = _installDir }; var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.True(result.Success, result.ErrorMessage); Assert.Contains("fake://zip", client.DownloadedUrls); Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt"))); } [Fact] public async Task Keeps_Zip_Cached_When_The_Install_Fails_And_Drops_It_On_Success() { var (client, zipName) = SetUpRelease(); var cachedZip = Path.Combine(_cacheDir, zipName); // Make extraction fail: a *file* named "app" blocks the app/ entry's directory. var blocker = Path.Combine(_installDir, "app"); File.WriteAllText(blocker, "blocked"); var step = new DownloadAndExtractStep(client, _cacheDir); var ctx = new InstallContext { InstallDirectory = _installDir }; var failed = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.False(failed.Success); Assert.True(File.Exists(cachedZip), "a failed install must keep the download cached"); // Retry after clearing the blocker: no second download, and the cache is dropped. File.Delete(blocker); client.DownloadedUrls.Clear(); var ok = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.True(ok.Success, ok.ErrorMessage); Assert.DoesNotContain("fake://zip", client.DownloadedUrls); Assert.False(File.Exists(cachedZip), "a successful install must not leave the zip behind"); } [Fact] public async Task Prunes_Cached_Zips_From_Other_Versions() { var (client, _) = SetUpRelease(); Directory.CreateDirectory(_cacheDir); var stale = Path.Combine(_cacheDir, "ClaudeDo-0.0.9-win-x64.zip"); File.WriteAllText(stale, "old release"); var step = new DownloadAndExtractStep(client, _cacheDir); var ctx = new InstallContext { InstallDirectory = _installDir }; var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.True(result.Success, result.ErrorMessage); Assert.False(File.Exists(stale)); } [Fact] public async Task Fails_When_ReleaseClient_Returns_Null() { var client = new FileCopyReleaseClient(new()) { Release = null }; var step = new DownloadAndExtractStep(client, _cacheDir); var ctx = new InstallContext { InstallDirectory = _installDir }; var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None); Assert.False(result.Success); } }