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; /// /// 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). /// /// The container is started from the real compose.yaml + compose.dev.yaml /// (only the eb-pocketbase service, not the web app) by shelling out to /// docker compose, 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 collection schema is not typed out here: it is imported from /// pb/pb_migrations/collections_schema.json, the same file the dev seed /// migration uses, so the schema is defined once and cannot drift between the two. /// The seeded records, 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. /// /// 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(); /// Creates a pointed at this container. public PocketBaseClient CreateClient() => new(_http); /// /// 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. /// 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"); } /// Reads the host address compose bound the service port to. private static async Task ResolveBaseUrlAsync() { // `docker compose 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}"); } /// /// 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. /// private static async Task ComposeAsync(params string[] args) { var start = new ProcessStartInfo("docker") { WorkingDirectory = RepoRoot(), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; // compose -p -f -f -f . 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; } /// Repo root, resolved by walking up from the test assembly to compose.yaml. private static string RepoRoot() { // The test binary sits under /Elternbeirat.Web.Tests/bin//; // 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)."); } /// /// Authenticates as superuser, then imports the shared collection schema and /// seeds the fixed test records the tests assert against. /// 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 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(); 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); } } /// /// Imports the four content collections from the shared schema file, the same /// one the dev seed migration reads (pb/pb_migrations/collections_schema.json). /// Uses PocketBase's /api/collections/import so the whole schema is /// defined in one place instead of being typed out here. /// 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. Header pages carry a location+order; the FAQ // sub-pages are plain content pages without a menu slot. All public. await CreateRecordAsync(http, "pages", new { title = "Startseite", 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(), @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(), @public = true, }); await CreateRecordAsync(http, "pages", new { title = "Downloads", body = "# Downloads", location = "header", order = 5, slug = "downloads", embed = Array.Empty(), @public = true, }); await CreateRecordAsync(http, "pages", new { title = "FAQ Mensa", body = "# Mensa", location = "", order = 0, slug = "faq-lunch", embed = Array.Empty(), @public = true, }); await CreateRecordAsync(http, "pages", new { title = "Kontakt", body = "Mail an uns", location = "footer", order = 1, slug = "contact", embed = Array.Empty(), @public = true, }); await CreateRecordAsync(http, "pages", new { title = "Impressum", body = "# Impressum", location = "footer", order = 2, slug = "imprint", embed = Array.Empty(), @public = true, }); await CreateRecordAsync(http, "pages", new { title = "Datenschutz", body = "# Datenschutz", location = "footer", order = 3, slug = "privacy", embed = Array.Empty(), @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(), @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: the meeting carries the wall-clock time the timezone test checks. await CreateRecordAsync(http, "events", new { start = "2026-10-08 19:30:00.000Z", title = "Elternbeiratssitzung", location = "Aula", note = "", @public = true, }); await CreateRecordAsync(http, "events", new { start = "2026-11-22 09:00:00.000Z", end = "2026-11-22 13: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; } = ""; } }