489 lines
20 KiB
C#
489 lines
20 KiB
C#
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><link></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><footer></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><section></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></a></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><ul></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();
|
||
}
|