PocketBase as the data layer: client, compose stack, dev environment and tests #28
No files matched your search
@@ -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<T>) 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
|
||||||
@@ -9,3 +9,10 @@ GITEA_TOKEN=
|
|||||||
# Base URL of the Gitea instance and the repo path issues belong to.
|
# Base URL of the Gitea instance and the repo path issues belong to.
|
||||||
GITEA_URL=https://gitea.anticarnist.de
|
GITEA_URL=https://gitea.anticarnist.de
|
||||||
GITEA_REPO=Tom/Elternbeirat
|
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>/_/.
|
||||||
|
PB_URL=http://<unraid-host>:8090
|
||||||
|
PB_ADMIN_EMAIL=
|
||||||
|
PB_ADMIN_PASSWORD=
|
||||||
@@ -4,6 +4,9 @@ obj/
|
|||||||
riderModule.iml
|
riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
.idea/
|
.idea/
|
||||||
|
# Per-user Rider/ReSharper settings (personal, not shared).
|
||||||
|
*.sln.DotSettings.user
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
|
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
|
||||||
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
|
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<!-- Solution-wide build settings, applied to every project. Package versions
|
||||||
|
live in Directory.Packages.props; this file carries code-quality settings. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Turn on the full set of built-in .NET analyzers (code quality + style),
|
||||||
|
including the opt-in rules. -->
|
||||||
|
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||||
|
<AnalysisMode>All</AnalysisMode>
|
||||||
|
<AnalysisLevel>latest-all</AnalysisLevel>
|
||||||
|
|
||||||
|
<!-- Enforce .editorconfig code-style rules as part of the build, not just
|
||||||
|
in the IDE. -->
|
||||||
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
|
||||||
|
<!-- Any warning fails the build, so quality issues cannot be ignored. -->
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<!-- Central Package Management: NuGet versions live here, the project files
|
||||||
|
reference packages by name only. See
|
||||||
|
https://learn.microsoft.com/nuget/consume-packages/central-package-management -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Markdig" Version="0.38.0" />
|
||||||
|
<PackageVersion Include="YamlDotNet" Version="16.2.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Test-only packages. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
/// <summary>A calendar entry. Sorted by <see cref="Start"/>.</summary>
|
||||||
|
public record Event
|
||||||
|
{
|
||||||
|
/// <summary>PocketBase record id.</summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("start")]
|
||||||
|
public DateTime Start { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional end of the event; null when unset.</summary>
|
||||||
|
[JsonPropertyName("end")]
|
||||||
|
public DateTime? End { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Event name, e.g. "Elternbeiratssitzung".</summary>
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Optional location, e.g. "Aula".</summary>
|
||||||
|
[JsonPropertyName("location")]
|
||||||
|
public string Location { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Optional note, e.g. "Anmeldung erforderlich".</summary>
|
||||||
|
[JsonPropertyName("note")]
|
||||||
|
public string Note { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Whether the event is visible to visitors.</summary>
|
||||||
|
[JsonPropertyName("public")]
|
||||||
|
public bool Public { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single question and answer, grouped on the FAQ page by <see cref="Topic"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record Faq
|
||||||
|
{
|
||||||
|
/// <summary>PocketBase record id.</summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>The question as a parent would phrase it.</summary>
|
||||||
|
[JsonPropertyName("question")]
|
||||||
|
public string Question { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>The answer in Markdown.</summary>
|
||||||
|
[JsonPropertyName("answer")]
|
||||||
|
public string Answer { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Topic the question is grouped under, e.g. "mensa".</summary>
|
||||||
|
[JsonPropertyName("topic")]
|
||||||
|
public string Topic { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Whether the question is visible to visitors.</summary>
|
||||||
|
[JsonPropertyName("public")]
|
||||||
|
public bool Public { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A content page. Pages also drive the site navigation: <see cref="Location"/>
|
||||||
|
/// and <see cref="Order"/> decide where and in which order a page appears in the
|
||||||
|
/// header or footer menu, and <see cref="Embed"/> lists dynamic blocks (posts,
|
||||||
|
/// events, faqs) rendered below the page body.
|
||||||
|
/// </summary>
|
||||||
|
public record Page
|
||||||
|
{
|
||||||
|
/// <summary>PocketBase record id.</summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Heading shown to visitors; may contain umlauts and spaces.</summary>
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Page body in Markdown.</summary>
|
||||||
|
[JsonPropertyName("body")]
|
||||||
|
public string Body { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Where the page appears in the navigation: "header" or "footer".</summary>
|
||||||
|
[JsonPropertyName("location")]
|
||||||
|
public string Location { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Sort order within its navigation location; smaller is earlier.</summary>
|
||||||
|
[JsonPropertyName("order")]
|
||||||
|
public double Order { get; init; }
|
||||||
|
|
||||||
|
/// <summary>URL slug (lowercase, no umlauts), e.g. "board" -> /board.</summary>
|
||||||
|
[JsonPropertyName("slug")]
|
||||||
|
public string Slug { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dynamic blocks to render below the body: any of "posts", "events", "faqs".
|
||||||
|
/// Empty for a plain text page.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("embed")]
|
||||||
|
public IReadOnlyList<string> Embed { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether the page is visible to visitors.</summary>
|
||||||
|
[JsonPropertyName("public")]
|
||||||
|
public bool Public { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
/// <summary>A news post. Sorted by <see cref="Date"/>, newest first.</summary>
|
||||||
|
public record Post
|
||||||
|
{
|
||||||
|
/// <summary>PocketBase record id.</summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publication date. Stored as UTC by PocketBase but read as local time
|
||||||
|
/// (Europe/Berlin) by convention; only the date part is shown.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("date")]
|
||||||
|
public DateTime Date { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Post heading shown to visitors; may contain umlauts and spaces.</summary>
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Post body in Markdown.</summary>
|
||||||
|
[JsonPropertyName("body")]
|
||||||
|
public string Body { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar.</summary>
|
||||||
|
[JsonPropertyName("slug")]
|
||||||
|
public string Slug { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Whether the post is visible to visitors.</summary>
|
||||||
|
[JsonPropertyName("public")]
|
||||||
|
public bool Public { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Elternbeirat.Contracts\Elternbeirat.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Elternbeirat.PocketBase;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="DateTimeKind.Unspecified"/> -- no timezone shift.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads PocketBase date values as local wall-clock time.
|
||||||
|
/// <para>
|
||||||
|
/// 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 <see cref="DateTimeKind.Unspecified"/> instant, so no
|
||||||
|
/// timezone shift is ever applied when the value is later formatted.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nullable counterpart of <see cref="LocalDateTimeConverter"/>. PocketBase sends
|
||||||
|
/// an empty string for an unset optional date (e.g. an event without an end);
|
||||||
|
/// that maps to null.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.PocketBase;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads published content from a PocketBase instance over its REST API.
|
||||||
|
/// <para>
|
||||||
|
/// One method per collection (pages, posts, events, faqs). Each returns only
|
||||||
|
/// records with <c>public = true</c> and lets PocketBase do the filtering and
|
||||||
|
/// sorting via query parameters. The <see cref="HttpClient"/> is expected to have
|
||||||
|
/// its <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL, so it
|
||||||
|
/// is registered as a typed client via <c>AddHttpClient</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets all public pages, ordered for navigation.</summary>
|
||||||
|
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
|
||||||
|
=> GetRecordsAsync<Page>("pages", "order", ct);
|
||||||
|
|
||||||
|
/// <summary>Gets all public posts, newest first.</summary>
|
||||||
|
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
|
||||||
|
=> GetRecordsAsync<Post>("posts", "-date", ct);
|
||||||
|
|
||||||
|
/// <summary>Gets all public events, earliest start first.</summary>
|
||||||
|
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
|
||||||
|
=> GetRecordsAsync<Event>("events", "start", ct);
|
||||||
|
|
||||||
|
/// <summary>Gets all public FAQ entries.</summary>
|
||||||
|
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
|
||||||
|
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(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<RecordList<T>>(url, JsonOptions, ct);
|
||||||
|
return result?.Items ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Elternbeirat.PocketBase;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/>
|
||||||
|
/// is used; the paging fields are ignored because content volumes are small and the
|
||||||
|
/// client requests a large page size in a single call.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The record type inside <c>items</c>.</typeparam>
|
||||||
|
internal sealed record RecordList<T>
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public IReadOnlyList<T> Items { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 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
|
||||||
@@ -8,19 +8,22 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="Shouldly" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
|
<Using Include="Shouldly" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Elternbeirat.Web\Elternbeirat.Web.csproj" />
|
<ProjectReference Include="..\Elternbeirat.Web\Elternbeirat.Web.csproj" />
|
||||||
|
<ProjectReference Include="..\Elternbeirat.PocketBase\Elternbeirat.PocketBase.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PocketBaseClientTests : IDisposable
|
||||||
|
{
|
||||||
|
// 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()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
if (client is null) return; // PB_URL not configured; skip live test.
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 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));
|
||||||
|
meeting.ShouldNotBeNull();
|
||||||
|
meeting.Start.Hour.ShouldBe(19);
|
||||||
|
meeting.Start.Minute.ShouldBe(30);
|
||||||
|
meeting.Start.Kind.ShouldBe(DateTimeKind.Unspecified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pages_load_with_navigation_fields()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
if (client is null) return; // PB_URL not configured; skip live test.
|
||||||
|
|
||||||
|
var pages = await client.GetPagesAsync();
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Posts_and_faqs_load_from_live_instance()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
if (client is null) return; // PB_URL not configured; skip live test.
|
||||||
|
|
||||||
|
var posts = await client.GetPostsAsync();
|
||||||
|
var faqs = await client.GetFaqsAsync();
|
||||||
|
|
||||||
|
posts.ShouldNotBeEmpty();
|
||||||
|
faqs.ShouldNotBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,9 +48,9 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
|||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
var response = await client.GetAsync(route);
|
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -60,8 +60,8 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
|||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
var response = await client.GetAsync(route);
|
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Markdig" Version="0.38.0" />
|
<PackageReference Include="Markdig" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
<PackageReference Include="YamlDotNet" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Elternbeirat.Web.Services;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single calendar entry (parents' evening, meeting, deadline). Read from
|
/// A single calendar entry (parents' evening, meeting, deadline). Read from
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Elternbeirat.Web.Services;
|
||||||
using YamlDotNet.Serialization;
|
using YamlDotNet.Serialization;
|
||||||
using YamlDotNet.Serialization.NamingConventions;
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Services;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
|
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Elternbeirat.Web.Services;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Services;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds an iCalendar (RFC 5545) document from the events so visitors can
|
/// Builds an iCalendar (RFC 5545) document from the events so visitors can
|
||||||
@@ -82,7 +83,7 @@ public static class IcsCalendar
|
|||||||
{
|
{
|
||||||
var seed = $"{ev.Title}|{ev.Start:O}";
|
var seed = $"{ev.Title}|{ev.Start:O}";
|
||||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||||
return $"{Convert.ToHexString(hash)[..16].ToLowerInvariant()}@elternbeirat-igmh";
|
return $"{Convert.ToHexString(hash)[..16]}@elternbeirat-igmh";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Local(DateTime value) =>
|
private static string Local(DateTime value) =>
|
||||||
@@ -93,11 +94,11 @@ public static class IcsCalendar
|
|||||||
|
|
||||||
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
|
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
|
||||||
private static string Escape(string value) => value
|
private static string Escape(string value) => value
|
||||||
.Replace("\\", "\\\\")
|
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
.Replace(";", "\\;")
|
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||||
.Replace(",", "\\,")
|
.Replace(",", "\\,", StringComparison.Ordinal)
|
||||||
.Replace("\r\n", "\\n")
|
.Replace("\r\n", "\\n", StringComparison.Ordinal)
|
||||||
.Replace("\n", "\\n");
|
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||||
|
|
||||||
// ICS lines are terminated with CRLF regardless of platform.
|
// ICS lines are terminated with CRLF regardless of platform.
|
||||||
private static void AppendLine(StringBuilder sb, string line) =>
|
private static void AppendLine(StringBuilder sb, string line) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Elternbeirat.Web.Components;
|
using Elternbeirat.Web.Components;
|
||||||
|
using Elternbeirat.Web.Features.Events;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Web.Services;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
|
<Project Path="Elternbeirat.Contracts/Elternbeirat.Contracts.csproj" />
|
||||||
|
<Project Path="Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj" />
|
||||||
<Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" />
|
<Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" />
|
||||||
<Project Path="Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj" />
|
<Project Path="Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
Reference in new issue
Block a user