using System.Net; using System.Text.Json; using System.Text.Json.Nodes; using GerbilManagerWebAPI.Cms; namespace GerbilManager.Tests; /// /// WEB-1: static site renderer — snapshot → HTML file map. /// All tests are pure unit tests (no DB, no HTTP); they build JsonObject /// snapshots directly and assert on rendered HTML. /// public class SiteRendererTests { // ── Snapshot builder helpers ───────────────────────────────────────────── private static JsonObject MakeSnapshot(params (string slug, string title, JsonArray blocks)[] pages) { var navOrder = new JsonArray(); var pagesArr = new JsonArray(); foreach (var (slug, title, blocks) in pages) { navOrder.Add(slug); pagesArr.Add(new JsonObject { ["slug"] = slug, ["title"] = title, ["seoDescription"] = $"SEO für {title}", ["status"] = "Published", ["blocks"] = blocks, }); } return new JsonObject { ["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = navOrder }, ["pages"] = pagesArr, }; } private static JsonArray Blocks(params JsonObject[] blocks) { var arr = new JsonArray(); int i = 0; foreach (var b in blocks) { b["order"] = i++; arr.Add(b); } return arr; } private static JsonObject HeadingBlock(string text, int level = 1) => new() { ["type"] = "Heading", ["data"] = new JsonObject { ["text"] = text, ["level"] = level }, }; private static JsonObject RichTextBlock(string md) => new() { ["type"] = "RichText", ["data"] = new JsonObject { ["markdown"] = md }, }; private static JsonObject ImageBlock(string url, string alt) => new() { ["type"] = "Image", ["data"] = new JsonObject { ["url"] = url, ["alt"] = alt }, }; private static JsonObject ContactInfoBlock(string name, string email, string phone) => new() { ["type"] = "ContactInfo", ["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone }, }; private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals) { var animalArr = new JsonArray(); foreach (var (name, farbe, group, photo) in animals) { var photos = new JsonArray(); if (photo is not null) photos.Add(photo); animalArr.Add(new JsonObject { ["name"] = name, ["farbschlag"] = farbe, ["group"] = group, ["photos"] = photos, ["aiSaleText"] = (JsonNode?)null, }); } return new JsonObject { ["type"] = "AbgabetiereList", ["data"] = new JsonObject { ["mode"] = "auto", ["intro"] = intro, ["animals"] = animalArr }, }; } // ── Tests ──────────────────────────────────────────────────────────────── [Fact] public void Render_produces_index_html_for_start_page() { var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Willkommen")))); var files = SiteRenderer.Render(snap); Assert.True(files.ContainsKey("index.html")); } [Fact] public void Render_produces_slug_subfolder_for_non_start_pages() { var snap = MakeSnapshot( ("start", "Start", Blocks()), ("abgabetiere", "Abgabetiere", Blocks())); var files = SiteRenderer.Render(snap); Assert.True(files.ContainsKey("abgabetiere/index.html")); } [Fact] public void Render_includes_css_asset() { var snap = MakeSnapshot(("start", "Start", Blocks())); var files = SiteRenderer.Render(snap); Assert.True(files.ContainsKey(SiteRenderer.CssPath)); Assert.Contains("site-header", files[SiteRenderer.CssPath]); } [Fact] public void Page_html_is_valid_doctype_and_contains_title() { var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Hallo")))); var html = SiteRenderer.Render(snap)["index.html"]; Assert.StartsWith("", html); Assert.Contains("Startseite |", html); Assert.Contains("lang=\"de\"", html); } [Fact] public void Page_html_includes_seo_description() { var snap = MakeSnapshot(("start", "Start", Blocks())); // seoDescription is set to "SEO für Start" by our helper var html = SiteRenderer.Render(snap)["index.html"]; Assert.Contains("meta name=\"description\"", html); Assert.Contains("SEO für Start", html); } [Fact] public void Nav_links_are_rendered_with_all_slugs() { var snap = MakeSnapshot( ("start", "Start", Blocks()), ("abgabetiere", "Abgabetiere", Blocks()), ("kontakt", "Kontakt", Blocks())); var html = SiteRenderer.Render(snap)["index.html"]; Assert.Contains("Abgabetiere", html); Assert.Contains("Kontakt", html); } [Fact] public void Current_page_nav_link_has_aria_current() { var snap = MakeSnapshot( ("start", "Start", Blocks()), ("abgabetiere", "Abgabetiere", Blocks())); var abgHtml = SiteRenderer.Render(snap)["abgabetiere/index.html"]; Assert.Contains("aria-current=\"page\"", abgHtml); } [Fact] public void Heading_block_renders_h_tag_with_text() { var snap = MakeSnapshot(("start", "Start", Blocks(HeadingBlock("Meine Rennmäuse", 2)))); var html = SiteRenderer.Render(snap)["index.html"]; Assert.Contains("<h2", html); Assert.Contains("Meine Rennmäuse", html); } [Fact] public void RichText_block_renders_markdown_bold_and_paragraph() { var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("**Hallo** Welt")))); var html = SiteRenderer.Render(snap)["index.html"]; Assert.Contains("<strong>Hallo</strong>", html); } [Fact] public void RichText_block_renders_markdown_list() { var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("- Eins\n- Zwei")))); var html = SiteRenderer.Render(snap)["index.html"]; Assert.Contains("<ul>", html); Assert.Contains("<li>", html); Assert.Contains("Eins", html); } [Fact] public void Image_block_renders_img_with_alt() { var snap = MakeSnapshot(("start", "Start", Blocks(ImageBlock("/img/maus.jpg", "Eine Rennmaus")))); var html = SiteRenderer.Render(snap)["index.html"]; Assert.Contains("src=\"/img/maus.jpg\"", html); Assert.Contains("alt=\"Eine Rennmaus\"", html); } [Fact] public void ContactInfo_block_renders_name_and_email() { var snap = MakeSnapshot(("kontakt", "Kontakt", Blocks(ContactInfoBlock("Kleine Chaoten", "info@kleine-chaoten.de", "+49 123 456789")))); var html = SiteRenderer.Render(snap)["kontakt/index.html"]; Assert.Contains("Kleine Chaoten", html); Assert.Contains("info@kleine-chaoten.de", html); Assert.Contains("+49 123 456789", html); } [Fact] public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag() { var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg"))))); var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; Assert.Contains("Krümel", html); Assert.Contains("CP-Agouti", html); Assert.Contains("Großbecken", html); Assert.Contains("/photos/files/abc.jpg", html); Assert.Contains("cms-animal-card", html); } [Fact] public void AbgabetiereList_empty_shows_placeholder_text() { var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock("")))); var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; Assert.Contains("cms-abgabe-empty", html); } [Fact] public void Draft_pages_are_excluded_from_output() { var pages = new JsonArray { new JsonObject { ["slug"] = "start", ["title"] = "Start", ["status"] = "Published", ["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() }, new JsonObject { ["slug"] = "entwurf", ["title"] = "Entwurf", ["status"] = "Draft", ["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() }, }; var snap = new JsonObject { ["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = new JsonArray { "start", "entwurf" } }, ["pages"] = pages, }; var files = SiteRenderer.Render(snap); Assert.True(files.ContainsKey("index.html")); Assert.False(files.ContainsKey("entwurf/index.html")); } [Fact] public void Html_encoding_prevents_xss_in_page_title() { var snap = MakeSnapshot(("start", "<script>alert(1)</script>", Blocks())); var html = SiteRenderer.Render(snap)["index.html"]; Assert.DoesNotContain("<script>", html); Assert.Contains("<script>", html); } [Fact] public void Render_six_seeded_pages_produces_expected_paths() { var snap = MakeSnapshot( ("start", "Startseite", Blocks()), ("ueber-die-zucht", "Über die Zucht", Blocks()), ("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock(""))), ("abgabebedingungen", "Abgabebedingungen", Blocks()), ("farben-genetik", "Farben & Genetik", Blocks()), ("kontakt", "Kontakt", Blocks())); var files = SiteRenderer.Render(snap); Assert.True(files.ContainsKey("index.html")); Assert.True(files.ContainsKey("ueber-die-zucht/index.html")); Assert.True(files.ContainsKey("abgabetiere/index.html")); Assert.True(files.ContainsKey("abgabebedingungen/index.html")); Assert.True(files.ContainsKey("farben-genetik/index.html")); Assert.True(files.ContainsKey("kontakt/index.html")); Assert.True(files.ContainsKey(SiteRenderer.CssPath)); // 6 pages + 1 CSS Assert.Equal(7, files.Count); } // ── Markdown tests ─────────────────────────────────────────────────────── [Theory] [InlineData("**fett**", "<strong>fett</strong>")] [InlineData("*kursiv*", "<em>kursiv</em>")] [InlineData("`code`", "<code>code</code>")] public void Markdown_inline_renders_correctly(string md, string expectedFragment) { var html = SiteRenderer.MarkdownToHtml(md); Assert.Contains(expectedFragment, html); } [Fact] public void Markdown_heading_renders_h_tag() { var html = SiteRenderer.MarkdownToHtml("## Abschnitt"); Assert.Contains("<h2>Abschnitt</h2>", html); } [Fact] public void Markdown_xss_text_is_encoded() { var html = SiteRenderer.MarkdownToHtml("<script>alert(1)</script>"); Assert.DoesNotContain("<script>", html); } } /// <summary>WEB-1: smoke-test the /api/render-site endpoint via ApiFactory.</summary> public class RenderSiteEndpointTests : IClassFixture<ApiFactory> { private readonly HttpClient _client; public RenderSiteEndpointTests(ApiFactory factory) => _client = factory.CreateClient(); [Fact] public async Task RenderSite_returns_file_list_with_css_and_pages() { var json = await _client.GetStringAsync("/api/render-site"); var files = JsonSerializer.Deserialize<List<RenderEntry>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; Assert.Contains(files, f => f.Path == SiteRenderer.CssPath); Assert.Contains(files, f => f.Path == "index.html"); Assert.All(files, f => Assert.True(f.Size > 0)); } private record RenderEntry(string Path, int Size); }