Run PocketBase as a compose stack, tests included, without Testcontainers

This commit is contained in:
tleininger committed 2026-09-23 10:03:16 +02:00
1 parent bbe792192d
commit b1d1953a62
14 files changed
+393 -217

No files matched your search

@@ -12,7 +12,6 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Shouldly" />
<PackageReference Include="Testcontainers" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
@@ -1,3 +1,5 @@
using Elternbeirat.PocketBase;
namespace Elternbeirat.Web.Tests;
/// <summary>
+100 -23
View File
@@ -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 &lt;project&gt; -f compose.yaml -f compose.dev.yaml &lt;args&gt;`
/// 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>