Add PocketBase client with contracts and sharp build tooling
This commit is contained in:
1 parent
56922707ea
commit
251f2d818b
24 files changed
+578
-20
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.
|
||||
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>/_/.
|
||||
PB_URL=http://<unraid-host>:8090
|
||||
PB_ADMIN_EMAIL=
|
||||
PB_ADMIN_PASSWORD=
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elternbeirat.Web\Elternbeirat.Web.csproj" />
|
||||
<ProjectReference Include="..\Elternbeirat.PocketBase\Elternbeirat.PocketBase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</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 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]
|
||||
@@ -60,8 +60,8 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
||||
{
|
||||
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>
|
||||
<PackageReference Include="Markdig" Version="0.38.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 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>
|
||||
/// A single calendar entry (parents' evening, meeting, deadline). Read from
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Web.Services;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Elternbeirat.Web.Services;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// 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 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) =>
|
||||
@@ -93,11 +94,11 @@ public static class IcsCalendar
|
||||
|
||||
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
|
||||
private static string Escape(string value) => value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace(";", "\\;")
|
||||
.Replace(",", "\\,")
|
||||
.Replace("\r\n", "\\n")
|
||||
.Replace("\n", "\\n");
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal)
|
||||
.Replace("\r\n", "\\n", StringComparison.Ordinal)
|
||||
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||
|
||||
// ICS lines are terminated with CRLF regardless of platform.
|
||||
private static void AppendLine(StringBuilder sb, string line) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Elternbeirat.Web.Components;
|
||||
using Elternbeirat.Web.Features.Events;
|
||||
using Elternbeirat.Web.Services;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<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.Tests/Elternbeirat.Web.Tests.csproj" />
|
||||
</Solution>
|
||||
Reference in new issue
Block a user