using System.Net.Http; namespace ClaudeDo.Releases.Tests; public class SelfUpdaterAssetMatchingTests { [Fact] public void FindInstallerAsset_PicksInstallerExeByPattern() { var assets = new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10), new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst.exe", 20), new ReleaseAsset("checksums.txt", "https://x/checks", 1), }; var result = SelfUpdater.FindInstallerAsset(assets); Assert.NotNull(result); Assert.Equal("ClaudeDo.Installer-0.3.0.exe", result!.Asset.Name); Assert.Equal("0.3.0", result.Version); } [Fact] public void FindInstallerAsset_ReturnsNullWhenAbsent() { var assets = new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10), }; Assert.Null(SelfUpdater.FindInstallerAsset(assets)); } [Fact] public void FindInstallerAsset_IgnoresAppZipThatContainsInstaller() { var assets = new[] { new ReleaseAsset("ClaudeDo.Installer.Portable-0.3.0.zip", "https://x/1", 1), new ReleaseAsset("not-the-installer.exe", "https://x/2", 1), }; Assert.Null(SelfUpdater.FindInstallerAsset(assets)); } } public class SelfUpdaterDecisionTests { private sealed class FakeReleaseClient : IReleaseClient { public GiteaRelease? Release { get; set; } public bool Throw { get; set; } public Task GetLatestReleaseAsync(CancellationToken ct) { if (Throw) throw new HttpRequestException("boom"); return Task.FromResult(Release); } public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct) => throw new NotSupportedException("not used in decision tests"); } [Fact] public async Task Decide_NoRelease_NoUpdate() { var client = new FakeReleaseClient { Release = null }; var d = await SelfUpdater.DecideUpdateAsync(client, currentVersion: "0.1.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } [Fact] public async Task Decide_NetworkError_NoUpdate() { var client = new FakeReleaseClient { Throw = true }; var d = await SelfUpdater.DecideUpdateAsync(client, "0.1.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } [Fact] public async Task Decide_OlderLatest_NoUpdate() { var client = new FakeReleaseClient { Release = new GiteaRelease("v0.1.0", "rel", new[] { new ReleaseAsset("ClaudeDo.Installer-0.1.0.exe", "u", 1), }), }; var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } [Fact] public async Task Decide_NewerLatestWithAsset_UpdateAvailable() { var client = new FakeReleaseClient { Release = new GiteaRelease("v0.3.0", "rel", new[] { new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x", 20), new ReleaseAsset("checksums.txt", "https://checks", 1), }), }; var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.UpdateAvailable, d.Kind); Assert.Equal("0.3.0", d.LatestVersion); Assert.NotNull(d.InstallerAsset); Assert.NotNull(d.ChecksumsAsset); } [Fact] public async Task Decide_NewerLatestButNoInstallerAsset_NoUpdate() { var client = new FakeReleaseClient { Release = new GiteaRelease("v0.3.0", "rel", new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 20), }), }; var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } } public class SelfUpdaterReplaceSelfTests : IDisposable { private readonly string _tempDir; public SelfUpdaterReplaceSelfTests() { _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } } [Fact] public async Task Replace_DeletesOldAndCopiesCurrent() { var oldPath = Path.Combine(_tempDir, "old.exe"); var currentPath = Path.Combine(_tempDir, "current.exe"); await File.WriteAllTextAsync(oldPath, "OLD"); await File.WriteAllTextAsync(currentPath, "NEW"); var relaunchedWith = ""; var result = await SelfUpdater.HandleReplaceSelfAsync( oldPath: oldPath, currentExePath: currentPath, launchProcess: path => { relaunchedWith = path; return true; }, maxWaitMs: 500); Assert.True(result); Assert.Equal(oldPath, relaunchedWith); Assert.Equal("NEW", await File.ReadAllTextAsync(oldPath)); } [Fact] public async Task Replace_TimesOutWhenFileStaysLocked_ReturnsFalse() { var oldPath = Path.Combine(_tempDir, "locked.exe"); var currentPath = Path.Combine(_tempDir, "current.exe"); await File.WriteAllTextAsync(oldPath, "OLD"); await File.WriteAllTextAsync(currentPath, "NEW"); // Hold an exclusive lock across the wait window. using var lockStream = new FileStream(oldPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); var result = await SelfUpdater.HandleReplaceSelfAsync( oldPath: oldPath, currentExePath: currentPath, launchProcess: _ => true, maxWaitMs: 200); Assert.False(result); } } public class SelfUpdaterDownloadTests : IDisposable { private readonly string _tempDir; public SelfUpdaterDownloadTests() { _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } } private sealed class StubReleaseClient : IReleaseClient { public string FileContent { get; set; } = ""; public string ChecksumsBody { get; set; } = ""; public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(null); public async Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct) { if (url.EndsWith("checksums.txt", StringComparison.OrdinalIgnoreCase)) { await File.WriteAllTextAsync(destPath, ChecksumsBody, ct); } else { await File.WriteAllTextAsync(destPath, FileContent, ct); } progress.Report(FileContent.Length); } } [Fact] public async Task Download_MatchingChecksum_ReturnsPath() { var content = "FAKE-INSTALLER-BINARY"; var hash = Sha256Hex(content); var client = new StubReleaseClient { FileContent = content, ChecksumsBody = $"{hash} ClaudeDo.Installer-0.3.0.exe\n", }; var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", content.Length); var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100); var path = await SelfUpdater.DownloadAndVerifyAsync( client, installer, checksums, _tempDir, new Progress(_ => { }), CancellationToken.None); Assert.NotNull(path); Assert.Equal(content, await File.ReadAllTextAsync(path!)); } [Fact] public async Task Download_ChecksumMismatch_ReturnsNull() { var client = new StubReleaseClient { FileContent = "real", ChecksumsBody = "deadbeef" + new string('0', 56) + " ClaudeDo.Installer-0.3.0.exe\n", }; var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", 4); var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100); var path = await SelfUpdater.DownloadAndVerifyAsync( client, installer, checksums, _tempDir, new Progress(_ => { }), CancellationToken.None); Assert.Null(path); } private static string Sha256Hex(string s) { using var sha = System.Security.Cryptography.SHA256.Create(); return Convert.ToHexString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s))).ToLowerInvariant(); } }