Run PocketBase as a compose stack, tests included, without Testcontainers
This commit is contained in:
1 parent
bbe792192d
commit
b1d1953a62
14 files changed
+393
-217
No files matched your search
@@ -1,7 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Elternbeirat.PocketBase;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
@@ -12,51 +12,128 @@ namespace Elternbeirat.Web.Tests;
|
||||
/// against this instance instead of the live one, so they are hermetic (no network
|
||||
/// to Unraid), reproducible (fixed data) and safe (isolated from production).
|
||||
/// <para>
|
||||
/// The seed data is deliberately fixed here rather than exported from the real
|
||||
/// instance, so tests assert against values this file controls. Requires Docker.
|
||||
/// The container is started from the real <c>compose.yaml</c> + <c>compose.dev.yaml</c>
|
||||
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
|
||||
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
|
||||
/// in one place -- the compose files -- and the tests always exercise the same
|
||||
/// PocketBase the stack runs. The seed data is deliberately fixed here rather than
|
||||
/// exported from the real instance, so tests assert against values this file
|
||||
/// controls. Requires Docker with the Compose plugin.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
{
|
||||
// Same image tag as production, so the tests exercise the real PocketBase
|
||||
// version. Superuser credentials are only used to set up the container.
|
||||
private const string Image = "ghcr.io/muchobien/pocketbase:0.40.4";
|
||||
// The service name and container port as defined in the compose files. Everything
|
||||
// else about the container (image version, superuser env, host port) comes from
|
||||
// compose, so there is nothing to keep in sync with it here.
|
||||
private const string ServiceName = "eb-pocketbase";
|
||||
private const int PocketBasePort = 8090;
|
||||
|
||||
// The superuser the dev overlay creates (PB_ADMIN_EMAIL/PASSWORD in
|
||||
// compose.dev.yaml); used only to authenticate for the one-time seed.
|
||||
private const string AdminEmail = "test@example.com";
|
||||
private const string AdminPassword = "test-password"; // >= 8 chars (PB rule)
|
||||
|
||||
private readonly IContainer _container = new ContainerBuilder(Image)
|
||||
// PB_ADMIN_EMAIL/PASSWORD make the entrypoint upsert a superuser on start.
|
||||
// The command must stay empty, or the entrypoint skips that step.
|
||||
.WithEnvironment("PB_ADMIN_EMAIL", AdminEmail)
|
||||
.WithEnvironment("PB_ADMIN_PASSWORD", AdminPassword)
|
||||
.WithPortBinding(PocketBasePort, assignRandomHostPort: true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer()
|
||||
.UntilHttpRequestIsSucceeded(r => r.ForPath("/api/health").ForPort(PocketBasePort)))
|
||||
.Build();
|
||||
// A fixed compose project name for the tests, sibling to the dev stack
|
||||
// ("eb-stack"). Fixed (not per-run) so the
|
||||
// container names are predictable and a leftover from an aborted run can be
|
||||
// cleaned up before the next start. Because it is its own project, it never
|
||||
// touches the dev stack -- the container_name is cleared in the test overlay so
|
||||
// both projects can coexist.
|
||||
private const string Project = "eb-test-stack";
|
||||
|
||||
// One HttpClient shared by all tests through the client; the fixture owns it and
|
||||
// disposes it in DisposeAsync. Its BaseAddress is set once the container is up.
|
||||
private readonly HttpClient _http = new();
|
||||
|
||||
/// <summary>Base URL of the running container, e.g. http://localhost:49153.</summary>
|
||||
public Uri BaseUrl =>
|
||||
new($"http://{_container.Hostname}:{_container.GetMappedPublicPort(PocketBasePort)}");
|
||||
|
||||
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
||||
public PocketBaseClient CreateClient() => new(_http);
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
_http.BaseAddress = BaseUrl;
|
||||
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
||||
// (a hard kill), so the fixed-name project starts from a clean, empty volume.
|
||||
await ComposeAsync("down", "--volumes", "--remove-orphans");
|
||||
// `up --wait` blocks until the service is healthy (the compose healthcheck),
|
||||
// so once this returns PocketBase is ready to answer.
|
||||
await ComposeAsync("up", "--detach", "--wait", ServiceName);
|
||||
_http.BaseAddress = await ResolveBaseUrlAsync();
|
||||
await SeedAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_http.Dispose();
|
||||
await _container.DisposeAsync();
|
||||
// Remove containers, network and the (dev) volume for this project.
|
||||
await ComposeAsync("down", "--volumes");
|
||||
}
|
||||
|
||||
/// <summary>Reads the host address compose bound the service port to.</summary>
|
||||
private static async Task<Uri> ResolveBaseUrlAsync()
|
||||
{
|
||||
// `docker compose port <service> <port>` prints e.g. "0.0.0.0:49153".
|
||||
var mapping = (await ComposeAsync(
|
||||
"port", ServiceName, PocketBasePort.ToString(CultureInfo.InvariantCulture))).Trim();
|
||||
var host = mapping[..mapping.LastIndexOf(':')];
|
||||
var port = mapping[(mapping.LastIndexOf(':') + 1)..];
|
||||
// 0.0.0.0 is a bind address, not something to connect to; use loopback.
|
||||
if (host is "0.0.0.0" or "::")
|
||||
host = "localhost";
|
||||
return new Uri($"http://{host}:{port}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs `docker compose -p <project> -f compose.yaml -f compose.dev.yaml <args>`
|
||||
/// from the repo root and returns its stdout, throwing on a non-zero exit.
|
||||
/// </summary>
|
||||
private static async Task<string> ComposeAsync(params string[] args)
|
||||
{
|
||||
var start = new ProcessStartInfo("docker")
|
||||
{
|
||||
WorkingDirectory = RepoRoot(),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
// compose -p <project> -f <base> -f <dev> -f <test> <args...>. The test
|
||||
// overlay swaps the dev overlay's fixed host port for a random one, so the
|
||||
// test stack does not fight a running dev stack over port 8090.
|
||||
start.ArgumentList.Add("compose");
|
||||
start.ArgumentList.Add("-p");
|
||||
start.ArgumentList.Add(Project);
|
||||
start.ArgumentList.Add("-f");
|
||||
start.ArgumentList.Add("compose.yaml");
|
||||
start.ArgumentList.Add("-f");
|
||||
start.ArgumentList.Add("compose.dev.yaml");
|
||||
start.ArgumentList.Add("-f");
|
||||
start.ArgumentList.Add("compose.test.yaml");
|
||||
foreach (var arg in args)
|
||||
start.ArgumentList.Add(arg);
|
||||
|
||||
using var process = Process.Start(start)
|
||||
?? throw new InvalidOperationException("Could not start the docker process.");
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"`docker compose {string.Join(' ', args)}` failed ({process.ExitCode}): {stderr}");
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/// <summary>Repo root, resolved by walking up from the test assembly to compose.yaml.</summary>
|
||||
private static string RepoRoot()
|
||||
{
|
||||
// The test binary sits under <repo>/Elternbeirat.Web.Tests/bin/<config>/<tfm>;
|
||||
// walk up until the directory that holds the compose files (the repo root).
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "compose.yaml")))
|
||||
dir = dir.Parent;
|
||||
|
||||
return dir?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate the repo root (compose.yaml).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in new issue
Block a user