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
-{
-}