diff --git a/.dockerignore b/.dockerignore index c6a6ce6..b7ee487 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,14 @@ -# Build-Ausgaben (werden im Container frisch erzeugt) +# Build outputs (regenerated fresh inside the container) **/bin/ **/obj/ **/out/ -# IDE- und Tooling-Kram +# IDE and tooling files .vs/ .idea/ .vscode/ -# Versionskontrolle und Doku, im Image nicht benoetigt +# Version control and docs, not needed in the image .git/ .gitea/ .gitignore @@ -16,7 +16,9 @@ docs/ *.md -# Docker-Dateien selbst +# The Docker files themselves Dockerfile .dockerignore compose.yaml +compose.dev.yaml +compose.test.yaml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..74d6a2f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,56 @@ +# EditorConfig for the Elternbeirat solution. +# https://editorconfig.org / https://learn.microsoft.com/dotnet/fundamentals/code-analysis/code-style-rule-options +# +# Code-style rules are added here over time. Kept intentionally minimal for now; +# the .NET analyzers (see Directory.Build.props) already run at their sharpest. + +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{cs,csproj,props,targets}] +indent_size = 4 + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.cs] +# CA1716 flags type names that collide with Visual Basic keywords (e.g. Event). +# This app is not consumed from VB, and Event/Post are the project's deliberate +# domain names (see CLAUDE.md: "im Code Event, nicht Termin"). Renaming would +# break that convention for a problem this codebase does not have. +dotnet_diagnostic.CA1716.severity = none + +# CA1515 suggests making types internal because an app's types are not referenced +# from outside its assembly. That is a library-author rule with no benefit here: +# this is an application, not a reusable package. Razor components (Home, PostList, +# ...) must stay public so Blazor can render them, and the Contracts records are +# consumed from another project. Turning types internal would gain nothing. +dotnet_diagnostic.CA1515.severity = none + +# CA1062 wants public methods to null-check their arguments. The public surface +# here is DI constructors and Blazor components, never called by foreign code with +# raw arguments; the container always supplies its dependencies. With #nullable on, +# a non-nullable parameter already carries the "never null" contract in its type. +dotnet_diagnostic.CA1062.severity = none + +# CA2007 (ConfigureAwait) targets libraries with a SynchronizationContext (WinForms, +# WPF, classic ASP.NET). ASP.NET Core has none, so ConfigureAwait(false) would be +# pure noise here. Microsoft's own project templates disable this rule. +dotnet_diagnostic.CA2007.severity = none + +# CA1812 flags types "never instantiated". The JSON DTOs (e.g. RecordList) are +# only created by the deserializer via reflection, which the analyzer cannot see. +# This is a known false positive for deserialization types. +dotnet_diagnostic.CA1812.severity = none + +# CA1724 flags a type name that matches part of its namespace (Home in +# ...Features.Home). That collision is a deliberate result of the feature-folder +# layout and is harmless (Blazor routing is not namespace-based). Renaming would +# break the folder convention for no benefit. +dotnet_diagnostic.CA1724.severity = none diff --git a/.env.example b/.env.example index bcd4377..80339d9 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,10 @@ GITEA_TOKEN= # Base URL of the Gitea instance and the repo path issues belong to. GITEA_URL=https://gitea.anticarnist.de GITEA_REPO=Tom/Elternbeirat + +# PocketBase superuser login, used to read/write records via the REST API +# (e.g. migrating content) without loosening the collection API rules -- a +# superuser bypasses them. Create the superuser in the dashboard at /_/. +PB_URL=http://:8090 +PB_ADMIN_EMAIL= +PB_ADMIN_PASSWORD= diff --git a/.gitignore b/.gitignore index 1e06d8f..daf55a7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ obj/ riderModule.iml /_ReSharper.Caches/ .idea/ +# Per-user Rider/ReSharper settings (personal, not shared). +*.sln.DotSettings.user +*.DotSettings.user # Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit # personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo. diff --git a/.run/DevEnvironment.run.xml b/.run/DevEnvironment.run.xml new file mode 100644 index 0000000..713b9be --- /dev/null +++ b/.run/DevEnvironment.run.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/CLAUDE.md b/CLAUDE.md index 4ef71d1..570d8d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,9 @@ nachlesen, bevor eine davon in Frage gestellt wird. | Thema | Datei | |---|---| | Warum SSR statt WASM, warum keine DB (AE-1 bis AE-4) | `docs/architektur.md` | +| Stack lokal starten, Tests, compose-Overlays | `docs/entwicklung.md` | | Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` | -| Inhalte anlegen und ändern | `docs/inhalte-pflegen.md` | +| Inhalte anlegen und ändern (PocketBase-Admin) | `docs/redaktion.md` | | Impressum, Datenschutz, Fotos | `docs/recht.md` | | Offene Arbeit, Meilensteine, offene Punkte | Gitea-Issues (Milestone „Elternbeirat-Website") | diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0ed0f70 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + + true + All + latest-all + + + true + + + true + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..3dbf02a --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,25 @@ + + + + + true + + + + + + + + + + + + + + + + + + diff --git a/Dockerfile b/Dockerfile index a63399d..3221e31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,15 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -# Copy only the csproj and restore first, so the NuGet restore layer stays -# cached as long as the dependencies do not change. +# Copy the solution-wide build files first (Central Package Management lives in +# Directory.Packages.props; without it a restore fails with NU1015). Then the +# csproj of every project in the web app's dependency tree. Restoring before +# copying the rest of the source keeps the NuGet layer cached as long as the +# dependencies do not change. +COPY Directory.Build.props Directory.Packages.props ./ COPY Elternbeirat.Web/Elternbeirat.Web.csproj Elternbeirat.Web/ +COPY Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj Elternbeirat.PocketBase/ +COPY Elternbeirat.Contracts/Elternbeirat.Contracts.csproj Elternbeirat.Contracts/ RUN dotnet restore Elternbeirat.Web/Elternbeirat.Web.csproj # Then the rest of the source. diff --git a/Elternbeirat.Contracts/Elternbeirat.Contracts.csproj b/Elternbeirat.Contracts/Elternbeirat.Contracts.csproj new file mode 100644 index 0000000..c79a88d --- /dev/null +++ b/Elternbeirat.Contracts/Elternbeirat.Contracts.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs new file mode 100644 index 0000000..22201ef --- /dev/null +++ b/Elternbeirat.Contracts/Event.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.Contracts; + +/// A calendar entry. Sorted by . +public record Event +{ + /// PocketBase record id. + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + /// + /// Start of the event with date and time. Stored as UTC by PocketBase but + /// read as local time (Europe/Berlin) by convention. An all-day event uses + /// 00:00 as the time. + /// + [JsonPropertyName("start")] + public DateTime Start { get; init; } + + /// Optional end of the event; null when unset. + [JsonPropertyName("end")] + public DateTime? End { get; init; } + + /// Event name, e.g. "Elternbeiratssitzung". + [JsonPropertyName("title")] + public string Title { get; init; } = ""; + + /// Optional location, e.g. "Aula". + [JsonPropertyName("location")] + public string Location { get; init; } = ""; + + /// Optional note, e.g. "Anmeldung erforderlich". + [JsonPropertyName("note")] + public string Note { get; init; } = ""; + + /// Whether the event is visible to visitors. + [JsonPropertyName("public")] + public bool Public { get; init; } +} diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs new file mode 100644 index 0000000..70ac2a2 --- /dev/null +++ b/Elternbeirat.Contracts/Faq.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.Contracts; + +/// +/// A single question and answer, grouped on the FAQ page by . +/// +public record Faq +{ + /// PocketBase record id. + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + /// The question as a parent would phrase it. + [JsonPropertyName("question")] + public string Question { get; init; } = ""; + + /// The answer in Markdown. + [JsonPropertyName("answer")] + public string Answer { get; init; } = ""; + + /// Topic the question is grouped under, e.g. "mensa". + [JsonPropertyName("topic")] + public string Topic { get; init; } = ""; + + /// Whether the question is visible to visitors. + [JsonPropertyName("public")] + public bool Public { get; init; } +} diff --git a/Elternbeirat.Contracts/Page.cs b/Elternbeirat.Contracts/Page.cs new file mode 100644 index 0000000..4c1ac14 --- /dev/null +++ b/Elternbeirat.Contracts/Page.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.Contracts; + +/// +/// A content page. Pages also drive the site navigation: +/// and decide where and in which order a page appears in the +/// header or footer menu, and lists dynamic blocks (posts, +/// events, faqs) rendered below the page body. +/// +public record Page +{ + /// PocketBase record id. + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + /// Heading shown to visitors; may contain umlauts and spaces. + [JsonPropertyName("title")] + public string Title { get; init; } = ""; + + /// Page body in Markdown. + [JsonPropertyName("body")] + public string Body { get; init; } = ""; + + /// Where the page appears in the navigation: "header" or "footer". + [JsonPropertyName("location")] + public string Location { get; init; } = ""; + + /// Sort order within its navigation location; smaller is earlier. + [JsonPropertyName("order")] + public double Order { get; init; } + + /// URL slug (lowercase, no umlauts), e.g. "board" -> /board. + [JsonPropertyName("slug")] + public string Slug { get; init; } = ""; + + /// + /// Dynamic blocks to render below the body: any of "posts", "events", "faqs". + /// Empty for a plain text page. + /// + [JsonPropertyName("embed")] + public IReadOnlyList Embed { get; init; } = []; + + /// Whether the page is visible to visitors. + [JsonPropertyName("public")] + public bool Public { get; init; } +} diff --git a/Elternbeirat.Contracts/Post.cs b/Elternbeirat.Contracts/Post.cs new file mode 100644 index 0000000..382200c --- /dev/null +++ b/Elternbeirat.Contracts/Post.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.Contracts; + +/// A news post. Sorted by , newest first. +public record Post +{ + /// PocketBase record id. + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + /// + /// Publication date. Stored as UTC by PocketBase but read as local time + /// (Europe/Berlin) by convention; only the date part is shown. + /// + [JsonPropertyName("date")] + public DateTime Date { get; init; } + + /// Post heading shown to visitors; may contain umlauts and spaces. + [JsonPropertyName("title")] + public string Title { get; init; } = ""; + + /// Post body in Markdown. + [JsonPropertyName("body")] + public string Body { get; init; } = ""; + + /// URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar. + [JsonPropertyName("slug")] + public string Slug { get; init; } = ""; + + /// Whether the post is visible to visitors. + [JsonPropertyName("public")] + public bool Public { get; init; } +} diff --git a/Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj b/Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj new file mode 100644 index 0000000..1091fcc --- /dev/null +++ b/Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/Elternbeirat.PocketBase/LocalDateTimeConverter.cs b/Elternbeirat.PocketBase/LocalDateTimeConverter.cs new file mode 100644 index 0000000..ec9ee0d --- /dev/null +++ b/Elternbeirat.PocketBase/LocalDateTimeConverter.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elternbeirat.PocketBase; + +/// +/// Shared parsing of a PocketBase date string as wall-clock time. The trailing +/// "Z" is stripped rather than honoured, so the number is taken at face value and +/// the result carries -- no timezone shift. +/// +internal static class WallClock +{ + public static DateTime Parse(string raw) + { + // Drop a trailing "Z" so DateTime.Parse does not treat the value as UTC + // and convert it to local time (which would shift 19:30 to 20:30/21:30). + var value = raw.EndsWith('Z') ? raw[..^1] : raw; + var parsed = DateTime.Parse(value, CultureInfo.InvariantCulture, + DateTimeStyles.None); + return DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified); + } +} + +/// +/// Reads PocketBase date values as local wall-clock time. +/// +/// PocketBase stores every date in UTC and serializes it with a trailing "Z" +/// (e.g. "2026-10-08 19:30:00.000Z"). By project convention the stored number +/// IS the local time (Europe/Berlin) and the "Z" is ignored -- see the timezone +/// decision in the data model. This converter therefore parses the value and +/// returns it as an instant, so no +/// timezone shift is ever applied when the value is later formatted. +/// +/// +public sealed class LocalDateTimeConverter : JsonConverter +{ + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + return string.IsNullOrEmpty(raw) + ? default + : WallClock.Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", + CultureInfo.InvariantCulture)); +} + +/// +/// Nullable counterpart of . PocketBase sends +/// an empty string for an unset optional date (e.g. an event without an end); +/// that maps to null. +/// +public sealed class NullableLocalDateTimeConverter : JsonConverter +{ + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (string.IsNullOrEmpty(raw)) + return null; + + return WallClock.Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value is null) + writer.WriteStringValue(""); + else + writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", + CultureInfo.InvariantCulture)); + } +} diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs new file mode 100644 index 0000000..cb96f81 --- /dev/null +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -0,0 +1,59 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Elternbeirat.Contracts; + +namespace Elternbeirat.PocketBase; + +/// +/// Reads published content from a PocketBase instance over its REST API. +/// +/// One method per collection (pages, posts, events, faqs). Each returns only +/// records with public = true and lets PocketBase do the filtering and +/// sorting via query parameters. The is expected to have +/// its set to the PocketBase base URL, so it +/// is registered as a typed client via AddHttpClient. +/// +/// +public sealed class PocketBaseClient(HttpClient http) +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + options.Converters.Add(new LocalDateTimeConverter()); + options.Converters.Add(new NullableLocalDateTimeConverter()); + return options; + } + + /// Gets all public pages, ordered for navigation. + public Task> GetPagesAsync(CancellationToken ct = default) + => GetRecordsAsync("pages", "order", ct); + + /// Gets all public posts, newest first. + public Task> GetPostsAsync(CancellationToken ct = default) + => GetRecordsAsync("posts", "-date", ct); + + /// Gets all public events, earliest start first. + public Task> GetEventsAsync(CancellationToken ct = default) + => GetRecordsAsync("events", "start", ct); + + /// Gets all public FAQ entries. + public Task> GetFaqsAsync(CancellationToken ct = default) + => GetRecordsAsync("faqs", "topic", ct); + + private async Task> GetRecordsAsync(string collection, string sort, CancellationToken ct) + { + // filter=public=true keeps drafts out; perPage is large enough to fetch + // every record in a single page given the small content volume. + var url = $"/api/collections/{collection}/records" + + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + + $"&sort={Uri.EscapeDataString(sort)}"; + + var result = await http.GetFromJsonAsync>(url, JsonOptions, ct); + return result?.Items ?? []; + } +} diff --git a/Elternbeirat.PocketBase/RecordList.cs b/Elternbeirat.PocketBase/RecordList.cs new file mode 100644 index 0000000..8987ae2 --- /dev/null +++ b/Elternbeirat.PocketBase/RecordList.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Elternbeirat.PocketBase; + +/// +/// The envelope PocketBase wraps a records list response in. Only +/// is used; the paging fields are ignored because content volumes are small and the +/// client requests a large page size in a single call. +/// +/// The record type inside items. +internal sealed record RecordList +{ + [JsonPropertyName("items")] + public IReadOnlyList Items { get; init; } = []; +} diff --git a/Elternbeirat.Web.Tests/.editorconfig b/Elternbeirat.Web.Tests/.editorconfig new file mode 100644 index 0000000..f07d631 --- /dev/null +++ b/Elternbeirat.Web.Tests/.editorconfig @@ -0,0 +1,19 @@ +# Test-project-only overrides. This file inherits from the root .editorconfig +# (root = true there) and applies on top of it for everything under this folder. + +[*.cs] +# Test methods use Given_When_Then style names with underscores, which is the +# 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 bc3d902..6f7bbd9 100644 --- a/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj +++ b/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj @@ -8,19 +8,22 @@ - - - - - + + + + + + + + \ No newline at end of file diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs new file mode 100644 index 0000000..b90033b --- /dev/null +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -0,0 +1,99 @@ +using Elternbeirat.PocketBase; + +namespace Elternbeirat.Web.Tests; + +/// +/// 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(PocketBaseFixture pocketBase) : IClassFixture +{ + [Fact] + public async Task Events_load_sorted_by_start() + { + var client = pocketBase.CreateClient(); + + var events = await client.GetEventsAsync(); + + // 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 = pocketBase.CreateClient(); + + var events = await client.GetEventsAsync(); + + // 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); + meeting.Start.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public async Task Event_without_end_maps_to_null() + { + var client = pocketBase.CreateClient(); + + var events = await client.GetEventsAsync(); + + 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_load_newest_first() + { + 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(); + + 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..1c333ef --- /dev/null +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -0,0 +1,303 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Http.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 seed data is deliberately fixed here rather than +/// exported from the real instance, so tests assert against values this file +/// controls. 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); + + 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 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; } = ""; + } +} diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index 38200e9..919306d 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -48,9 +48,9 @@ public sealed class RouteSmokeTests : IClassFixture - - + +