diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 3540d14..e6f50c3 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -66,25 +66,14 @@ jobs: with: dotnet-version: '10.0.x' - - name: Run HTTP-level tests against the image + - name: Run integration tests (HTTP-level + real Chorus client) against the image working-directory: csharp env: - # The test fixture drives a container via this CLI (docker on the runner); it starts/stops - # the pre-built image itself, so we skip the fixture's own build step. - HGRESUME_PODMAN: docker + # The test fixture drives the pre-built image itself via Testcontainers (against the + # runner's local Docker daemon), so we skip the fixture's own build step. HGRESUME_IMAGE: hgresume-csharp:test HGRESUME_SKIP_BUILD: '1' - HGRESUME_PORT: '8034' - run: dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal" - - - name: Run send/receive tests (real Chorus client) against the image - working-directory: csharp - env: - HGRESUME_PODMAN: docker - HGRESUME_IMAGE: hgresume-csharp:test - HGRESUME_SKIP_BUILD: '1' - HGRESUME_PORT: '8041' - run: dotnet test test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj --logger "console;verbosity=normal" + run: dotnet test test/HgResume.IntegrationTests/HgResume.IntegrationTests.csproj --logger "console;verbosity=normal" - name: Log in to the Container registry # Fork PRs get a read-only GITHUB_TOKEN; skip login/push so the job still builds without a 403. diff --git a/README.md b/README.md index fac2779..8e6afa0 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ The original PHP implementation is no longer in this tree. The last commit that - `AsyncRunner` — runs long hg commands in the background and signals completion via a `.async_run` file, so a later HTTP request can observe the result (this is what makes transfers resumable). - `BundleHelper` — per-transaction state + metadata (stored as JSON). -- `csharp/test/HgResume.HttpTests/` — HTTP-level xUnit tests. They drive the **running container** - over HTTP (via a podman-managed fixture) and assert on the protocol. +- `csharp/test/HgResume.IntegrationTests/` — xUnit tests that drive the **running container** (via a + podman-managed fixture): HTTP-level wire-protocol tests, and end-to-end send/receive tests using the + real Chorus resumable client. - `csharp/Dockerfile` — multi-stage `dotnet/sdk:10.0` → `dotnet/aspnet:10.0`, installs `mercurial`. Listens on port 80 and exposes `/var/cache/hgresume` and `/var/vcs/public`. - `docker-compose.yaml` — local run against a host Mercurial repo tree. @@ -53,16 +54,19 @@ curl -i http://localhost:8034/api/v03/isAvailable ## Tests -The HTTP-level suite builds the image, runs it in a container, seeds fixture repos, and exercises -the protocol end-to-end: +The integration suite builds the image, runs it in a container, seeds fixture repos, and exercises +the protocol end-to-end — both directly over HTTP and via the real Chorus resumable client: ```bash cd csharp ./run-tests.sh # or: pwsh ./run-tests.ps1 ``` -Useful env overrides: `HGRESUME_IMAGE`, `HGRESUME_PORT`, `HGRESUME_SKIP_BUILD`, and -`HGRESUME_BASE_URL` + `HGRESUME_CONTAINER` (to run the tests against an already-running container). +Useful env overrides: `HGRESUME_IMAGE`, `HGRESUME_SKIP_BUILD`, and `HGRESUME_BASE_URL` + +`HGRESUME_CONTAINER` (to run the tests against an already-running container). The suite drives the +container via [Testcontainers](https://testcontainers.com/), which needs a Docker-API-compatible +endpoint — Docker Desktop/Engine work out of the box; podman needs its API socket exposed and +`DOCKER_HOST` pointed at it. ## CI diff --git a/csharp/.gitignore b/csharp/.gitignore index 222be3c..c89ffcc 100644 --- a/csharp/.gitignore +++ b/csharp/.gitignore @@ -4,6 +4,6 @@ obj/ .idea/ .vs/ -# Bundled Mercurial dropped into the SendReceive test project by SIL.Chorus.Mercurial at build time -test/HgResume.SendReceiveTests/Mercurial/ -test/HgResume.SendReceiveTests/MercurialExtensions/ +# Bundled Mercurial dropped into the integration test project by SIL.Chorus.Mercurial at build time +test/HgResume.IntegrationTests/Mercurial/ +test/HgResume.IntegrationTests/MercurialExtensions/ diff --git a/csharp/HgResume.slnx b/csharp/HgResume.slnx index 38414f1..8e6a2b5 100644 --- a/csharp/HgResume.slnx +++ b/csharp/HgResume.slnx @@ -3,7 +3,6 @@ - - + diff --git a/csharp/run-tests.ps1 b/csharp/run-tests.ps1 index 7fce61d..be47609 100644 --- a/csharp/run-tests.ps1 +++ b/csharp/run-tests.ps1 @@ -1,9 +1,10 @@ #!/usr/bin/env pwsh -# Builds the C# hgresume image with podman, then runs the HTTP-level test suite against a container. -# The test fixture starts/stops the container itself; this script just builds the image first. +# Builds the C# hgresume image with podman, then runs the integration test suite against a container. +# The test fixture (Testcontainers) starts/stops the container itself; this script just builds the +# image first. Testcontainers talks to the Docker Engine API directly, so this only works if podman's +# API socket is exposed and DOCKER_HOST points at it (Docker Desktop/Engine need no extra setup). param( [string]$Image = "hgresume-csharp:test", - [string]$Port = "8034", [switch]$SkipBuild ) @@ -20,11 +21,10 @@ try { } $env:HGRESUME_IMAGE = $Image - $env:HGRESUME_PORT = $Port $env:HGRESUME_SKIP_BUILD = "1" # already built above - Write-Host "==> Running HTTP-level tests against the image" -ForegroundColor Cyan - dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal" + Write-Host "==> Running integration tests against the image" -ForegroundColor Cyan + dotnet test test/HgResume.IntegrationTests/HgResume.IntegrationTests.csproj --logger "console;verbosity=normal" if ($LASTEXITCODE -ne 0) { throw "dotnet test failed with exit code $LASTEXITCODE" } diff --git a/csharp/run-tests.sh b/csharp/run-tests.sh index 0ad04fa..a01b4e5 100644 --- a/csharp/run-tests.sh +++ b/csharp/run-tests.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# Builds the C# hgresume image with podman, then runs the HTTP-level test suite against a container. -# The test fixture starts/stops the container itself; this script just builds the image first. +# Builds the C# hgresume image with podman, then runs the integration test suite against a container. +# The test fixture (Testcontainers) starts/stops the container itself; this script just builds the +# image first. Testcontainers talks to the Docker Engine API directly, so this only works if podman's +# API socket is exposed and DOCKER_HOST points at it (Docker Desktop/Engine need no extra setup). set -euo pipefail IMAGE="${HGRESUME_IMAGE:-hgresume-csharp:test}" -PORT="${HGRESUME_PORT:-8034}" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$here" @@ -14,8 +15,7 @@ if [ "${1:-}" != "--skip-build" ]; then fi export HGRESUME_IMAGE="$IMAGE" -export HGRESUME_PORT="$PORT" export HGRESUME_SKIP_BUILD=1 -echo "==> Running HTTP-level tests against the image" -dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal" +echo "==> Running integration tests against the image" +dotnet test test/HgResume.IntegrationTests/HgResume.IntegrationTests.csproj --logger "console;verbosity=normal" diff --git a/csharp/src/HgResume.Api/HgResumeApi.cs b/csharp/src/HgResume.Api/HgResumeApi.cs index 44ed0e6..fc214d0 100644 --- a/csharp/src/HgResume.Api/HgResumeApi.cs +++ b/csharp/src/HgResume.Api/HgResumeApi.cs @@ -447,11 +447,20 @@ private string GetRepoPath(string repoId) foreach (var basePath in _config.RepoSearchPaths) { var fullBasePath = Path.GetFullPath(basePath); - var possibleRepoPath = Path.Combine(fullBasePath, repoId); + var flatPath = Path.Combine(fullBasePath, repoId); - if (possibleRepoPath.StartsWith(fullBasePath) && Directory.Exists(possibleRepoPath)) + if (flatPath.StartsWith(fullBasePath) && Directory.Exists(flatPath)) { - return possibleRepoPath; + return flatPath; + } + + // LexBox/manage-API layout nests repos one level under their first character (e.g. + // {root}/s/sample-hg-repo, see RepoManageService.PrefixRepoFilePath). Repos LexBox + // provisions via /api/manage live there, so check it too before giving up. + var nestedPath = Path.Combine(fullBasePath, repoId[0].ToString(), repoId); + if (nestedPath.StartsWith(fullBasePath) && Directory.Exists(nestedPath)) + { + return nestedPath; } } return ""; diff --git a/csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs b/csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs index 45ec78d..f94f678 100644 --- a/csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs +++ b/csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs @@ -165,7 +165,31 @@ private static async Task> FinishReset( max.MaxRequestBodySize = null; } - await repos.FinishReset(projectCode, request.Body, cancellationToken); + // ZipArchive needs a seekable stream (to read the central directory), and request.Body is + // neither seekable nor safe to read synchronously, so buffer it first. Repo zips can be + // large, so buffer to a temp file rather than to memory. + var zipPath = Path.Combine(Path.GetTempPath(), $"hgresume-finish-reset-{Guid.NewGuid():N}.zip"); + try + { + await using (var zipBuffer = new FileStream( + zipPath, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await request.Body.CopyToAsync(zipBuffer, cancellationToken); + zipBuffer.Position = 0; + + await repos.FinishReset(projectCode, zipBuffer, cancellationToken); + } + } + finally + { + File.Delete(zipPath); + } + return TypedResults.NoContent(); } diff --git a/csharp/test/HgResume.HttpTests/HgResume.HttpTests.csproj b/csharp/test/HgResume.HttpTests/HgResume.HttpTests.csproj deleted file mode 100644 index cdbbea2..0000000 --- a/csharp/test/HgResume.HttpTests/HgResume.HttpTests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - - - - - - - - - diff --git a/csharp/test/HgResume.HttpTests/ServerFixture.cs b/csharp/test/HgResume.HttpTests/ServerFixture.cs deleted file mode 100644 index 0a2f058..0000000 --- a/csharp/test/HgResume.HttpTests/ServerFixture.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System.Diagnostics; -using System.IO.Compression; -using Xunit; - -namespace HgResume.HttpTests; - -/// -/// Shared fixture that runs the hgresume C# image in a container (via podman) and drives it over HTTP. -/// It seeds Mercurial repos into the repo volume by extracting fixtures on the host and -/// podman cp-ing them in, so the production image does not need unzip. -/// -/// Environment overrides: -/// HGRESUME_PODMAN container CLI (default "podman") -/// HGRESUME_IMAGE image to run (default "hgresume-csharp:test") -/// HGRESUME_PORT host port to publish (default "8034") -/// HGRESUME_SKIP_BUILD if set, do not build the image (assume it exists) -/// HGRESUME_BASE_URL reuse an already-running server at this URL (with HGRESUME_CONTAINER) -/// HGRESUME_CONTAINER name of the already-running container to exec/cp against -/// HGRESUME_KEEP if set, do not stop/remove the container on teardown -/// -public sealed class ServerFixture : IAsyncLifetime -{ - private readonly string _podman = Env("HGRESUME_PODMAN", "podman"); - private readonly string _image = Env("HGRESUME_IMAGE", "hgresume-csharp:test"); - private readonly string _port = Env("HGRESUME_PORT", "8034"); - private readonly string _dataDir = Path.Combine(AppContext.BaseDirectory, "data"); - // Set HGRESUME_REPO_OWNER (e.g. "www-data") to chown seeded repos when the server runs as a - // non-root user (the PHP/Apache reference image). Empty = leave ownership as-is (C# runs as root). - private readonly string _repoOwner = Env("HGRESUME_REPO_OWNER", ""); - // Where the server looks for the maintenance file. C# default is under the cache dir; the PHP app - // looks in its src dir (SourcePath . "/maintenance_message.txt"). - private readonly string _maintPath = Env("HGRESUME_MAINT_PATH", "/var/cache/hgresume/maintenance_message.txt"); - - private bool _startedByUs; - - public string ContainerName { get; private set; } = ""; - public string BaseUrl { get; private set; } = ""; - public ApiClient Client { get; private set; } = default!; - - public async Task InitializeAsync() - { - string? reuseUrl = Environment.GetEnvironmentVariable("HGRESUME_BASE_URL"); - if (!string.IsNullOrWhiteSpace(reuseUrl)) - { - BaseUrl = reuseUrl; - ContainerName = Env("HGRESUME_CONTAINER", "hgresumable"); - } - else - { - if (Environment.GetEnvironmentVariable("HGRESUME_SKIP_BUILD") is null) - { - string context = FindContextDir(); - Run(_podman, "build", "-t", _image, "-f", Path.Combine(context, "Dockerfile"), context); - } - - ContainerName = "hgresume-test-" + Environment.ProcessId; - // Clean up a stale container with the same name, if any. - TryRun(_podman, "rm", "-f", ContainerName); - Run(_podman, "run", "-d", "--name", ContainerName, "-p", $"{_port}:80", - "-e", "HGRESUME_MANAGE_SECRET=test-secret", _image); - _startedByUs = true; - BaseUrl = $"http://localhost:{_port}"; - } - - Client = new ApiClient(BaseUrl); - await WaitForReadyAsync(); - } - - public Task DisposeAsync() - { - if (_startedByUs && Environment.GetEnvironmentVariable("HGRESUME_KEEP") is null) - { - TryRun(_podman, "logs", ContainerName); // surfaced in test output on failures - TryRun(_podman, "rm", "-f", ContainerName); - } - return Task.CompletedTask; - } - - private async Task WaitForReadyAsync() - { - var deadline = DateTime.UtcNow.AddSeconds(90); - Exception? last = null; - while (DateTime.UtcNow < deadline) - { - try - { - var r = Client.IsAvailable(); - if ((int)r.Http == 200) return; - } - catch (Exception e) - { - last = e; - } - await Task.Delay(500); - } - throw new Exception($"Server at {BaseUrl} did not become ready in time. Last error: {last?.Message}"); - } - - // ---- repo/maintenance seeding --------------------------------------------------------------- - - /// Extracts a fixture repo zip on the host into /var/vcs/public/<repoId>. Returns the repoId. - public string SeedRepo(string zipName, string? repoId = null) - { - repoId ??= Path.GetFileNameWithoutExtension(zipName); - string localZip = Path.Combine(_dataDir, zipName); - if (!File.Exists(localZip)) throw new FileNotFoundException($"fixture not found: {localZip}"); - - string extractDir = Path.Combine(Path.GetTempPath(), "hgresume-seed-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(extractDir); - ZipFile.ExtractToDirectory(localZip, extractDir); - - Exec($"rm -rf /var/vcs/public/{repoId}"); - Run(_podman, "cp", extractDir, $"{ContainerName}:/var/vcs/public/{repoId}"); - if (!string.IsNullOrEmpty(_repoOwner)) - { - Exec($"chown -R {_repoOwner}:{_repoOwner} /var/vcs/public/{repoId}"); - } - } - finally - { - try { Directory.Delete(extractDir, recursive: true); } - catch { /* best-effort temp cleanup */ } - } - return repoId; - } - - public void RemoveRepo(string repoId) => Exec($"rm -rf /var/vcs/public/{repoId}"); - - /// Adds and commits a file into the given repo (mirrors the PHP addAndCheckInFile helper). - public void AddAndCommit(string repoId, string filename, string content) - { - string cmd = $"cd /var/vcs/public/{repoId} && printf '%s' '{content}' > {filename} && " + - $"hg --config ui.username=system add {filename} && " + - $"hg --config ui.username=system commit -m 'added {filename}'"; - if (!string.IsNullOrEmpty(_repoOwner)) - { - cmd += $" && chown -R {_repoOwner}:{_repoOwner} /var/vcs/public/{repoId}"; - } - Exec(cmd); - } - - public void SetMaintenance(string message) - => Exec($"printf '%s' '{message}' > {_maintPath}"); - - public void ClearMaintenance() - => Exec($"rm -f {_maintPath}"); - - public void Exec(string shellCommand) - => Run(_podman, "exec", ContainerName, "sh", "-lc", shellCommand); - - public byte[] Fixture(string name) => File.ReadAllBytes(Path.Combine(_dataDir, name)); - - public string FixtureText(string name) => File.ReadAllText(Path.Combine(_dataDir, name)).Trim(); - - // ---- process helpers ------------------------------------------------------------------------ - - private static string FindContextDir() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null) - { - if (File.Exists(Path.Combine(dir.FullName, "Dockerfile")) && - Directory.Exists(Path.Combine(dir.FullName, "src"))) - { - return dir.FullName; - } - dir = dir.Parent; - } - throw new Exception("could not locate csharp/ context dir (with Dockerfile) above the test output"); - } - - private static (int Code, string Out, string Err) Run(string exe, params string[] args) - { - var (code, so, se) = TryRun(exe, args); - if (code != 0) - { - throw new Exception($"`{exe} {string.Join(' ', args)}` failed ({code}).\nstdout:\n{so}\nstderr:\n{se}"); - } - return (code, so, se); - } - - private static (int Code, string Out, string Err) TryRun(string exe, params string[] args) - { - var psi = new ProcessStartInfo - { - FileName = exe, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - foreach (var a in args) psi.ArgumentList.Add(a); - - using var proc = Process.Start(psi)!; - var so = proc.StandardOutput.ReadToEndAsync(); - var se = proc.StandardError.ReadToEndAsync(); - proc.WaitForExit(); - return (proc.ExitCode, so.GetAwaiter().GetResult(), se.GetAwaiter().GetResult()); - } - - private static string Env(string name, string fallback) - { - string? v = Environment.GetEnvironmentVariable(name); - return string.IsNullOrWhiteSpace(v) ? fallback : v; - } -} - -[CollectionDefinition("server")] -public sealed class ServerCollection : ICollectionFixture -{ -} diff --git a/csharp/test/HgResume.HttpTests/ApiClient.cs b/csharp/test/HgResume.IntegrationTests/ApiClient.cs similarity index 99% rename from csharp/test/HgResume.HttpTests/ApiClient.cs rename to csharp/test/HgResume.IntegrationTests/ApiClient.cs index 0f5b688..170bb91 100644 --- a/csharp/test/HgResume.HttpTests/ApiClient.cs +++ b/csharp/test/HgResume.IntegrationTests/ApiClient.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text; -namespace HgResume.HttpTests; +namespace HgResume.IntegrationTests; /// Parsed HTTP response, exposing the X-HgR-* protocol headers the Chorus client reads. public sealed record ApiResponse(HttpStatusCode Http, IReadOnlyDictionary Headers, byte[] Content) diff --git a/csharp/test/HgResume.HttpTests/AssemblyInfo.cs b/csharp/test/HgResume.IntegrationTests/AssemblyInfo.cs similarity index 100% rename from csharp/test/HgResume.HttpTests/AssemblyInfo.cs rename to csharp/test/HgResume.IntegrationTests/AssemblyInfo.cs diff --git a/csharp/test/HgResume.HttpTests/ContractFacts.cs b/csharp/test/HgResume.IntegrationTests/ContractFacts.cs similarity index 95% rename from csharp/test/HgResume.HttpTests/ContractFacts.cs rename to csharp/test/HgResume.IntegrationTests/ContractFacts.cs index 0a3cd1b..ea69658 100644 --- a/csharp/test/HgResume.HttpTests/ContractFacts.cs +++ b/csharp/test/HgResume.IntegrationTests/ContractFacts.cs @@ -2,7 +2,7 @@ using System.Text; using Xunit; -namespace HgResume.HttpTests; +namespace HgResume.IntegrationTests; /// /// Wire-contract regression tests that the higher-level ApiClient (which uses HttpClient) cannot catch, @@ -21,11 +21,11 @@ public sealed class ContractFacts [Fact] public void ResponseWithBody_SetsContentLength_AndIsNotChunked() { - _fx.SeedRepo("sampleHgRepo2.zip"); + _fx.SeedRepo("sample-hg-repo2.zip"); var uri = new Uri(_fx.BaseUrl); string raw = RawGet(uri.Host, uri.Port, - "/api/v03/getRevisions?offset=0&quantity=50&repoId=sampleHgRepo2"); + "/api/v03/getRevisions?offset=0&quantity=50&repoId=sample-hg-repo2"); int sep = raw.IndexOf("\r\n\r\n", StringComparison.Ordinal); Assert.True(sep > 0, "malformed HTTP response: no header/body separator"); diff --git a/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj b/csharp/test/HgResume.IntegrationTests/HgResume.IntegrationTests.csproj similarity index 72% rename from csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj rename to csharp/test/HgResume.IntegrationTests/HgResume.IntegrationTests.csproj index 7d85425..6423148 100644 --- a/csharp/test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj +++ b/csharp/test/HgResume.IntegrationTests/HgResume.IntegrationTests.csproj @@ -1,15 +1,19 @@ net10.0 @@ -23,6 +27,7 @@ + @@ -36,6 +41,7 @@ + diff --git a/csharp/test/HgResume.SendReceiveTests/MercurialService.cs b/csharp/test/HgResume.IntegrationTests/MercurialService.cs similarity index 99% rename from csharp/test/HgResume.SendReceiveTests/MercurialService.cs rename to csharp/test/HgResume.IntegrationTests/MercurialService.cs index bcf7267..ba3f849 100644 --- a/csharp/test/HgResume.SendReceiveTests/MercurialService.cs +++ b/csharp/test/HgResume.IntegrationTests/MercurialService.cs @@ -4,7 +4,7 @@ using SIL.Progress; using Xunit.Abstractions; -namespace HgResume.SendReceiveTests; +namespace HgResume.IntegrationTests; /// /// Drives the REAL Chorus resumable client (HgResumeTransport, via HgRepository) against our hgresume diff --git a/csharp/test/HgResume.HttpTests/MiscFacts.cs b/csharp/test/HgResume.IntegrationTests/MiscFacts.cs similarity index 90% rename from csharp/test/HgResume.HttpTests/MiscFacts.cs rename to csharp/test/HgResume.IntegrationTests/MiscFacts.cs index c06ec64..7d05302 100644 --- a/csharp/test/HgResume.HttpTests/MiscFacts.cs +++ b/csharp/test/HgResume.IntegrationTests/MiscFacts.cs @@ -1,7 +1,7 @@ using System.Text; using Xunit; -namespace HgResume.HttpTests; +namespace HgResume.IntegrationTests; /// getRevisions, availability/maintenance, and wire-contract smoke checks. [Collection("server")] @@ -15,8 +15,8 @@ public sealed class MiscFacts [Fact] public void GetRevisions_2BranchRepo_ReturnsTwoBranches() { - _fx.SeedRepo("sample2branchHgRepo.zip"); - var r = Api.GetRevisions("sample2branchHgRepo", 0, 50); + _fx.SeedRepo("sample2branch-hg-repo.zip"); + var r = Api.GetRevisions("sample2branch-hg-repo", 0, 50); Assert.Equal("SUCCESS", r.Status); var branches = new HashSet(); @@ -32,7 +32,7 @@ public void GetRevisions_2BranchRepo_ReturnsTwoBranches() public void GetRevisions_SubDir_Works() { _fx.Exec("mkdir -p /var/vcs/public/s"); - var repoId = _fx.SeedRepo("sampleHgRepo.zip", "s/sampleHgRepo"); + var repoId = _fx.SeedRepo("sample-hg-repo.zip", "s/sample-hg-repo"); var r = Api.GetRevisions(repoId, 0, 50); Assert.Equal("SUCCESS", r.Status); } @@ -40,19 +40,19 @@ public void GetRevisions_SubDir_Works() [Fact] public void GetRevisions_BogusId_UnknownCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); var r = Api.GetRevisions("fakeid", 0, 50); Assert.Equal("UNKNOWNID", r.Status); } [Theory] - [InlineData("../sampleHgRepo")] + [InlineData("../sample-hg-repo")] [InlineData("..")] [InlineData("foo/bar")] public void GetRevisions_PathTraversalRepoId_UnknownCode(string repoId) { // Even when a real repo exists under the search root, path segments must not escape it. - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); var r = Api.GetRevisions(repoId, 0, 50); Assert.Equal("UNKNOWNID", r.Status); } @@ -62,8 +62,8 @@ public void GetRevisions_PathTraversalRepoId_UnknownCode(string repoId) [Fact] public void PushBundleChunk_PathTraversalRepoId_UnknownCode() { - _fx.SeedRepo("sampleHgRepo.zip"); - var r = Api.PushBundleChunk("../sampleHgRepo", 10000, 0, + _fx.SeedRepo("sample-hg-repo.zip"); + var r = Api.PushBundleChunk("../sample-hg-repo", 10000, 0, Encoding.UTF8.GetBytes("chunkData"), nameof(PushBundleChunk_PathTraversalRepoId_UnknownCode)); Assert.Equal("UNKNOWNID", r.Status); } diff --git a/csharp/test/HgResume.SendReceiveTests/ModifyProjectHelper.cs b/csharp/test/HgResume.IntegrationTests/ModifyProjectHelper.cs similarity index 97% rename from csharp/test/HgResume.SendReceiveTests/ModifyProjectHelper.cs rename to csharp/test/HgResume.IntegrationTests/ModifyProjectHelper.cs index b0cb1d1..3ff6ed4 100644 --- a/csharp/test/HgResume.SendReceiveTests/ModifyProjectHelper.cs +++ b/csharp/test/HgResume.IntegrationTests/ModifyProjectHelper.cs @@ -1,4 +1,4 @@ -namespace HgResume.SendReceiveTests; +namespace HgResume.IntegrationTests; // Verbatim from LexBox backend/Testing/Services/ModifyProjectHelper.cs — byte-patches the fwdata's // DateModified field so a subsequent send/receive has a real change to transfer. diff --git a/csharp/test/HgResume.HttpTests/Protocol.cs b/csharp/test/HgResume.IntegrationTests/Protocol.cs similarity index 99% rename from csharp/test/HgResume.HttpTests/Protocol.cs rename to csharp/test/HgResume.IntegrationTests/Protocol.cs index 3e358a1..7ec9137 100644 --- a/csharp/test/HgResume.HttpTests/Protocol.cs +++ b/csharp/test/HgResume.IntegrationTests/Protocol.cs @@ -1,4 +1,4 @@ -namespace HgResume.HttpTests; +namespace HgResume.IntegrationTests; /// /// Client-side push/pull loops that mirror how Chorus's HgResumeTransport drives the protocol, diff --git a/csharp/test/HgResume.HttpTests/PullFacts.cs b/csharp/test/HgResume.IntegrationTests/PullFacts.cs similarity index 79% rename from csharp/test/HgResume.HttpTests/PullFacts.cs rename to csharp/test/HgResume.IntegrationTests/PullFacts.cs index 9846bd6..a0cbd91 100644 --- a/csharp/test/HgResume.HttpTests/PullFacts.cs +++ b/csharp/test/HgResume.IntegrationTests/PullFacts.cs @@ -1,6 +1,6 @@ using Xunit; -namespace HgResume.HttpTests; +namespace HgResume.IntegrationTests; /// HTTP-level ports of the pull cases in api/test/HgResumeApi_Test.php. [Collection("server")] @@ -14,7 +14,7 @@ public sealed class PullFacts [Fact] public void PullBundleChunk_EmptyId_UnknownCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PullBundleChunk_EmptyId_UnknownCode); Api.FinishPullBundle(tx); var r = Api.PullBundleChunk("", new[] { "" }, 0, 50, tx); @@ -24,7 +24,7 @@ public void PullBundleChunk_EmptyId_UnknownCode() [Fact] public void PullBundleChunk_BogusId_UnknownCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PullBundleChunk_BogusId_UnknownCode); Api.FinishPullBundle(tx); var r = Api.PullBundleChunk("fakeid", new[] { "" }, 0, 50, tx); @@ -34,32 +34,32 @@ public void PullBundleChunk_BogusId_UnknownCode() [Fact] public void PullBundleChunk_InvalidHash_FailCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PullBundleChunk_InvalidHash_FailCode); Api.FinishPullBundle(tx); - var r = Api.PullBundleChunk("sampleHgRepo", new[] { "fakehash" }, 0, 50, tx); + var r = Api.PullBundleChunk("sample-hg-repo", new[] { "fakehash" }, 0, 50, tx); Assert.Equal("FAIL", r.Status); } [Fact] public void PullBundleChunk_ValidRequestButNoChanges_NoChangeCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PullBundleChunk_ValidRequestButNoChanges_NoChangeCode); Api.FinishPullBundle(tx); string hash = _fx.FixtureText("sample.bundle.hash"); - var r = Api.PullBundleChunk("sampleHgRepo", new[] { hash }, 0, 50, tx); + var r = Api.PullBundleChunk("sample-hg-repo", new[] { hash }, 0, 50, tx); Assert.Equal("NOCHANGE", r.Status); } [Fact] public void PullBundleChunk_OffsetZero_ValidData() { - _fx.SeedRepo("sampleHgRepo2.zip"); + _fx.SeedRepo("sample-hg-repo2.zip"); string tx = nameof(PullBundleChunk_OffsetZero_ValidData); Api.FinishPullBundle(tx); string hash = _fx.FixtureText("sample.bundle.hash"); - var r = Protocol.PullFirstChunk(Api, "sampleHgRepo2", new[] { hash }, 0, 50, tx); + var r = Protocol.PullFirstChunk(Api, "sample-hg-repo2", new[] { hash }, 0, 50, tx); Assert.Equal("SUCCESS", r.Status); Assert.Equal(50, r.HgrInt("chunkSize")); Assert.Equal(_fx.Fixture("sample.bundle").Length, r.HgrInt("bundleSize")); @@ -68,28 +68,28 @@ public void PullBundleChunk_OffsetZero_ValidData() [Fact] public void PullBundleChunk_OffsetGreaterThanZeroAndNoBundleCreated_ResetResponse() { - _fx.SeedRepo("sampleHgRepo2.zip"); + _fx.SeedRepo("sample-hg-repo2.zip"); string tx = nameof(PullBundleChunk_OffsetGreaterThanZeroAndNoBundleCreated_ResetResponse); Api.FinishPullBundle(tx); string hash = _fx.FixtureText("sample.bundle.hash"); - var r = Api.PullBundleChunk("sampleHgRepo2", new[] { hash }, 50, 50, tx); + var r = Api.PullBundleChunk("sample-hg-repo2", new[] { hash }, 50, 50, tx); Assert.Equal("RESET", r.Status); } [Fact] public void PullBundleChunk_OffsetEqualToBundleSize_SuccessCodeWithZeroChunkSize() { - _fx.SeedRepo("sampleHgRepo2.zip"); + _fx.SeedRepo("sample-hg-repo2.zip"); string tx = nameof(PullBundleChunk_OffsetEqualToBundleSize_SuccessCodeWithZeroChunkSize); Api.FinishPullBundle(tx); string hash = _fx.FixtureText("sample.bundle.hash"); - var first = Protocol.PullFirstChunk(Api, "sampleHgRepo2", new[] { hash }, 0, 50, tx); + var first = Protocol.PullFirstChunk(Api, "sample-hg-repo2", new[] { hash }, 0, 50, tx); Assert.Equal("SUCCESS", first.Status); int bundleSize = first.HgrInt("bundleSize"); // At offset == bundleSize the response is SUCCESS only once the transaction has flipped from the // Bundle to the Downloading state; until then the server returns INPROGRESS (as the real client // polls through). Poll rather than asserting SUCCESS on the first request, which is timing-flaky. - var r = Protocol.PullFirstChunk(Api, "sampleHgRepo2", new[] { hash }, bundleSize, 1000, tx); + var r = Protocol.PullFirstChunk(Api, "sample-hg-repo2", new[] { hash }, bundleSize, 1000, tx); Assert.Equal("SUCCESS", r.Status); Assert.Equal(0, r.HgrInt("chunkSize")); Assert.Empty(r.Content); @@ -98,64 +98,64 @@ public void PullBundleChunk_OffsetEqualToBundleSize_SuccessCodeWithZeroChunkSize [Fact] public void PullBundleChunk_PullUntilFinished_AssembledBundleIsValid() { - _fx.SeedRepo("sampleHgRepo2.zip"); + _fx.SeedRepo("sample-hg-repo2.zip"); string tx = nameof(PullBundleChunk_PullUntilFinished_AssembledBundleIsValid); Api.FinishPullBundle(tx); string hash = _fx.FixtureText("sample.bundle.hash"); - var (assembled, _) = Protocol.PullEntireBundle(Api, "sampleHgRepo2", new[] { hash }, tx); + var (assembled, _) = Protocol.PullEntireBundle(Api, "sample-hg-repo2", new[] { hash }, tx); Assert.Equal(_fx.Fixture("sample.bundle"), assembled); } [Fact] public void PullBundleChunk_PullFromBaseRevisionUntilFinishedOnTwoBranchRepo_AssembledBundleIsValid() { - _fx.SeedRepo("sample2branchHgRepo.zip"); + _fx.SeedRepo("sample2branch-hg-repo.zip"); string tx = nameof(PullBundleChunk_PullFromBaseRevisionUntilFinishedOnTwoBranchRepo_AssembledBundleIsValid); Api.FinishPullBundle(tx); string hash = _fx.FixtureText("sample2branch.hash"); - var (assembled, _) = Protocol.PullEntireBundle(Api, "sample2branchHgRepo", new[] { hash }, tx); + var (assembled, _) = Protocol.PullEntireBundle(Api, "sample2branch-hg-repo", new[] { hash }, tx); Assert.Equal(_fx.Fixture("sample2branch.bundle"), assembled); } [Fact] public void PullBundleChunk_PullFromTwoBaseRevisionsUntilFinishedOnTwoBranchRepo_AssembledBundleIsValid() { - _fx.SeedRepo("sample2branchHgRepo.zip"); + _fx.SeedRepo("sample2branch-hg-repo.zip"); string tx = nameof(PullBundleChunk_PullFromTwoBaseRevisionsUntilFinishedOnTwoBranchRepo_AssembledBundleIsValid); Api.FinishPullBundle(tx); var hashes = _fx.FixtureText("sample2branch2base.hash").Split('|'); - var (assembled, _) = Protocol.PullEntireBundle(Api, "sample2branchHgRepo", hashes, tx); + var (assembled, _) = Protocol.PullEntireBundle(Api, "sample2branch-hg-repo", hashes, tx); Assert.Equal(_fx.Fixture("sample2branch2base.bundle"), assembled); } [Fact] public void PullBundleChunk_2BranchRepoNoChanges_ReturnsNoChange() { - _fx.SeedRepo("sample2branchHgRepo.zip"); + _fx.SeedRepo("sample2branch-hg-repo.zip"); string tx = nameof(PullBundleChunk_2BranchRepoNoChanges_ReturnsNoChange); Api.FinishPullBundle(tx); var hashes = _fx.FixtureText("sample2branch2tip.hash").Split('|'); - var r = Api.PullBundleChunk("sample2branchHgRepo", hashes, 0, 500, tx); + var r = Api.PullBundleChunk("sample2branch-hg-repo", hashes, 0, 500, tx); Assert.Equal("NOCHANGE", r.Status); } [Fact] public void PullBundleChunk_BaseHashIsZero_ReturnsEntireRepoAsBundle() { - _fx.SeedRepo("sampleHgRepo2.zip"); + _fx.SeedRepo("sample-hg-repo2.zip"); string tx = nameof(PullBundleChunk_BaseHashIsZero_ReturnsEntireRepoAsBundle); Api.FinishPullBundle(tx); - var (assembled, _) = Protocol.PullEntireBundle(Api, "sampleHgRepo2", new[] { "0" }, tx); + var (assembled, _) = Protocol.PullEntireBundle(Api, "sample-hg-repo2", new[] { "0" }, tx); Assert.Equal(_fx.Fixture("sample_entire.bundle"), assembled); } [Fact] public void PullBundleChunk_EmptyRepositoryReturnsNoChanges() { - _fx.SeedRepo("emptyHgRepo.zip"); + _fx.SeedRepo("empty-hg-repo.zip"); string tx = nameof(PullBundleChunk_EmptyRepositoryReturnsNoChanges); Api.FinishPullBundle(tx); - var r = Api.PullBundleChunk("emptyHgRepo", new[] { "0" }, 0, 50, tx); + var r = Api.PullBundleChunk("empty-hg-repo", new[] { "0" }, 0, 50, tx); Assert.Equal("NOCHANGE", r.Status); } @@ -167,13 +167,13 @@ public async Task PullBundleChunk_EmptyRepoWithNonZeroBaseHash_FailsWithoutHangi // regardless of offset, so the hash is never found and IsValidBase keeps advancing the offset and // re-querying forever, hanging the request. Contrast with PullBundleChunk_EmptyRepositoryReturnsNoChanges, // which passes baseHash "0" and short-circuits before the loop. - _fx.SeedRepo("emptyHgRepo.zip"); + _fx.SeedRepo("empty-hg-repo.zip"); string tx = nameof(PullBundleChunk_EmptyRepoWithNonZeroBaseHash_FailsWithoutHanging); Api.FinishPullBundle(tx); // Run on a background task with a timeout so the bug surfaces as a fast, clear failure rather than // hanging until the HTTP client's 120s timeout (or forever, once the fix removes that safety net). - var call = Task.Run(() => Api.PullBundleChunk("emptyHgRepo", new[] { "fakehash" }, 0, 50, tx)); + var call = Task.Run(() => Api.PullBundleChunk("empty-hg-repo", new[] { "fakehash" }, 0, 50, tx)); var finished = await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(30))); Assert.True(finished == call, "PullBundleChunk against an empty repo with a non-zero baseHash did not return within 30s — " + @@ -187,17 +187,17 @@ public async Task PullBundleChunk_EmptyRepoWithNonZeroBaseHash_FailsWithoutHangi [Fact] public async Task PullBundleChunk_NonEmptyRepoMissingHashAcrossPages_FailsWithoutHanging() { - // manyRevsHgRepo has 205 revisions, more than IsValidBase's page size (q = 200). A baseHash that + // many-revs-hg-repo has 205 revisions, more than IsValidBase's page size (q = 200). A baseHash that // does not exist forces the pagination loop past the first full page (offset 0 -> 200) and onto a // short final page, exercising the offset-advancement + short-page-break branch that the // single-page PullBundleChunk_InvalidHash_FailCode test never reaches. It must terminate with FAIL // rather than paging forever. - _fx.SeedRepo("manyRevsHgRepo.zip"); + _fx.SeedRepo("many-revs-hg-repo.zip"); string tx = nameof(PullBundleChunk_NonEmptyRepoMissingHashAcrossPages_FailsWithoutHanging); Api.FinishPullBundle(tx); // Guard with a timeout so a non-terminating loop surfaces as a fast, clear failure. - var call = Task.Run(() => Api.PullBundleChunk("manyRevsHgRepo", new[] { "ffffffffffff" }, 0, 50, tx)); + var call = Task.Run(() => Api.PullBundleChunk("many-revs-hg-repo", new[] { "ffffffffffff" }, 0, 50, tx)); var finished = await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(30))); Assert.True(finished == call, "PullBundleChunk against a >200-revision repo with a missing baseHash did not return within 30s — " + @@ -210,17 +210,17 @@ public async Task PullBundleChunk_NonEmptyRepoMissingHashAcrossPages_FailsWithou [Fact] public void PullBundleChunk_LongMakeBundle_InProgressCode() { - _fx.SeedRepo("sampleLargeBundleHgRepo.zip"); + _fx.SeedRepo("sample-large-bundle-hg-repo.zip"); string tx = nameof(PullBundleChunk_LongMakeBundle_InProgressCode); Api.FinishPullBundle(tx); - var r = Api.PullBundleChunk("sampleLargeBundleHgRepo", new[] { "0" }, 0, 10000000, tx); + var r = Api.PullBundleChunk("sample-large-bundle-hg-repo", new[] { "0" }, 0, 10000000, tx); Assert.Equal("INPROGRESS", r.Status); } [Fact] public void PullBundleChunk_PullUntilFinishedThenRepoChanges_ResetReceivedFromFinishPullBundle() { - _fx.SeedRepo("sampleHgRepo2.zip"); + _fx.SeedRepo("sample-hg-repo2.zip"); string tx = nameof(PullBundleChunk_PullUntilFinishedThenRepoChanges_ResetReceivedFromFinishPullBundle); Api.FinishPullBundle(tx); string hash = _fx.FixtureText("sample.bundle.hash"); @@ -234,9 +234,9 @@ public void PullBundleChunk_PullUntilFinishedThenRepoChanges_ResetReceivedFromFi { if (ctr == 3) { - _fx.AddAndCommit("sampleHgRepo2", "fileToAdd.txt", "sample data to add"); + _fx.AddAndCommit("sample-hg-repo2", "fileToAdd.txt", "sample data to add"); } - var r = Protocol.PullFirstChunk(Api, "sampleHgRepo2", new[] { hash }, offset, chunkSize, tx); + var r = Protocol.PullFirstChunk(Api, "sample-hg-repo2", new[] { hash }, offset, chunkSize, tx); Assert.Equal("SUCCESS", r.Status); bundleSize = r.HgrInt("bundleSize"); chunkSize = r.HgrInt("chunkSize") > 0 ? r.HgrInt("chunkSize") : chunkSize; diff --git a/csharp/test/HgResume.HttpTests/PushFacts.cs b/csharp/test/HgResume.IntegrationTests/PushFacts.cs similarity index 62% rename from csharp/test/HgResume.HttpTests/PushFacts.cs rename to csharp/test/HgResume.IntegrationTests/PushFacts.cs index 585f4e1..7f9cc1d 100644 --- a/csharp/test/HgResume.HttpTests/PushFacts.cs +++ b/csharp/test/HgResume.IntegrationTests/PushFacts.cs @@ -1,7 +1,7 @@ using System.Text; using Xunit; -namespace HgResume.HttpTests; +namespace HgResume.IntegrationTests; /// HTTP-level ports of the push cases in api/test/HgResumeApi_Test.php. [Collection("server")] @@ -17,7 +17,7 @@ public sealed class PushFacts [Fact] public void PushBundleChunk_BogusId_UnknownCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); var r = Api.PushBundleChunk("fakeid", 10000, 0, B("chunkData"), nameof(PushBundleChunk_BogusId_UnknownCode)); Assert.Equal("UNKNOWNID", r.Status); } @@ -25,7 +25,7 @@ public void PushBundleChunk_BogusId_UnknownCode() [Fact] public void PushBundleChunk_EmptyId_UnknownCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); var r = Api.PushBundleChunk("", 10000, 0, B("chunkData"), nameof(PushBundleChunk_EmptyId_UnknownCode)); Assert.Equal("UNKNOWNID", r.Status); } @@ -33,77 +33,77 @@ public void PushBundleChunk_EmptyId_UnknownCode() [Fact] public void PushBundleChunk_InvalidOffset_FailCode() { - _fx.SeedRepo("sampleHgRepo.zip"); - var r = Api.PushBundleChunk("sampleHgRepo", 1000, 2000, B("chunkData"), nameof(PushBundleChunk_InvalidOffset_FailCode)); + _fx.SeedRepo("sample-hg-repo.zip"); + var r = Api.PushBundleChunk("sample-hg-repo", 1000, 2000, B("chunkData"), nameof(PushBundleChunk_InvalidOffset_FailCode)); Assert.Equal("FAIL", r.Status); } [Fact] public void PushBundleChunk_NoData_FailCode() { - _fx.SeedRepo("sampleHgRepo.zip"); - var r = Api.PushBundleChunk("sampleHgRepo", 1000, 0, Array.Empty(), nameof(PushBundleChunk_NoData_FailCode)); + _fx.SeedRepo("sample-hg-repo.zip"); + var r = Api.PushBundleChunk("sample-hg-repo", 1000, 0, Array.Empty(), nameof(PushBundleChunk_NoData_FailCode)); Assert.Equal("FAIL", r.Status); } [Fact] public void PushBundleChunk_InvalidBundleSize_FailCode() { - _fx.SeedRepo("sampleHgRepo.zip"); - var r = Api.PushBundleChunkRaw("sampleHgRepo", "invalid", 0, B("someData"), nameof(PushBundleChunk_InvalidBundleSize_FailCode)); + _fx.SeedRepo("sample-hg-repo.zip"); + var r = Api.PushBundleChunkRaw("sample-hg-repo", "invalid", 0, B("someData"), nameof(PushBundleChunk_InvalidBundleSize_FailCode)); Assert.Equal("FAIL", r.Status); } [Fact] public void PushBundleChunk_DataTooLarge_FailCode() { - _fx.SeedRepo("sampleHgRepo.zip"); - var r = Api.PushBundleChunk("sampleHgRepo", 10, 0, B("someDataLargerThan 10 bytes"), nameof(PushBundleChunk_DataTooLarge_FailCode)); + _fx.SeedRepo("sample-hg-repo.zip"); + var r = Api.PushBundleChunk("sample-hg-repo", 10, 0, B("someDataLargerThan 10 bytes"), nameof(PushBundleChunk_DataTooLarge_FailCode)); Assert.Equal("FAIL", r.Status); } [Fact] public void PushBundleChunk_ChunkSent_ReceivedCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PushBundleChunk_ChunkSent_ReceivedCode); Api.FinishPushBundle(tx); - var r = Api.PushBundleChunk("sampleHgRepo", 100, 0, B("someChunkData"), tx); + var r = Api.PushBundleChunk("sample-hg-repo", 100, 0, B("someChunkData"), tx); Assert.Equal("RECEIVED", r.Status); } [Fact] public void PushBundleChunk_AllChunksSent_SuccessCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PushBundleChunk_AllChunksSent_SuccessCode); Api.FinishPushBundle(tx); - var r = Protocol.PushEntireBundle(Api, "sampleHgRepo", tx, _fx.Fixture("sample.bundle")); + var r = Protocol.PushEntireBundle(Api, "sample-hg-repo", tx, _fx.Fixture("sample.bundle")); Assert.Equal("SUCCESS", r.Status); } [Fact] public void PushBundleChunk_AllChunksSentButBadDataChunkSoBundleFails_ResetCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PushBundleChunk_AllChunksSentButBadDataChunkSoBundleFails_ResetCode); Api.FinishPushBundle(tx); - Assert.Equal("RECEIVED", Api.PushBundleChunk("sampleHgRepo", 15, 0, B("12345"), tx).Status); - Assert.Equal("RECEIVED", Api.PushBundleChunk("sampleHgRepo", 15, 5, B("1234"), tx).Status); - Assert.Equal("RECEIVED", Api.PushBundleChunk("sampleHgRepo", 15, 9, B("1234"), tx).Status); - var last = Api.PushBundleChunk("sampleHgRepo", 15, 13, B("12"), tx); - last = Protocol.SettlePush(Api, "sampleHgRepo", tx, B("123451234123412"), last); + Assert.Equal("RECEIVED", Api.PushBundleChunk("sample-hg-repo", 15, 0, B("12345"), tx).Status); + Assert.Equal("RECEIVED", Api.PushBundleChunk("sample-hg-repo", 15, 5, B("1234"), tx).Status); + Assert.Equal("RECEIVED", Api.PushBundleChunk("sample-hg-repo", 15, 9, B("1234"), tx).Status); + var last = Api.PushBundleChunk("sample-hg-repo", 15, 13, B("12"), tx); + last = Protocol.SettlePush(Api, "sample-hg-repo", tx, B("123451234123412"), last); Assert.Equal("RESET", last.Status); } [Fact] public void PushBundleChunk_RequestedOffsetNotEqualToSOW_FailCodeReturnsSOW() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PushBundleChunk_RequestedOffsetNotEqualToSOW_FailCodeReturnsSOW); Api.FinishPushBundle(tx); - Api.PushBundleChunk("sampleHgRepo", 15, 0, B("12345"), tx); - var r = Api.PushBundleChunk("sampleHgRepo", 15, 10, B("12345"), tx); + Api.PushBundleChunk("sample-hg-repo", 15, 0, B("12345"), tx); + var r = Api.PushBundleChunk("sample-hg-repo", 15, 10, B("12345"), tx); Assert.Equal("FAIL", r.Status); Assert.Equal(5, r.HgrInt("sow")); } @@ -111,11 +111,11 @@ public void PushBundleChunk_RequestedOffsetNotEqualToSOW_FailCodeReturnsSOW() [Fact] public void PushBundleChunk_PushWithOffsetZeroButSOWGreaterThanZero_ReceivedCodeReturnsSOW() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PushBundleChunk_PushWithOffsetZeroButSOWGreaterThanZero_ReceivedCodeReturnsSOW); Api.FinishPushBundle(tx); - Api.PushBundleChunk("sampleHgRepo", 15, 0, B("12345"), tx); - var r = Api.PushBundleChunk("sampleHgRepo", 15, 0, B("12"), tx); + Api.PushBundleChunk("sample-hg-repo", 15, 0, B("12345"), tx); + var r = Api.PushBundleChunk("sample-hg-repo", 15, 0, B("12"), tx); Assert.Equal("RECEIVED", r.Status); Assert.Equal(5, r.HgrInt("sow")); } @@ -123,17 +123,17 @@ public void PushBundleChunk_PushWithOffsetZeroButSOWGreaterThanZero_ReceivedCode [Fact] public void PushBundleChunk_InitializedRepoWithZeroChangesets_BundleSuccessfullyApplied() { - _fx.SeedRepo("emptyHgRepo.zip"); + _fx.SeedRepo("empty-hg-repo.zip"); string tx = nameof(PushBundleChunk_InitializedRepoWithZeroChangesets_BundleSuccessfullyApplied); Api.FinishPushBundle(tx); - var r = Protocol.PushEntireBundle(Api, "emptyHgRepo", tx, _fx.Fixture("sample_entire.bundle")); + var r = Protocol.PushEntireBundle(Api, "empty-hg-repo", tx, _fx.Fixture("sample_entire.bundle")); Assert.Equal("SUCCESS", r.Status); } [Fact] public void PushBundleChunk_PushOneChunkThenRepoChanges_PushContinuesSuccessfully() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PushBundleChunk_PushOneChunkThenRepoChanges_PushContinuesSuccessfully); Api.FinishPushBundle(tx); @@ -146,10 +146,10 @@ public void PushBundleChunk_PushOneChunkThenRepoChanges_PushContinuesSuccessfull { if (sow >= 50 && !changed) { - _fx.AddAndCommit("sampleHgRepo", "fileToAdd.txt", "sample data to add"); + _fx.AddAndCommit("sample-hg-repo", "fileToAdd.txt", "sample data to add"); changed = true; } - r = Api.PushBundleChunk("sampleHgRepo", bundleSize, sow, Protocol.Slice(bundle, sow, 50), tx); + r = Api.PushBundleChunk("sample-hg-repo", bundleSize, sow, Protocol.Slice(bundle, sow, 50), tx); if ((int)r.Http == 200) break; if ((int)r.Http == 202) { sow = r.HgrInt("sow"); if (r.Status == "INPROGRESS") Thread.Sleep(300); continue; } break; @@ -162,24 +162,24 @@ public void PushBundleChunk_PushOneChunkThenRepoChanges_PushContinuesSuccessfull [Fact] public void PushBundleChunk_UnrelatedRepo1_FailCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PushBundleChunk_UnrelatedRepo1_FailCode); Api.FinishPushBundle(tx); byte[] bundle = _fx.Fixture("unrelated.bundle"); - var r = Api.PushBundleChunk("sampleHgRepo", bundle.Length, 0, bundle, tx); - r = Protocol.SettlePush(Api, "sampleHgRepo", tx, bundle, r); + var r = Api.PushBundleChunk("sample-hg-repo", bundle.Length, 0, bundle, tx); + r = Protocol.SettlePush(Api, "sample-hg-repo", tx, bundle, r); Assert.Equal("FAIL", r.Status); } [Fact] public void PushBundleChunk_UnrelatedRepo2_FailCode() { - _fx.SeedRepo("sampleHgRepo.zip"); + _fx.SeedRepo("sample-hg-repo.zip"); string tx = nameof(PushBundleChunk_UnrelatedRepo2_FailCode); Api.FinishPushBundle(tx); byte[] bundle = _fx.Fixture("unrelated2.bundle"); - var r = Api.PushBundleChunk("sampleHgRepo", bundle.Length, 0, bundle, tx); - r = Protocol.SettlePush(Api, "sampleHgRepo", tx, bundle, r); + var r = Api.PushBundleChunk("sample-hg-repo", bundle.Length, 0, bundle, tx); + r = Protocol.SettlePush(Api, "sample-hg-repo", tx, bundle, r); Assert.Equal("FAIL", r.Status); } } diff --git a/csharp/test/HgResume.SendReceiveTests/SendReceiveModels.cs b/csharp/test/HgResume.IntegrationTests/SendReceiveModels.cs similarity index 95% rename from csharp/test/HgResume.SendReceiveTests/SendReceiveModels.cs rename to csharp/test/HgResume.IntegrationTests/SendReceiveModels.cs index a78a572..1d19b4d 100644 --- a/csharp/test/HgResume.SendReceiveTests/SendReceiveModels.cs +++ b/csharp/test/HgResume.IntegrationTests/SendReceiveModels.cs @@ -1,4 +1,4 @@ -namespace HgResume.SendReceiveTests; +namespace HgResume.IntegrationTests; // Trimmed copies of the LexBox test models (backend/Testing/Services), with the LexBox server/API // coupling removed. Only the Resumable protocol is relevant here (that's what our hgresume serves). diff --git a/csharp/test/HgResume.SendReceiveTests/SendReceiveTests.cs b/csharp/test/HgResume.IntegrationTests/SendReceiveTests.cs similarity index 93% rename from csharp/test/HgResume.SendReceiveTests/SendReceiveTests.cs rename to csharp/test/HgResume.IntegrationTests/SendReceiveTests.cs index df7f5a3..7df88c2 100644 --- a/csharp/test/HgResume.SendReceiveTests/SendReceiveTests.cs +++ b/csharp/test/HgResume.IntegrationTests/SendReceiveTests.cs @@ -5,24 +5,24 @@ using Xunit; using Xunit.Abstractions; -namespace HgResume.SendReceiveTests; +namespace HgResume.IntegrationTests; /// /// The main LexBox e2e send/receive tests (SendReceiveServiceTests.cs) adapted to run against our C# /// hgresume container using the real Chorus resumable client. Auth/reset/hgweb-only cases are dropped; /// LexBox project registration is replaced by `hg init` in the container. /// -[Collection("hgresume-server")] +[Collection("server")] public class SendReceiveTests { private static readonly string BasePath = Path.Join(Path.GetTempPath(), "hgresume_sr_tests"); private static readonly SendReceiveAuth Auth = new("test", "test"); // server ignores auth private readonly ITestOutputHelper _output; - private readonly HgResumeServerFixture _server; + private readonly ServerFixture _server; private readonly MercurialService _sr; - public SendReceiveTests(ITestOutputHelper output, HgResumeServerFixture server) + public SendReceiveTests(ITestOutputHelper output, ServerFixture server) { _output = output; _server = server; @@ -37,7 +37,7 @@ public async Task ModifyProjectData(HgProtocol protocol) var project = InitLocalFlexProjectWithRepo(code); _server.InitServerRepo(code); - var srp = new SendReceiveParams(protocol, _server.BaseUrl, project); + var srp = new SendReceiveParams(protocol, _server.HostPort, project); // Push the fresh project to the server _sr.SendReceiveProject(srp, Auth); @@ -67,14 +67,14 @@ public async Task CloneProject() var code = NewCode(); var project = InitLocalFlexProjectWithRepo(code); _server.InitServerRepo(code); - var srp = new SendReceiveParams(HgProtocol.Resumable, _server.BaseUrl, project); + var srp = new SendReceiveParams(HgProtocol.Resumable, _server.HostPort, project); _sr.SendReceiveProject(srp, Auth); (await _server.GetServerTip(code)).Should().NotBe("0", "the project should have been pushed to the server"); // Clone it into a fresh directory over the resumable protocol. var cloneDir = Path.Join(BasePath, $"{code}-clone"); if (Directory.Exists(cloneDir)) Directory.Delete(cloneDir, true); - var cloneParams = new SendReceiveParams(HgProtocol.Resumable, _server.BaseUrl, new ProjectPath(code, cloneDir)); + var cloneParams = new SendReceiveParams(HgProtocol.Resumable, _server.HostPort, new ProjectPath(code, cloneDir)); var clonedTo = _sr.CloneProject(cloneParams, Auth, cloneDir); // The cloned working directory should contain the fwdata, byte-identical to what we pushed. @@ -97,7 +97,7 @@ private async Task SendNewProjectOfSize(int totalSizeMb, int fileCount) var project = InitLocalFlexProjectWithRepo(code); _server.InitServerRepo(code); - var srp = new SendReceiveParams(HgProtocol.Resumable, _server.BaseUrl, project); + var srp = new SendReceiveParams(HgProtocol.Resumable, _server.HostPort, project); // add a bunch of large files as separate commits so the resumable push is large var progress = new NullProgress(); diff --git a/csharp/test/HgResume.IntegrationTests/ServerFixture.cs b/csharp/test/HgResume.IntegrationTests/ServerFixture.cs new file mode 100644 index 0000000..7c6ee11 --- /dev/null +++ b/csharp/test/HgResume.IntegrationTests/ServerFixture.cs @@ -0,0 +1,297 @@ +using System.IO.Compression; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Images; +using Xunit; + +namespace HgResume.IntegrationTests; + +/// +/// Shared fixture that runs the hgresume C# image in a container (via Testcontainers) and drives it +/// over HTTP. One container serves both test styles in this project: the HTTP-level wire-protocol +/// tests (via , an ) and the end-to-end Chorus send/receive +/// tests (via / and ). +/// +/// Repos are seeded and torn down through the container's own /api/manage/* endpoints (see +/// ), so nothing needs host-side access into the container's filesystem. The +/// one exception is , which seeds a repo at a path the +/// manage API's ProjectCode validation deliberately rejects (it contains "/") — that case, plus +/// and the maintenance-file helpers (none of which are "manage" operations), +/// still go through /, +/// which are unavailable when reusing an external server (see HGRESUME_BASE_URL below) and throw. +/// +/// Testcontainers talks to the Docker Engine API directly rather than shelling out to a CLI, so a +/// plain podman-CLI-only setup is not enough on its own — podman needs to expose a Docker-API-compatible +/// socket (e.g. via `podman machine`) with `DOCKER_HOST` pointed at it. Docker Desktop/Docker Engine +/// work out of the box. +/// +/// Environment overrides: +/// HGRESUME_IMAGE image to run/build (default "hgresume-csharp:test") +/// HGRESUME_SKIP_BUILD if set, do not build the image (assume it exists) +/// HGRESUME_BASE_URL reuse an already-running server at this URL (with HGRESUME_CONTAINER) +/// HGRESUME_CONTAINER name of the already-running container (informational only in this mode) +/// HGRESUME_KEEP if set, do not stop/remove the container on teardown +/// +public sealed class ServerFixture : IAsyncLifetime +{ + private const ushort ContainerPort = 80; + + // Shared with the HGRESUME_MANAGE_SECRET environment variable passed to the container below. + private const string ManageSecret = "test-secret"; + + private readonly string _image = Env("HGRESUME_IMAGE", "hgresume-csharp:test"); + private readonly string _dataDir = Path.Combine(AppContext.BaseDirectory, "data"); + // Set HGRESUME_REPO_OWNER (e.g. "www-data") to chown seeded repos when the server runs as a + // non-root user (the PHP/Apache reference image). Empty = leave ownership as-is (C# runs as root). + private readonly string _repoOwner = Env("HGRESUME_REPO_OWNER", ""); + // Where the server looks for the maintenance file. C# default is under the cache dir; the PHP app + // looks in its src dir (SourcePath . "/maintenance_message.txt"). + private readonly string _maintPath = Env("HGRESUME_MAINT_PATH", "/var/cache/hgresume/maintenance_message.txt"); + + private IContainer? _container; + private HttpClient? _manageHttp; + + public string ContainerName { get; private set; } = ""; + public string BaseUrl { get; private set; } = ""; + public ApiClient Client { get; private set; } = default!; + + /// host:port with no scheme, e.g. for building a Chorus repo URL as http://{HostPort}/{code}. + public string HostPort => BaseUrl.Replace("http://", "").Replace("https://", ""); + + private HttpClient ManageHttp => _manageHttp ??= new HttpClient + { + BaseAddress = new Uri(BaseUrl), + Timeout = TimeSpan.FromSeconds(120), + DefaultRequestHeaders = { { "X-Manage-Secret", ManageSecret } }, + }; + + public async Task InitializeAsync() + { + string? reuseUrl = Environment.GetEnvironmentVariable("HGRESUME_BASE_URL"); + if (!string.IsNullOrWhiteSpace(reuseUrl)) + { + BaseUrl = reuseUrl; + ContainerName = Env("HGRESUME_CONTAINER", "hgresumable"); + Client = new ApiClient(BaseUrl); + return; + } + + bool keep = Environment.GetEnvironmentVariable("HGRESUME_KEEP") is not null; + ContainerName = "hgresume-test-" + Environment.ProcessId; + + IImage image; + if (Environment.GetEnvironmentVariable("HGRESUME_SKIP_BUILD") is null) + { + IFutureDockerImage futureImage = new ImageFromDockerfileBuilder() + .WithName(_image) + .WithDockerfileDirectory(FindContextDir()) + .WithDockerfile("Dockerfile") + .Build(); + await futureImage.CreateAsync(); + image = futureImage; + } + else + { + image = new DockerImage(_image); + } + + _container = new ContainerBuilder(image) + .WithName(ContainerName) + .WithPortBinding(ContainerPort, assignRandomHostPort: true) + .WithEnvironment("HGRESUME_MANAGE_SECRET", ManageSecret) + .WithCleanUp(!keep) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r.ForPort(ContainerPort).ForPath("/api/v03/isAvailable"))) + .Build(); + await _container.StartAsync(); + BaseUrl = $"http://{_container.Hostname}:{_container.GetMappedPublicPort(ContainerPort)}"; + Client = new ApiClient(BaseUrl); + } + + public async Task DisposeAsync() + { + _manageHttp?.Dispose(); + if (_container is null) return; + + if (Environment.GetEnvironmentVariable("HGRESUME_KEEP") is not null) return; + + var (stdout, stderr) = await _container.GetLogsAsync(); + Console.WriteLine("--- container stdout ---\n" + stdout); + Console.WriteLine("--- container stderr ---\n" + stderr); + await _container.DisposeAsync(); + } + + // ---- repo/maintenance seeding --------------------------------------------------------------- + + /// + /// Seeds /var/vcs/public/<repoId> from a fixture repo zip. Returns the repoId. Goes through + /// POST /api/manage/repos/{code}/finish-reset (the zip becomes the repo's .hg folder) unless + /// repoId isn't a valid ProjectCode (e.g. contains "/"; see + /// ) or HGRESUME_REPO_OWNER is set (a non-root + /// image with no manage API), in which case it falls back to extracting on the host and copying + /// the result into the container directly. + /// + public string SeedRepo(string zipName, string? repoId = null) + { + repoId ??= Path.GetFileNameWithoutExtension(zipName); + string localZip = Path.Combine(_dataDir, zipName); + if (!File.Exists(localZip)) throw new FileNotFoundException($"fixture not found: {localZip}"); + + if (repoId.Contains('/') || !string.IsNullOrEmpty(_repoOwner)) + { + SeedRepoViaFilesystem(localZip, repoId); + return repoId; + } + + using var content = new ByteArrayContent(File.ReadAllBytes(localZip)); + using var resp = ManageHttp.PostAsync($"/api/manage/repos/{repoId}/finish-reset", content) + .GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) + { + throw new Exception($"finish-reset for {repoId} failed: {(int)resp.StatusCode} {resp.ReasonPhrase}"); + } + return repoId; + } + + private void SeedRepoViaFilesystem(string localZip, string repoId) + { + string extractDir = Path.Combine(Path.GetTempPath(), "hgresume-seed-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(extractDir); + ZipFile.ExtractToDirectory(localZip, extractDir); + + Exec($"rm -rf /var/vcs/public/{repoId}"); + RequireContainer().CopyAsync(new DirectoryInfo(extractDir), $"/var/vcs/public/{repoId}") + .GetAwaiter().GetResult(); + if (!string.IsNullOrEmpty(_repoOwner)) + { + Exec($"chown -R {_repoOwner}:{_repoOwner} /var/vcs/public/{repoId}"); + } + } + finally + { + try { Directory.Delete(extractDir, recursive: true); } + catch { /* best-effort temp cleanup */ } + } + } + + public void RemoveRepo(string repoId) + { + if (repoId.Contains('/')) + { + Exec($"rm -rf /var/vcs/public/{repoId}"); + return; + } + + using var resp = ManageHttp.DeleteAsync($"/api/manage/repos/{repoId}").GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) + { + throw new Exception($"delete repo {repoId} failed: {(int)resp.StatusCode}"); + } + } + + /// Creates an empty hg repo on the server for the given project code, via /api/manage. + public void InitServerRepo(string code) + { + RemoveRepo(code); + using var resp = ManageHttp.PostAsync($"/api/manage/repos/{code}", null).GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) + { + throw new Exception($"init repo {code} failed: {(int)resp.StatusCode}"); + } + } + + /// Returns the server's revision list ("hash:branch|...") for a repo, via the HTTP API. + public Task GetServerRevisions(string code, int quantity = 50) => + Task.Run(() => Client.GetRevisions(code, 0, quantity).Text); + + /// Server tip hash (first revision), or "" if the repo is empty. + public async Task GetServerTip(string code) + { + var revs = await GetServerRevisions(code, 1); + // format: ":|..."; empty repo returns "0:" + var first = revs.Split('|').FirstOrDefault() ?? ""; + return first.Split(':').FirstOrDefault() ?? ""; + } + + public string ContainerLogs() + { + if (_container is null) return ""; + var (stdout, stderr) = _container.GetLogsAsync().GetAwaiter().GetResult(); + return stdout + stderr; + } + + /// Adds and commits a file into the given repo (mirrors the PHP addAndCheckInFile helper). + public void AddAndCommit(string repoId, string filename, string content) + { + string repoPath = RepoPath(repoId); + string cmd = $"cd {repoPath} && printf '%s' '{content}' > {filename} && " + + $"hg --config ui.username=system add {filename} && " + + $"hg --config ui.username=system commit -m 'added {filename}'"; + if (!string.IsNullOrEmpty(_repoOwner)) + { + cmd += $" && chown -R {_repoOwner}:{_repoOwner} {repoPath}"; + } + Exec(cmd); + } + + /// + /// Repos with a slash in their id were seeded at that literal path (the SubDir path-traversal + /// test); everything else went through /api/manage, which nests repos one level under their + /// first character (see RepoManageService.PrefixRepoFilePath). + /// + private static string RepoPath(string repoId) => + repoId.Contains('/') ? $"/var/vcs/public/{repoId}" : $"/var/vcs/public/{repoId[0]}/{repoId}"; + + public void SetMaintenance(string message) + => Exec($"printf '%s' '{message}' > {_maintPath}"); + + public void ClearMaintenance() + => Exec($"rm -f {_maintPath}"); + + public void Exec(string shellCommand) + { + var result = RequireContainer().ExecAsync(["sh", "-lc", shellCommand]).GetAwaiter().GetResult(); + if (result.ExitCode != 0) + { + throw new Exception($"`{shellCommand}` failed ({result.ExitCode}).\nstdout:\n{result.Stdout}\nstderr:\n{result.Stderr}"); + } + } + + public byte[] Fixture(string name) => File.ReadAllBytes(Path.Combine(_dataDir, name)); + + public string FixtureText(string name) => File.ReadAllText(Path.Combine(_dataDir, name)).Trim(); + + // ---- helpers --------------------------------------------------------------------------------- + + private IContainer RequireContainer() => _container ?? throw new NotSupportedException( + "Exec/filesystem-based helpers aren't available when reusing an external server via " + + "HGRESUME_BASE_URL; use the /api/manage-based helpers (SeedRepo, RemoveRepo, InitServerRepo) instead."); + + private static string FindContextDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Dockerfile")) && + Directory.Exists(Path.Combine(dir.FullName, "src"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new Exception("could not locate csharp/ context dir (with Dockerfile) above the test output"); + } + + private static string Env(string name, string fallback) + { + string? v = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(v) ? fallback : v; + } +} + +[CollectionDefinition("server")] +public sealed class ServerCollection : ICollectionFixture +{ +} diff --git a/csharp/test/HgResume.SendReceiveTests/VerifyHgWorking.cs b/csharp/test/HgResume.IntegrationTests/VerifyHgWorking.cs similarity index 95% rename from csharp/test/HgResume.SendReceiveTests/VerifyHgWorking.cs rename to csharp/test/HgResume.IntegrationTests/VerifyHgWorking.cs index 1ecd967..fc66f2e 100644 --- a/csharp/test/HgResume.SendReceiveTests/VerifyHgWorking.cs +++ b/csharp/test/HgResume.IntegrationTests/VerifyHgWorking.cs @@ -4,7 +4,7 @@ using Xunit; using Xunit.Abstractions; -namespace HgResume.SendReceiveTests; +namespace HgResume.IntegrationTests; // Mirrors LexBox's SendReceiveServiceTests.VerifyHgWorking: confirms the bundled Chorus Mercurial // is present and usable in this project before we exercise send/receive. diff --git a/csharp/test/HgResume.HttpTests/data/emptyHgRepo.zip b/csharp/test/HgResume.IntegrationTests/data/empty-hg-repo.zip similarity index 100% rename from csharp/test/HgResume.HttpTests/data/emptyHgRepo.zip rename to csharp/test/HgResume.IntegrationTests/data/empty-hg-repo.zip diff --git a/csharp/test/HgResume.HttpTests/data/manyRevsHgRepo.zip b/csharp/test/HgResume.IntegrationTests/data/many-revs-hg-repo.zip similarity index 100% rename from csharp/test/HgResume.HttpTests/data/manyRevsHgRepo.zip rename to csharp/test/HgResume.IntegrationTests/data/many-revs-hg-repo.zip diff --git a/csharp/test/HgResume.HttpTests/data/sampleHgRepo.zip b/csharp/test/HgResume.IntegrationTests/data/sample-hg-repo.zip similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sampleHgRepo.zip rename to csharp/test/HgResume.IntegrationTests/data/sample-hg-repo.zip diff --git a/csharp/test/HgResume.HttpTests/data/sampleHgRepo2.zip b/csharp/test/HgResume.IntegrationTests/data/sample-hg-repo2.zip similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sampleHgRepo2.zip rename to csharp/test/HgResume.IntegrationTests/data/sample-hg-repo2.zip diff --git a/csharp/test/HgResume.HttpTests/data/sampleLargeBundleHgRepo.zip b/csharp/test/HgResume.IntegrationTests/data/sample-large-bundle-hg-repo.zip similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sampleLargeBundleHgRepo.zip rename to csharp/test/HgResume.IntegrationTests/data/sample-large-bundle-hg-repo.zip diff --git a/csharp/test/HgResume.HttpTests/data/sample.bundle b/csharp/test/HgResume.IntegrationTests/data/sample.bundle similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample.bundle rename to csharp/test/HgResume.IntegrationTests/data/sample.bundle diff --git a/csharp/test/HgResume.HttpTests/data/sample.bundle.hash b/csharp/test/HgResume.IntegrationTests/data/sample.bundle.hash similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample.bundle.hash rename to csharp/test/HgResume.IntegrationTests/data/sample.bundle.hash diff --git a/csharp/test/HgResume.HttpTests/data/sample2branchHgRepo.zip b/csharp/test/HgResume.IntegrationTests/data/sample2branch-hg-repo.zip similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample2branchHgRepo.zip rename to csharp/test/HgResume.IntegrationTests/data/sample2branch-hg-repo.zip diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch.bundle b/csharp/test/HgResume.IntegrationTests/data/sample2branch.bundle similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample2branch.bundle rename to csharp/test/HgResume.IntegrationTests/data/sample2branch.bundle diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch.hash b/csharp/test/HgResume.IntegrationTests/data/sample2branch.hash similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample2branch.hash rename to csharp/test/HgResume.IntegrationTests/data/sample2branch.hash diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch2base.bundle b/csharp/test/HgResume.IntegrationTests/data/sample2branch2base.bundle similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample2branch2base.bundle rename to csharp/test/HgResume.IntegrationTests/data/sample2branch2base.bundle diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch2base.hash b/csharp/test/HgResume.IntegrationTests/data/sample2branch2base.hash similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample2branch2base.hash rename to csharp/test/HgResume.IntegrationTests/data/sample2branch2base.hash diff --git a/csharp/test/HgResume.HttpTests/data/sample2branch2tip.hash b/csharp/test/HgResume.IntegrationTests/data/sample2branch2tip.hash similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample2branch2tip.hash rename to csharp/test/HgResume.IntegrationTests/data/sample2branch2tip.hash diff --git a/csharp/test/HgResume.HttpTests/data/sample_entire.bundle b/csharp/test/HgResume.IntegrationTests/data/sample_entire.bundle similarity index 100% rename from csharp/test/HgResume.HttpTests/data/sample_entire.bundle rename to csharp/test/HgResume.IntegrationTests/data/sample_entire.bundle diff --git a/csharp/test/HgResume.HttpTests/data/unrelated.bundle b/csharp/test/HgResume.IntegrationTests/data/unrelated.bundle similarity index 100% rename from csharp/test/HgResume.HttpTests/data/unrelated.bundle rename to csharp/test/HgResume.IntegrationTests/data/unrelated.bundle diff --git a/csharp/test/HgResume.HttpTests/data/unrelated2.bundle b/csharp/test/HgResume.IntegrationTests/data/unrelated2.bundle similarity index 100% rename from csharp/test/HgResume.HttpTests/data/unrelated2.bundle rename to csharp/test/HgResume.IntegrationTests/data/unrelated2.bundle diff --git a/csharp/test/HgResume.SendReceiveTests/test-template-repo.zip b/csharp/test/HgResume.IntegrationTests/test-template-repo.zip similarity index 100% rename from csharp/test/HgResume.SendReceiveTests/test-template-repo.zip rename to csharp/test/HgResume.IntegrationTests/test-template-repo.zip diff --git a/csharp/test/HgResume.SendReceiveTests/HgResumeServerFixture.cs b/csharp/test/HgResume.SendReceiveTests/HgResumeServerFixture.cs deleted file mode 100644 index 8df0de8..0000000 --- a/csharp/test/HgResume.SendReceiveTests/HgResumeServerFixture.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Diagnostics; -using Xunit; - -namespace HgResume.SendReceiveTests; - -/// -/// Runs the C# hgresume image in a container (via podman/docker) and lets tests create empty server -/// repos with `hg init` — the stand-in for LexBox's project registration. Exposes the host:port the -/// Chorus resumable client points at. The project CODE must contain the substring "resumable" so -/// Chorus selects its resumable transport (RepositoryAddress.IsKnownResumableRepository). -/// -public sealed class HgResumeServerFixture : IAsyncLifetime -{ - private readonly string _cli = Env("HGRESUME_PODMAN", "podman"); - private readonly string _image = Env("HGRESUME_IMAGE", "hgresume-csharp:test"); - private readonly string _port = Env("HGRESUME_PORT", "8041"); - private readonly HttpClient _http = new(); - private string _container = ""; - - public string BaseUrl => $"localhost:{_port}"; - - public async Task InitializeAsync() - { - _container = "hgresume-sr-" + Environment.ProcessId; - TryRun(_cli, "rm", "-f", _container); - Run(_cli, "run", "-d", "--name", _container, "-p", $"{_port}:80", - "-e", "HGRESUME_MANAGE_SECRET=test-secret", _image); - await WaitForReadyAsync(); - } - - public Task DisposeAsync() - { - if (Environment.GetEnvironmentVariable("HGRESUME_KEEP") is null) - { - TryRun(_cli, "rm", "-f", _container); - } - return Task.CompletedTask; - } - - /// Creates an empty hg repo on the server for the given project code. - public void InitServerRepo(string code) - { - Run(_cli, "exec", _container, "sh", "-lc", - $"rm -rf /var/vcs/public/{code} && hg init /var/vcs/public/{code}"); - } - - /// Returns the server's revision list ("hash:branch|...") for a repo, via the HTTP API. - public async Task GetServerRevisions(string code, int quantity = 50) - { - var resp = await _http.GetAsync($"http://{BaseUrl}/api/v03/getRevisions?offset=0&quantity={quantity}&repoId={code}"); - return await resp.Content.ReadAsStringAsync(); - } - - /// Server tip hash (first revision), or "" if the repo is empty. - public async Task GetServerTip(string code) - { - var revs = await GetServerRevisions(code, 1); - // format: ":|..."; empty repo returns "0:" - var first = revs.Split('|').FirstOrDefault() ?? ""; - return first.Split(':').FirstOrDefault() ?? ""; - } - - public string ContainerLogs() => TryRun(_cli, "logs", _container).Out; - - private async Task WaitForReadyAsync() - { - var deadline = DateTime.UtcNow.AddSeconds(90); - while (DateTime.UtcNow < deadline) - { - try - { - var r = await _http.GetAsync($"http://{BaseUrl}/api/v03/isAvailable"); - if ((int)r.StatusCode == 200) return; - } - catch - { - // not up yet - } - await Task.Delay(500); - } - throw new Exception($"hgresume container not ready at {BaseUrl}"); - } - - private static (int Code, string Out, string Err) Run(string exe, params string[] args) - { - var r = TryRun(exe, args); - if (r.Code != 0) - throw new Exception($"`{exe} {string.Join(' ', args)}` failed ({r.Code}).\n{r.Out}\n{r.Err}"); - return r; - } - - private static (int Code, string Out, string Err) TryRun(string exe, params string[] args) - { - var psi = new ProcessStartInfo - { - FileName = exe, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - foreach (var a in args) psi.ArgumentList.Add(a); - using var proc = Process.Start(psi)!; - var so = proc.StandardOutput.ReadToEndAsync(); - var se = proc.StandardError.ReadToEndAsync(); - proc.WaitForExit(); - return (proc.ExitCode, so.GetAwaiter().GetResult(), se.GetAwaiter().GetResult()); - } - - private static string Env(string n, string d) - { - var v = Environment.GetEnvironmentVariable(n); - return string.IsNullOrWhiteSpace(v) ? d : v; - } -} - -[CollectionDefinition("hgresume-server")] -public sealed class HgResumeServerCollection : ICollectionFixture -{ -}