using System.Net; using Microsoft.AspNetCore.Mvc.Testing; namespace Elternbeirat.Web.Tests; /// /// 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. /// /// The post routes now read from PocketBase, so the tests point the web app at the /// seeded container from (shared via the /// collection) and assert against its known slugs. Requires Docker, like the /// client tests. /// /// [Collection(PocketBaseTestGroup.Name)] public sealed 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 _baseFactory = new(); private readonly WebApplicationFactory _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(); } /// /// 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. /// public static TheoryData 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 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); } }