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);
}
}