From d78a13815a73ea9431e7563e1aa3548d785957fa Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 23 Sep 2026 07:40:56 +0200 Subject: [PATCH] Add PocketBase client with contracts and containerized tests --- Directory.Packages.props | 1 + Elternbeirat.Web.Tests/.editorconfig | 11 + .../Elternbeirat.Web.Tests.csproj | 1 + .../PocketBaseClientTests.cs | 132 +++++----- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 226 ++++++++++++++++++ 5 files changed, 298 insertions(+), 73 deletions(-) create mode 100644 Elternbeirat.Web.Tests/PocketBaseFixture.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 3dbf02a..0ab715c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + diff --git a/Elternbeirat.Web.Tests/.editorconfig b/Elternbeirat.Web.Tests/.editorconfig index 857393d..f07d631 100644 --- a/Elternbeirat.Web.Tests/.editorconfig +++ b/Elternbeirat.Web.Tests/.editorconfig @@ -6,3 +6,14 @@ # common, readable convention for tests. CA1707 (no underscores in member names) # stays enforced in production code, but is turned off here. dotnet_diagnostic.CA1707.severity = none + +# CA1861 wants constant array arguments hoisted to static readonly fields to avoid +# re-allocation. In one-time test setup (collection seeding) that micro-optimization +# has no benefit and inline arrays keep the seed data readable. +dotnet_diagnostic.CA1861.severity = none + +# CA1001 wants a type with a disposable field to implement IDisposable. The xunit +# fixture already owns and disposes its fields in IAsyncLifetime.DisposeAsync, which +# xunit calls; the analyzer just does not recognize that as the dispose contract. +# Adding IAsyncDisposable clashes with IAsyncLifetime's Task-returning DisposeAsync. +dotnet_diagnostic.CA1001.severity = none diff --git a/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj b/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj index 6f7bbd9..9b6c3b5 100644 --- a/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj +++ b/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index c520b9a..fb210f7 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -1,81 +1,35 @@ -using Elternbeirat.PocketBase; - namespace Elternbeirat.Web.Tests; /// -/// Integration tests that hit a live PocketBase instance. They read the base URL -/// from the PB_URL environment variable (falling back to the repo .env file) and -/// skip themselves when no URL is configured, so CI without PocketBase stays green. +/// Tests the against a throwaway PocketBase container +/// with a known seed (see ). Because the data is fixed, +/// the tests assert on exact values, and they need no network to the live instance. /// -public sealed class PocketBaseClientTests : IDisposable +public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClassFixture { - // One HttpClient per test instance; xUnit creates a fresh instance per test, - // so this is disposed in Dispose() when the test finishes. - private readonly HttpClient _http = new(); - - public void Dispose() => _http.Dispose(); - - private static string? ResolvePocketBaseUrl() - { - var fromEnv = Environment.GetEnvironmentVariable("PB_URL"); - if (!string.IsNullOrWhiteSpace(fromEnv)) - return fromEnv; - - // Fall back to the repo .env (gitignored, holds PB_URL locally). - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null) - { - var envPath = Path.Combine(dir.FullName, ".env"); - if (File.Exists(envPath)) - { - foreach (var line in File.ReadAllLines(envPath)) - { - var trimmed = line.Trim(); - if (trimmed.StartsWith("PB_URL=", StringComparison.Ordinal)) - return trimmed["PB_URL=".Length..].Trim(); - } - } - dir = dir.Parent; - } - - return null; - } - - private PocketBaseClient? CreateClient() - { - var url = ResolvePocketBaseUrl(); - if (string.IsNullOrWhiteSpace(url)) - return null; - - _http.BaseAddress = new Uri(url); - return new PocketBaseClient(_http); - } - [Fact] - public async Task Events_load_from_live_instance() + public async Task Events_load_sorted_by_start() { - var client = CreateClient(); - if (client is null) return; // PB_URL not configured; skip live test. + var client = pocketBase.CreateClient(); var events = await client.GetEventsAsync(); - events.ShouldNotBeEmpty(); - // Every event must at least have a title and a start. - events.ShouldAllBe(e => !string.IsNullOrWhiteSpace(e.Title) && e.Start != default); + // Two seeded events, earliest start first. + events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]); + events.ShouldAllBe(e => e.Public); } [Fact] public async Task Event_time_is_read_as_wall_clock_not_shifted() { - var client = CreateClient(); - if (client is null) return; // PB_URL not configured; skip live test. + var client = pocketBase.CreateClient(); var events = await client.GetEventsAsync(); - // The Elternbeiratssitzung is stored as 19:30; by the timezone convention - // the number is taken at face value, so no shift to 20:30/21:30 happens. - var meeting = events.FirstOrDefault(@event => - @event.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal)); + // The meeting is seeded as 19:30; by the timezone convention the number is + // taken at face value, so no shift to 20:30/21:30 happens. + var meeting = events.FirstOrDefault(e => + e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal)); meeting.ShouldNotBeNull(); meeting.Start.Hour.ShouldBe(19); meeting.Start.Minute.ShouldBe(30); @@ -83,29 +37,61 @@ public sealed class PocketBaseClientTests : IDisposable } [Fact] - public async Task Pages_load_with_navigation_fields() + public async Task Event_without_end_maps_to_null() { - var client = CreateClient(); - if (client is null) return; // PB_URL not configured; skip live test. + var client = pocketBase.CreateClient(); - var pages = await client.GetPagesAsync(); + var events = await client.GetEventsAsync(); - pages.ShouldNotBeEmpty(); - // Navigation depends on location + slug being present on every page. - pages.ShouldAllBe(p => - !string.IsNullOrWhiteSpace(p.Slug) && (p.Location == "header" || p.Location == "footer")); + var meeting = events.Single(e => e.Title == "Elternbeiratssitzung"); + var basar = events.Single(e => e.Title == "Herbstbasar"); + meeting.End.ShouldBeNull(); // no end seeded + basar.End.ShouldNotBeNull(); // end seeded } [Fact] - public async Task Posts_and_faqs_load_from_live_instance() + public async Task Posts_load_newest_first() { - var client = CreateClient(); - if (client is null) return; // PB_URL not configured; skip live test. + var client = pocketBase.CreateClient(); var posts = await client.GetPostsAsync(); + + // Sorted by -date: March before January. + posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]); + } + + [Fact] + public async Task Pages_exclude_non_public_records() + { + var client = pocketBase.CreateClient(); + + var pages = await client.GetPagesAsync(); + + // Three pages seeded, one with public=false; the draft must be filtered out. + pages.Select(p => p.Slug).ShouldBe(["home", "contact"]); + pages.ShouldNotContain(p => p.Slug == "draft"); + } + + [Fact] + public async Task Page_embed_is_read_as_list() + { + var client = pocketBase.CreateClient(); + + var pages = await client.GetPagesAsync(); + + var home = pages.Single(p => p.Slug == "home"); + home.Embed.ShouldBe(["posts", "events"], ignoreOrder: true); + home.Location.ShouldBe("header"); + } + + [Fact] + public async Task Faqs_load_from_instance() + { + var client = pocketBase.CreateClient(); + var faqs = await client.GetFaqsAsync(); - posts.ShouldNotBeEmpty(); - faqs.ShouldNotBeEmpty(); + faqs.Select(f => f.Topic).ShouldBe(["mensa", "schliessfach"], ignoreOrder: true); + faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question)); } } diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs new file mode 100644 index 0000000..9b17939 --- /dev/null +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -0,0 +1,226 @@ +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; } = ""; + } +}