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; /// /// 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 seed data is deliberately fixed here rather than exported from the real /// instance, so tests assert against values this file controls. Requires Docker. /// /// 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"; private const int PocketBasePort = 8090; 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(); // 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(); /// Base URL of the running container, e.g. http://localhost:49153. public Uri BaseUrl => new($"http://{_container.Hostname}:{_container.GetMappedPublicPort(PocketBasePort)}"); /// Creates a pointed at this container. public PocketBaseClient CreateClient() => new(_http); public async Task InitializeAsync() { await _container.StartAsync(); _http.BaseAddress = BaseUrl; await SeedAsync(); } public async Task DisposeAsync() { _http.Dispose(); await _container.DisposeAsync(); } /// /// Authenticates as superuser, then creates the collections and records. This /// mirrors the shape the client reads, not the full production schema. /// private async Task SeedAsync() { var token = await AuthenticateAsync(_http); _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token); await CreateCollectionsAsync(_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); } } private static async Task CreateCollectionsAsync(HttpClient http) { // listRule/viewRule = "" means publicly readable; the client's // filter=public=true does the visibility gating on top. await CreateCollectionAsync(http, "pages", new object[] { new { name = "title", type = "text", required = true }, new { name = "body", type = "editor" }, new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } }, new { name = "order", type = "number" }, new { name = "slug", type = "text", required = true }, new { name = "embed", type = "select", maxSelect = 3, values = new[] { "posts", "events", "faqs" } }, new { name = "public", type = "bool" }, }); await CreateCollectionAsync(http, "posts", new object[] { new { name = "date", type = "date" }, new { name = "title", type = "text", required = true }, new { name = "body", type = "editor" }, new { name = "slug", type = "text", required = true }, new { name = "public", type = "bool" }, }); await CreateCollectionAsync(http, "events", new object[] { new { name = "start", type = "date", required = true }, new { name = "end", type = "date" }, new { name = "title", type = "text", required = true }, new { name = "location", type = "text" }, new { name = "note", type = "text" }, new { name = "public", type = "bool" }, }); await CreateCollectionAsync(http, "faqs", new object[] { new { name = "question", type = "text", required = true }, new { name = "answer", type = "editor" }, new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } }, new { name = "public", type = "bool" }, }); } private static async Task CreateCollectionAsync(HttpClient http, string name, object[] fields) { var response = await http.PostAsJsonAsync("/api/collections", new { name, type = "base", listRule = "", viewRule = "", fields, }); response.EnsureSuccessStatusCode(); } private static async Task SeedRecordsAsync(HttpClient http) { // Pages: one header page, one footer page. Both 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 = "Kontakt", body = "Mail an uns", location = "footer", order = 1, slug = "contact", 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; } = ""; } }