Serve static maintenance page with 503 when PocketBase is unreachable

This commit is contained in:
tleininger committed 2026-10-01 17:12:00 +02:00
1 parent ed6aa5d5ec
commit 7e56e83953
7 files changed
+500 -4

No files matched your search

@@ -0,0 +1,73 @@
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the health cache behind the maintenance gate. The probe is a fake
/// that counts its calls, so no PocketBase is needed.
/// </summary>
public class PocketBaseHealthCacheTests
{
private readonly SteppingClock _clock = new(new DateTimeOffset(2026, 10, 1, 10, 0, 0, TimeSpan.Zero));
private int _probes;
private Func<CancellationToken, Task<bool>> Probe(bool healthy) =>
_ =>
{
_probes++;
return Task.FromResult(healthy);
};
[Fact]
public async Task Result_is_reused_within_the_lifetime()
{
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
_clock.Now += PocketBaseHealthCache.Lifetime - TimeSpan.FromSeconds(1);
var healthy = await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
healthy.ShouldBeTrue();
_probes.ShouldBe(1);
}
[Fact]
public async Task Stale_result_is_probed_again()
{
// The way back after an outage: once the lifetime is over, the new answer
// replaces the old one.
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
_clock.Now += PocketBaseHealthCache.Lifetime;
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
healthy.ShouldBeTrue();
_probes.ShouldBe(2);
}
[Fact]
public async Task Unhealthy_result_is_cached_too()
{
// While PocketBase is down, not every request should wait for a failing probe.
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
healthy.ShouldBeFalse();
_probes.ShouldBe(1);
}
/// <summary>
/// A clock the test moves forward by hand, to step past the cache lifetime without
/// waiting.
/// </summary>
/// <param name="start">The moment the clock shows at first.</param>
private sealed class SteppingClock(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Now;
}
}
+56
View File
@@ -308,8 +308,64 @@ public sealed partial class RouteSmokeTests : IDisposable
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
(await response.Content.ReadAsStringAsync()).ShouldBe("PocketBase unreachable");
}
[Theory]
[InlineData("/")]
[InlineData("/board")]
[InlineData("/posts/new-board")]
public async Task Page_shows_maintenance_page_when_PocketBase_is_unreachable(string route)
{
// The maintenance gate: with PocketBase down, a page is not rendered half-empty
// but answered with the static maintenance.html, a 503 and a hint when to retry.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
response.Headers.RetryAfter.ShouldNotBeNull();
(await response.Content.ReadAsStringAsync()).ShouldContain("Die Seite ist gerade nicht erreichbar");
}
[Fact]
public async Task Calendar_feed_stays_an_empty_calendar_when_PocketBase_is_unreachable()
{
// Not a page, so the gate lets it through: subscribed calendar apps keep
// getting a valid (empty) calendar instead of an HTML page.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri("/events.ics", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
(await response.Content.ReadAsStringAsync()).ShouldContain("BEGIN:VCALENDAR");
}
[Fact]
public async Task Maintenance_page_and_its_font_stay_reachable_when_PocketBase_is_unreachable()
{
// Static files are not gated: the maintenance page loads its self-hosted font
// from /fonts while PocketBase is down.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var page = await client.GetAsync(new Uri("/maintenance.html", UriKind.Relative));
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
page.StatusCode.ShouldBe(HttpStatusCode.OK);
font.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// A throwaway app pointed at a dead address: nothing listens on port 1, so every
/// PocketBase call is refused at once, without a timeout wait.
/// </summary>
private WebApplicationFactory<Program> UnreachablePocketBase() =>
_baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
[Fact]
public async Task Home_hero_takes_title_and_buttons_from_the_body()
{