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