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; } = "";
+ }
+}