234 lines
9.5 KiB
C#
234 lines
9.5 KiB
C#
using System.Net;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
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 = _baseFactory.WithWebHostBuilder(builder =>
|
|
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString()));
|
|
}
|
|
|
|
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")]
|
|
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 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("Förderverein"); // the page body, rendered from Markdown
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <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();
|
|
}
|