338 lines
15 KiB
C#
338 lines
15 KiB
C#
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Elternbeirat.PocketBase;
|
|
|
|
namespace Elternbeirat.Web.Tests;
|
|
|
|
/// <summary>
|
|
/// Starts a throwaway PocketBase container once per test run, creates the four
|
|
/// content collections and seeds them with a small, known data set. The tests run
|
|
/// 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 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// The collection <b>schema</b> is not typed out here: it is imported from
|
|
/// <c>pb/pb_migrations/collections_schema.json</c>, the same file the dev seed
|
|
/// migration uses, so the schema is defined once and cannot drift between the two.
|
|
/// The seeded <b>records</b>, by contrast, are fixed in this file on purpose (not
|
|
/// the dev seed's data), so tests assert against values this file controls -- e.g.
|
|
/// a non-public draft page that must never appear, and an event at a known time.
|
|
/// Requires Docker with the Compose plugin.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class PocketBaseFixture : IAsyncLifetime
|
|
{
|
|
// 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)
|
|
|
|
// 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>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
|
public PocketBaseClient CreateClient() => new(_http);
|
|
|
|
/// <summary>
|
|
/// The base URL the container's PocketBase is reachable at. Set once the
|
|
/// container is up; used to point the web app's client at this instance in the
|
|
/// route smoke tests.
|
|
/// </summary>
|
|
public Uri BaseUrl => _http.BaseAddress
|
|
?? throw new InvalidOperationException("PocketBase is not started yet.");
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
// 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();
|
|
// 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>
|
|
/// Authenticates as superuser, then imports the shared collection schema and
|
|
/// seeds the fixed test records the tests assert against.
|
|
/// </summary>
|
|
private async Task SeedAsync()
|
|
{
|
|
var token = await AuthenticateAsync(_http);
|
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
|
|
|
|
await ImportCollectionsAsync(_http);
|
|
await SeedRecordsAsync(_http);
|
|
|
|
// Drop the superuser token so the tests read as an anonymous visitor would,
|
|
// exercising the public list/view rules rather than a privileged bypass.
|
|
_http.DefaultRequestHeaders.Authorization = null;
|
|
}
|
|
|
|
private static async Task<string> AuthenticateAsync(HttpClient http)
|
|
{
|
|
// The superuser upsert runs before serve, so once /api/health answers the
|
|
// account exists; a couple of retries still guard against a race.
|
|
for (var attempt = 0; ; attempt++)
|
|
{
|
|
var response = await http.PostAsJsonAsync(
|
|
"/api/collections/_superusers/auth-with-password",
|
|
new { identity = AdminEmail, password = AdminPassword });
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
|
return payload?.Token ?? throw new InvalidOperationException("No auth token returned.");
|
|
}
|
|
|
|
if (attempt >= 5)
|
|
response.EnsureSuccessStatusCode(); // give up: throw with the status.
|
|
|
|
await Task.Delay(500);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Imports the four content collections from the shared schema file, the same
|
|
/// one the dev seed migration reads (<c>pb/pb_migrations/collections_schema.json</c>).
|
|
/// Uses PocketBase's <c>/api/collections/import</c> so the whole schema is
|
|
/// defined in one place instead of being typed out here.
|
|
/// </summary>
|
|
private static async Task ImportCollectionsAsync(HttpClient http)
|
|
{
|
|
// The file maps name -> collection object; the import endpoint wants a
|
|
// flat list, so unwrap the values. deleteMissing=false leaves anything
|
|
// already present untouched (the import is idempotent).
|
|
var path = Path.Combine(RepoRoot(), "pb", "pb_migrations", "collections_schema.json");
|
|
using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path));
|
|
var collections = schema.RootElement.EnumerateObject()
|
|
.Select(property => property.Value)
|
|
.ToArray();
|
|
|
|
// Serialize the JsonElement list back to JSON for the request body.
|
|
var body = JsonSerializer.Serialize(new { collections, deleteMissing = false });
|
|
using var content = new StringContent(body, Encoding.UTF8, "application/json");
|
|
var response = await http.PutAsync(new Uri("/api/collections/import", UriKind.Relative), content);
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
private static async Task SeedRecordsAsync(HttpClient http)
|
|
{
|
|
// Pages that the route smoke tests reach through the navigation, the
|
|
// footer or the FAQ hub. Every page carries a location ("header" or
|
|
// "footer") -- the field is required, matching production. All public.
|
|
// The home page sits in the header at order 1, like production; the brand
|
|
// links home too, but the nav entry is the single source of the home link.
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "Start", body = "# Willkommen", location = "header",
|
|
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "Vorstandsteam", body = "# Vorstand", location = "header",
|
|
order = 2, slug = "board", embed = Array.Empty<string>(), @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
// Carries an embed so the ContentPage embed path is covered: /patrons
|
|
// renders its body plus the FAQ teaser.
|
|
title = "Förderverein", body = "# Förderverein", location = "header",
|
|
order = 3, slug = "patrons", embed = new[] { "faqs" }, @public = true,
|
|
});
|
|
// Nav placeholder for the FAQ list (route /faqs is served by FaqList,
|
|
// which shadows this page). Slug matches production's "faqs" nav entry.
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "FAQ", body = "# Häufige Fragen", location = "header",
|
|
order = 4, slug = "faqs", embed = Array.Empty<string>(), @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "Downloads", body = "# Downloads", location = "header",
|
|
order = 5, slug = "downloads", embed = Array.Empty<string>(), @public = true,
|
|
});
|
|
// A plain content page reached only by its slug (not shown in the header),
|
|
// so the catch-all "/{slug}" ContentPage route stays covered. It carries a
|
|
// valid location because the field is required; footer keeps it low-key.
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "FAQ Mensa", body = "# Mensa", location = "footer",
|
|
order = 9, slug = "faq-lunch", embed = Array.Empty<string>(), @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "Kontakt", body = "Mail an uns", location = "footer",
|
|
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "Impressum", body = "# Impressum", location = "footer",
|
|
order = 2, slug = "imprint", embed = Array.Empty<string>(), @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "Datenschutz", body = "# Datenschutz", location = "footer",
|
|
order = 3, slug = "privacy", embed = Array.Empty<string>(), @public = true,
|
|
});
|
|
// A draft page that must never appear (public=false).
|
|
await CreateRecordAsync(http, "pages", new
|
|
{
|
|
title = "Entwurf", body = "geheim", location = "header",
|
|
order = 9, slug = "draft", embed = Array.Empty<string>(), @public = false,
|
|
});
|
|
|
|
// Posts: newest first once sorted by -date.
|
|
await CreateRecordAsync(http, "posts", new
|
|
{
|
|
date = "2026-03-01 00:00:00.000Z", title = "Neuer Vorstand",
|
|
body = "Text", slug = "new-board", @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "posts", new
|
|
{
|
|
date = "2026-01-15 00:00:00.000Z", title = "Neue Sporthalle",
|
|
body = "Text", slug = "new-hall", @public = true,
|
|
});
|
|
|
|
// Events: PocketBase stores UTC. The meeting is 19:30 Berlin; October is
|
|
// summer time (UTC+2), so it is stored as 17:30Z and the timezone test
|
|
// expects 19:30 back. The Herbstbasar is 09:00-13:00 Berlin in November
|
|
// (winter, UTC+1), stored as 08:00Z-12:00Z.
|
|
await CreateRecordAsync(http, "events", new
|
|
{
|
|
start = "2026-10-08 17:30:00.000Z", title = "Elternbeiratssitzung",
|
|
location = "Aula", note = "", @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "events", new
|
|
{
|
|
start = "2026-11-22 08:00:00.000Z", end = "2026-11-22 12:00:00.000Z",
|
|
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
|
});
|
|
|
|
// Faqs: two topics.
|
|
await CreateRecordAsync(http, "faqs", new
|
|
{
|
|
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
|
|
topic = "mensa", @public = true,
|
|
});
|
|
await CreateRecordAsync(http, "faqs", new
|
|
{
|
|
question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.",
|
|
topic = "schliessfach", @public = true,
|
|
});
|
|
}
|
|
|
|
private static async Task CreateRecordAsync(HttpClient http, string collection, object record)
|
|
{
|
|
var response = await http.PostAsJsonAsync($"/api/collections/{collection}/records", record);
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
private sealed record AuthResponse
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("token")]
|
|
public string Token { get; init; } = "";
|
|
}
|
|
}
|