Merge branch 'main' into feat/ui-improvements
This commit is contained in:
106
tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
Normal file
106
tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class ChecksumVerifierTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ChecksumVerifierTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoChecksum-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSha256_KnownVector_EmptyFile()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "empty.bin");
|
||||
File.WriteAllBytes(path, Array.Empty<byte>());
|
||||
|
||||
var hash = ChecksumVerifier.ComputeSha256(path);
|
||||
|
||||
Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSha256_KnownVector_Hello()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "hello.bin");
|
||||
File.WriteAllText(path, "hello");
|
||||
|
||||
var hash = ChecksumVerifier.ComputeSha256(path);
|
||||
|
||||
Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsTrue_WhenHashMatches()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "x.bin");
|
||||
File.WriteAllText(path, "hello");
|
||||
|
||||
Assert.True(ChecksumVerifier.Verify(path,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_IsCaseInsensitive()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "x.bin");
|
||||
File.WriteAllText(path, "hello");
|
||||
|
||||
Assert.True(ChecksumVerifier.Verify(path,
|
||||
"2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsFalse_OnMismatch()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "x.bin");
|
||||
File.WriteAllText(path, "hello");
|
||||
|
||||
Assert.False(ChecksumVerifier.Verify(path, new string('0', 64)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksumsFile_ReadsTwoLines()
|
||||
{
|
||||
var content = """
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ClaudeDo-0.2.0-win-x64.zip
|
||||
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ClaudeDo.Installer-0.2.0.exe
|
||||
""";
|
||||
|
||||
var map = ChecksumVerifier.ParseChecksumsFile(content);
|
||||
|
||||
Assert.Equal(2, map.Count);
|
||||
Assert.Equal(
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
map["ClaudeDo-0.2.0-win-x64.zip"]);
|
||||
Assert.Equal(
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
map["ClaudeDo.Installer-0.2.0.exe"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksumsFile_SkipsBlankAndMalformedLines()
|
||||
{
|
||||
var content = """
|
||||
|
||||
not a line
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip
|
||||
|
||||
""";
|
||||
|
||||
var map = ChecksumVerifier.ParseChecksumsFile(content);
|
||||
|
||||
Assert.Single(map);
|
||||
Assert.True(map.ContainsKey("file.zip"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ClaudeDo.Installer\ClaudeDo.Installer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
148
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
Normal file
148
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class DownloadAndExtractStepTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _installDir;
|
||||
|
||||
public DownloadAndExtractStepTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_installDir = Path.Combine(_tempDir, "install");
|
||||
Directory.CreateDirectory(_installDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private sealed class FileCopyReleaseClient : IReleaseClient
|
||||
{
|
||||
private readonly Dictionary<string, string> _urlToSourceFile;
|
||||
public GiteaRelease? Release { get; set; }
|
||||
|
||||
public FileCopyReleaseClient(Dictionary<string, string> urlToSourceFile)
|
||||
=> _urlToSourceFile = urlToSourceFile;
|
||||
|
||||
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
|
||||
|
||||
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||
{
|
||||
File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
|
||||
progress.Report(new FileInfo(destPath).Length);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), 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);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), 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<ReleaseAsset>());
|
||||
var client = new FileCopyReleaseClient(new()) { Release = release };
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_When_ReleaseClient_Returns_Null()
|
||||
{
|
||||
var client = new FileCopyReleaseClient(new()) { Release = null };
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
32
tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
Normal file
32
tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||
private readonly object _lock = new();
|
||||
private readonly List<HttpRequestMessage> _requests = new();
|
||||
|
||||
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public FakeHttpMessageHandler(HttpStatusCode status, string body)
|
||||
: this(_ => new HttpResponseMessage(status) { Content = new StringContent(body) })
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyList<HttpRequestMessage> Requests
|
||||
{
|
||||
get { lock (_lock) return _requests.ToArray(); }
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock) _requests.Add(request);
|
||||
return Task.FromResult(_handler(request));
|
||||
}
|
||||
}
|
||||
76
tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
Normal file
76
tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class InstallManifestStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public InstallManifestStoreTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoInstallerTests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_ReturnsNull_WhenFileMissing()
|
||||
{
|
||||
var result = InstallManifestStore.TryRead(_tempDir);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_Then_Read_RoundTripsAllFields()
|
||||
{
|
||||
var manifest = new InstallManifest(
|
||||
Version: "0.2.0",
|
||||
InstallDir: _tempDir,
|
||||
WorkerDir: Path.Combine(_tempDir, "worker"),
|
||||
InstalledAt: new DateTimeOffset(2026, 4, 15, 12, 34, 56, TimeSpan.Zero));
|
||||
|
||||
InstallManifestStore.Write(_tempDir, manifest);
|
||||
|
||||
var round = InstallManifestStore.TryRead(_tempDir);
|
||||
Assert.NotNull(round);
|
||||
Assert.Equal("0.2.0", round!.Version);
|
||||
Assert.Equal(manifest.InstallDir, round.InstallDir);
|
||||
Assert.Equal(manifest.WorkerDir, round.WorkerDir);
|
||||
Assert.Equal(manifest.InstalledAt, round.InstalledAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_CreatesInstallDir_IfMissing()
|
||||
{
|
||||
var nested = Path.Combine(_tempDir, "nested");
|
||||
Assert.False(Directory.Exists(nested));
|
||||
|
||||
InstallManifestStore.Write(nested, new InstallManifest(
|
||||
"0.0.1", nested, Path.Combine(nested, "worker"), DateTimeOffset.UtcNow));
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(nested, "install.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_ReturnsNull_WhenJsonMalformed()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tempDir, "install.json"), "{ not json");
|
||||
var result = InstallManifestStore.TryRead(_tempDir);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRead_ReturnsNull_WhenJsonIsValidButShapeIsWrong()
|
||||
{
|
||||
// Valid JSON, but installedAt has a wrong type — causes JsonException, swallowed silently.
|
||||
File.WriteAllText(Path.Combine(_tempDir, "install.json"),
|
||||
"""{"version":"1.0","installDir":"x","workerDir":"y","installedAt":12345}""");
|
||||
var result = InstallManifestStore.TryRead(_tempDir);
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
124
tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
Normal file
124
tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class InstallModeDetectorTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public InstallModeDetectorTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDetector-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private sealed class FakeReleaseClient : IReleaseClient
|
||||
{
|
||||
public GiteaRelease? Release { get; set; }
|
||||
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
|
||||
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_FreshInstall_WhenManifestMissing()
|
||||
{
|
||||
var detector = new InstallModeDetector(new FakeReleaseClient());
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.FreshInstall, state.Mode);
|
||||
Assert.Null(state.Existing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Config_WhenManifestPresent_And_Api_Unreachable()
|
||||
{
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var detector = new InstallModeDetector(new FakeReleaseClient { Release = null });
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Config, state.Mode);
|
||||
Assert.Equal("0.1.0", state.Existing!.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Update_WhenLatest_GreaterThan_Installed()
|
||||
{
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var fake = new FakeReleaseClient
|
||||
{
|
||||
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
|
||||
};
|
||||
var detector = new InstallModeDetector(fake);
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Update, state.Mode);
|
||||
Assert.Equal("0.1.0", state.Existing!.Version);
|
||||
Assert.Equal("0.2.0", state.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Config_WhenLatest_EqualsOrOlderThan_Installed()
|
||||
{
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("0.2.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var fake = new FakeReleaseClient
|
||||
{
|
||||
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
|
||||
};
|
||||
var detector = new InstallModeDetector(fake);
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Config, state.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Config_WhenInstalledIs_Newer_Than_Latest()
|
||||
{
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("0.3.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var fake = new FakeReleaseClient
|
||||
{
|
||||
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
|
||||
};
|
||||
var detector = new InstallModeDetector(fake);
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Config, state.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detect_Config_WhenInstalledVersion_IsUnparseable()
|
||||
{
|
||||
// install.json has been tampered with or written by an older installer with a
|
||||
// version string we can't compare. Must not crash; must land on Config (no update).
|
||||
InstallManifestStore.Write(_tempDir,
|
||||
new InstallManifest("garbage", _tempDir, _tempDir, DateTimeOffset.UtcNow));
|
||||
|
||||
var fake = new FakeReleaseClient
|
||||
{
|
||||
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
|
||||
};
|
||||
var detector = new InstallModeDetector(fake);
|
||||
|
||||
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
|
||||
|
||||
Assert.Equal(InstallerMode.Config, state.Mode);
|
||||
}
|
||||
}
|
||||
109
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
Normal file
109
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class ReleaseClientTests
|
||||
{
|
||||
private const string ApiBase = "https://git.example.test/api/v1/repos/releases/ClaudeDo";
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestReleaseAsync_ParsesTagAndAssets()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"tag_name": "v0.2.0",
|
||||
"name": "v0.2.0",
|
||||
"assets": [
|
||||
{
|
||||
"name": "ClaudeDo-0.2.0-win-x64.zip",
|
||||
"browser_download_url": "https://git.example.test/dl/zip",
|
||||
"size": 12345
|
||||
},
|
||||
{
|
||||
"name": "checksums.txt",
|
||||
"browser_download_url": "https://git.example.test/dl/checksums",
|
||||
"size": 128
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal("v0.2.0", release!.TagName);
|
||||
Assert.Equal(2, release.Assets.Count);
|
||||
Assert.Equal("ClaudeDo-0.2.0-win-x64.zip", release.Assets[0].Name);
|
||||
Assert.Equal("https://git.example.test/dl/zip", release.Assets[0].BrowserDownloadUrl);
|
||||
Assert.Equal(12345, release.Assets[0].Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestReleaseAsync_Returns_Null_On404()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound, "");
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||
|
||||
Assert.Null(release);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestReleaseAsync_Returns_Null_OnNetworkError()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("boom"));
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||
|
||||
Assert.Null(release);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestReleaseAsync_Hits_CorrectUrl()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, "{\"tag_name\":\"v0.1.0\",\"assets\":[]}");
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
_ = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(handler.Requests);
|
||||
Assert.Equal($"{ApiBase}/releases/latest", handler.Requests[0].RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAsync_WritesBytesToDisk()
|
||||
{
|
||||
var payload = new byte[] { 1, 2, 3, 4, 5 };
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(payload)
|
||||
});
|
||||
using var http = new HttpClient(handler);
|
||||
var client = new ReleaseClient(http, ApiBase);
|
||||
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), "ClaudeDoDlTest-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
await client.DownloadAsync("https://example/foo", tempPath,
|
||||
new Progress<long>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.True(File.Exists(tempPath));
|
||||
Assert.Equal(payload, File.ReadAllBytes(tempPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class WriteInstallManifestStepTests : IDisposable
|
||||
{
|
||||
private readonly string _installDir;
|
||||
|
||||
public WriteInstallManifestStepTests()
|
||||
{
|
||||
_installDir = Path.Combine(Path.GetTempPath(), "ClaudeDoWriteManifest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_installDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_installDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writes_Manifest_WithAllFields()
|
||||
{
|
||||
var ctx = new InstallContext
|
||||
{
|
||||
InstallDirectory = _installDir,
|
||||
InstalledVersion = "0.2.0",
|
||||
};
|
||||
|
||||
var step = new WriteInstallManifestStep();
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var manifest = InstallManifestStore.TryRead(_installDir);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("0.2.0", manifest!.Version);
|
||||
Assert.Equal(_installDir, manifest.InstallDir);
|
||||
Assert.Equal(Path.Combine(_installDir, "worker"), manifest.WorkerDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_When_InstalledVersion_Missing()
|
||||
{
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir }; // no version set
|
||||
|
||||
var step = new WriteInstallManifestStep();
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user