Files
Elternbeirat/Elternbeirat.Web.Tests/RouteSmokeTests.cs
T

489 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Smoke tests: every known route must return 200, unknown routes must return
/// 404. This catches broken content files, renamed slugs or routing regressions
/// before a deploy.
/// <para>
/// The post routes now read from PocketBase, so the tests point the web app at the
/// seeded container from <see cref="PocketBaseFixture"/> (shared via the
/// collection) and assert against its known slugs. Requires Docker, like the
/// client tests.
/// </para>
/// </summary>
[Collection(PocketBaseTestGroup.Name)]
public sealed partial class RouteSmokeTests : IDisposable
{
// WithWebHostBuilder returns a new factory that wraps the base one; both are
// disposable, so both are held and disposed to avoid leaking either.
private readonly WebApplicationFactory<Program> _baseFactory = new();
private readonly WebApplicationFactory<Program> _factory;
public RouteSmokeTests(PocketBaseFixture pocketBase)
{
// Point the app's PocketBaseClient at the seeded test container instead of
// the value from appsettings. UseSetting (not ConfigureAppConfiguration)
// because it wins over appsettings.Development.json, which otherwise pins the
// client to localhost:8090 while the test container uses a random port.
_factory = WithClock(
_baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString())),
BeforeFixtureEvents);
}
/// <summary>
/// "Now" for the default factory: a week before the fixture's first event, so
/// "upcoming" stays true no matter when the tests run.
/// </summary>
private static readonly DateTimeOffset BeforeFixtureEvents = new(2026, 10, 1, 10, 0, 0, TimeSpan.Zero);
/// <summary>
/// Wraps <paramref name="factory"/> so the app's clock shows <paramref name="now"/>.
/// </summary>
private static WebApplicationFactory<Program> WithClock(WebApplicationFactory<Program> factory, DateTimeOffset now) =>
factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(new FixedTimeProvider(now))));
public void Dispose()
{
_factory.Dispose();
_baseFactory.Dispose();
}
/// <summary>
/// Every route that a visitor can reach through the navigation, the FAQ hub
/// or the news section. The post slugs match the fixture's seed.
/// </summary>
public static TheoryData<string> KnownRoutes =>
[
"/",
"/board",
"/patrons",
"/faqs",
"/faq-lunch",
"/downloads",
"/contact",
"/imprint",
"/privacy",
"/posts",
"/posts/new-board",
"/posts/new-hall",
"/events",
"/events.ics",
];
[Theory]
[MemberData(nameof(KnownRoutes))]
public async Task Known_route_returns_200(string route)
{
var client = _factory.CreateClient();
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Theory]
[InlineData("/gibt-es-nicht")]
[InlineData("/posts/gibt-es-nicht")]
[InlineData("/events/gibt-es-nicht.ics")]
public async Task Unknown_route_returns_404(string route)
{
var client = _factory.CreateClient();
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact]
public async Task Faqs_route_renders_the_topic_accordion()
{
// /faqs is a literal route (FaqList) that must win over the /{Slug}
// catch-all content page. It reads the "faqs" page for heading and intro
// and renders each topic as a collapsible group with its questions nested
// inside, so both the topic headings and the questions are in the markup.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative));
html.ShouldContain("FAQs"); // heading from the page title
html.ShouldContain("Mensa und Mittagessen"); // German topic heading (a group)
html.ShouldContain("Wann gibt es Mittagessen?"); // a question nested in the group
}
[Fact]
public async Task Layout_navigation_is_built_from_the_pages()
{
// The header and footer menus are generated from the "pages" records, not
// hard-coded. Assert a header link, a footer link, and that the non-public
// draft page never leaks into the menu.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain("href=\"/board\""); // header page
html.ShouldContain("href=\"/imprint\""); // footer page
html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
}
[Fact]
public async Task Footer_links_every_public_header_and_footer_page_in_order()
{
// The footer repeats the header pages as "Schnellzugriff" and then lists the
// footer pages, each column in its order field. Only the <footer> is checked,
// so the header menu cannot make the test pass on its own. The expected list
// mirrors the fixture's pages; the draft (public=false) must be missing.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var footer = FooterElement().Match(html);
footer.Success.ShouldBeTrue();
var hrefs = Href().Matches(footer.Value).Select(match => match.Groups[1].Value);
hrefs.ShouldBe(
[
"/", "/board", "/patrons", "/faqs", "/downloads", "/posts", "/events",
"/contact", "/imprint", "/privacy", "/faq-lunch",
]);
footer.Value.ShouldContain("Schnellzugriff");
footer.Value.ShouldContain("Weiteres");
}
[Fact]
public async Task Home_renders_the_embeds_its_page_opts_into()
{
// The home page's embed field is ["posts","events"], so the root route
// renders the posts teaser and the events teaser below the intro.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain("Aktuelle Beiträge"); // posts embed heading
html.ShouldContain("Neuer Vorstand"); // a seeded post title
html.ShouldContain("Kommende Termine"); // events embed heading
}
[Fact]
public async Task Content_page_renders_its_body()
{
// /patrons is a catch-all content page (the /{Slug} route). Assert it renders
// its Markdown body, proving the ContentPage path works for an arbitrary slug.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
html.ShouldContain("Der Förderverein unterstützt die Schule."); // the page body, rendered from Markdown
}
[Fact]
public async Task Content_page_body_carries_the_markdown_styles_and_blocks()
{
// The editor-content styles in app.css all hang off .markdown-body, and the
// design blocks off the class a ":::" container renders. Assert both reach
// the markup of a real page, so a renamed wrapper or a pipeline without
// custom containers fails here instead of silently unstyling the site.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
html.ShouldContain("class=\"markdown-body\"");
html.ShouldContain("<div class=\"kennzahlen\">");
}
[Fact]
public async Task Posts_and_events_render_their_page_heading_and_body()
{
// /posts and /events are served by PostList and EventList, which -- like
// FaqList -- read their own "posts"/"events" page record for heading and
// intro instead of hard-coding them. Assert both the title (as the h1) and
// the body text from the fixture records reach the markup.
var client = _factory.CreateClient();
var posts = await client.GetStringAsync(new Uri("/posts", UriKind.Relative));
posts.ShouldContain("Beiträge"); // heading from the page title
posts.ShouldContain("Neuigkeiten aus dem Elternbeirat."); // the page body
var events = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
events.ShouldContain("Termine"); // heading from the page title
events.ShouldContain("Sitzungen und Veranstaltungen auf einen Blick."); // the page body
}
[Theory]
[InlineData("/app.css")]
[InlineData("/")]
public async Task No_resource_is_loaded_from_a_foreign_host(string route)
{
// Privacy rule (docs/recht.md): nothing may make the visitor's browser contact
// a third-party host -- no Google Fonts, no CDN. Check the stylesheet and the
// rendered home page for anything that LOADS from an absolute URL (src,
// srcset, <link href>, CSS url() and @import). Plain <a href> links are left
// alone on purpose: an editor may link to another site, and a link loads
// nothing until the visitor clicks it.
var client = _factory.CreateClient();
var body = await client.GetStringAsync(new Uri(route, UriKind.Relative));
ForeignResource().Matches(body).Select(match => match.Value).ShouldBeEmpty();
}
[Fact]
public async Task Site_font_is_self_hosted()
{
// The @font-face must point at our own wwwroot with a relative URL, and the
// file must actually be served -- otherwise every visitor silently falls back
// to the system font.
var client = _factory.CreateClient();
var css = await client.GetStringAsync(new Uri("/app.css", UriKind.Relative));
css.ShouldContain("url(\"fonts/nunito-latin-wght.woff2\")");
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
font.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task Health_returns_200_when_PocketBase_is_reachable()
{
// The monitor's happy path: the app answers and PocketBase (the seeded
// fixture) is up, so /health is 200. This mostly guards the wiring -- that the
// endpoint exists and reaches the client -- since the fixture keeps PocketBase
// alive; the unreachable case is covered separately below.
var client = _factory.CreateClient();
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task Health_returns_503_when_PocketBase_is_unreachable()
{
// The case the monitor exists for: the app is up but PocketBase is not. Point a
// throwaway app at a dead address (nothing listens on port 1, so the probe is
// refused at once, no timeout wait) and assert /health reports 503 rather than
// claiming healthy.
await using var factory = _baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
}
[Fact]
public async Task Home_hero_takes_title_and_buttons_from_the_body()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain(">Willkommen</h1>");
html.ShouldNotContain(">Elternbeirat der IGMH</h1>"); // the fallback title
html.ShouldContain("""<a class="btn btn-primary" href="/events">""");
html.ShouldContain("""<a class="btn btn-secondary" href="/contact">""");
}
[Fact]
public async Task Home_tiles_are_exactly_the_public_header_pages()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var tiles = HomeTiles().Match(html);
tiles.Success.ShouldBeTrue();
Href().Matches(tiles.Value).Select(match => match.Groups[1].Value)
.ShouldBe(["/board", "/patrons", "/faqs", "/downloads", "/posts", "/events"]);
}
[Fact]
public async Task Home_shows_the_next_event_when_one_is_upcoming()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain("Nächster Termin");
html.ShouldContain("Elternbeiratssitzung");
}
[Fact]
public async Task Home_hides_the_next_event_when_none_is_upcoming()
{
// After the fixture's last event nothing is upcoming; the card must vanish
// instead of showing an empty frame.
await using var factory = WithClock(_factory, new DateTimeOffset(2027, 1, 1, 10, 0, 0, TimeSpan.Zero));
var client = factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldNotContain("Nächster Termin");
}
[Fact]
public async Task Event_cards_on_the_events_page_do_not_link_to_the_events_page()
{
// Events have no page of their own; a card linking to /events led the
// visitor back to where they already were. The navigation does link to
// /events, so only the card list is checked.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
var list = EventCards().Match(html);
list.Success.ShouldBeTrue();
Href().Matches(list.Value).Select(match => match.Groups[1].Value).ShouldAllBe(href => href.EndsWith(".ics", StringComparison.Ordinal));
}
[Fact]
public async Task Events_page_shows_its_intro_once_and_a_subscribe_button()
{
// The intro is the "events" page body; a hard-coded intro beside it used to
// say the same thing twice.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
Regex.Count(html, Regex.Escape("Sitzungen und Veranstaltungen auf einen Blick.")).ShouldBe(1);
html.ShouldContain("""<a class="btn btn-secondary" href="/events.ics">""");
html.ShouldContain("Kalender abonnieren");
}
[Fact]
public async Task Events_page_folds_past_events_away()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
var past = PastEvents().Match(html);
past.Success.ShouldBeTrue();
past.Value.ShouldContain("Vergangene Termine anzeigen (2)");
past.Value.ShouldContain("Infoabend Klasse 5");
past.Value.ShouldNotContain("Elternbeiratssitzung"); // upcoming, not past
past.Value.ShouldContain("""<h3 class="past-events-year">2026</h3>""");
past.Value.ShouldContain("10. September"); // day without weekday or year
past.Value.ShouldContain("19:00 Uhr"); // the time stays, after the title
past.Value.ShouldContain("20.–22. Juli"); // several days as a span
Regex.Count(past.Value, "Uhr").ShouldBe(1); // the all-day Projekttage get no time
}
[Fact]
public async Task Single_event_calendar_file_holds_exactly_that_event()
{
// The "In Kalender übernehmen" link of a card leads to a calendar with just
// that one event. Take the link from the page, as a visitor would.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
var href = Href().Matches(EventCards().Match(html).Value)
.Select(match => match.Groups[1].Value)
.First();
var response = await client.GetAsync(new Uri(href, UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/calendar");
var ics = await response.Content.ReadAsStringAsync();
ics.ShouldStartWith("BEGIN:VCALENDAR");
Regex.Count(ics, "BEGIN:VEVENT").ShouldBe(1);
ics.ShouldContain("SUMMARY:Elternbeiratssitzung"); // the earliest upcoming card
}
[Fact]
public async Task Home_event_cards_link_to_the_events_page()
{
// On the home page the cards are a teaser and may lead to the full list.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var list = EventCards().Match(html);
list.Success.ShouldBeTrue();
Href().Matches(list.Value).Select(match => match.Groups[1].Value).ShouldAllBe(href => href == "/events");
html.ShouldContain("class=\"calendar-sheet\"");
}
[Fact]
public async Task Home_next_event_shows_the_time_and_keeps_the_full_date_for_screen_readers()
{
// The calendar sheet already shows the day, so the written-out date is only
// for screen readers; visible beside the sheet is the time. The meeting is
// 19:30 Berlin on Thursday, 8 October 2026 (fixture, test clock 1 October).
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var card = NextEventCard().Match(html);
card.Success.ShouldBeTrue();
var hidden = VisuallyHidden().Match(card.Value);
hidden.Value.ShouldContain("Donnerstag, 8. Oktober 2026, 19:30 Uhr");
var visible = card.Value.Replace(hidden.Value, "", StringComparison.Ordinal);
visible.ShouldContain("19:30 Uhr");
visible.ShouldNotContain("Oktober 2026");
}
/// <summary>
/// Matches a resource reference that points at an absolute URL, with or without
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
/// <c>src</c>/<c>srcset</c> attributes, a <c>&lt;link&gt;</c>'s <c>href</c>, CSS
/// <c>url()</c> and <c>@import</c>.
/// </summary>
[GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|<link\b[^>]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
private static partial Regex ForeignResource();
/// <summary>
/// Matches the page's <c>&lt;footer&gt;</c> element, from its opening tag to the
/// closing one. The layout renders exactly one footer and nests no other.
/// </summary>
[GeneratedRegex("""<footer\b.*?</footer>""", RegexOptions.Singleline)]
private static partial Regex FooterElement();
/// <summary>
/// Matches an <c>href</c> attribute and captures its value.
/// </summary>
[GeneratedRegex(@"\bhref=""([^""]*)""")]
private static partial Regex Href();
/// <summary>
/// Matches the home page's tile section, from its opening tag to the closing
/// one. It nests no other <c>&lt;section&gt;</c>.
/// </summary>
[GeneratedRegex("""<section class="home-tiles".*?</section>""", RegexOptions.Singleline)]
private static partial Regex HomeTiles();
/// <summary>
/// Matches the "Nächster Termin" card in the home hero. It holds no other link,
/// so the first closing <c>&lt;/a&gt;</c> ends it.
/// </summary>
[GeneratedRegex("""<a class="next-event".*?</a>""", RegexOptions.Singleline)]
private static partial Regex NextEventCard();
/// <summary>
/// Matches the first text hidden for sighted visitors but read by screen readers.
/// </summary>
[GeneratedRegex("""<span class="visually-hidden".*?</span>""", RegexOptions.Singleline)]
private static partial Regex VisuallyHidden();
/// <summary>
/// Matches the first list of event cards, from its opening tag to the closing one.
/// The cards nest no other <c>&lt;ul&gt;</c>.
/// </summary>
[GeneratedRegex("""<ul class="event-list".*?</ul>""", RegexOptions.Singleline)]
private static partial Regex EventCards();
/// <summary>
/// Matches the folded-up past events on the events page.
/// </summary>
[GeneratedRegex("""<details class="past-events[ "].*?</details>""", RegexOptions.Singleline)]
private static partial Regex PastEvents();
}