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, string? gender, string? dob, string? note)[] animals) { var animalArr = new JsonArray(); foreach (var (name, farbe, group, photo, gender, dob, note) in animals) { var photos = new JsonArray(); if (photo is not null) photos.Add(photo); animalArr.Add(new JsonObject { ["name"] = name, ["farbschlag"] = farbe, ["group"] = group, ["gender"] = gender, ["dateOfBirth"] = dob, ["characterNote"] = note, ["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_group_card_with_name_farbschlag_and_photo() { var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null))))); 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-group-card", html); } [Fact] public void AbgabetiereList_group_card_shows_gender_label_and_available_badge() { var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock("", ("Frieda", "Agouti", "Becken 1", null, "female", null, null), ("Rosa", "PEW", "Becken 1", null, "female", null, null))))); var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; Assert.Contains("Weibchen", html); Assert.Contains("Verfügbar", html); Assert.Contains("cms-badge--free", html); } [Fact] public void AbgabetiereList_shows_dateOfBirth_and_characterNote() { var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock("", ("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt."))))); var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; Assert.Contains("März 2024", html); Assert.Contains("Sehr neugierig und verspielt.", html); } [Fact] public void AbgabetiereList_solo_animal_without_group_gets_its_own_card() { var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock("", ("Einzelkind", "Zobel", null, null, "male", null, null))))); var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; Assert.Contains("Einzelkind", html); Assert.Contains("Männchen", html); Assert.Contains("cms-group-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); } [Fact] public void Render_impressum_and_datenschutz_pages_are_rendered() { var snap = MakeSnapshot( ("start", "Startseite", Blocks()), ("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))), ("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter")))); var files = SiteRenderer.Render(snap); Assert.True(files.ContainsKey("impressum/index.html")); Assert.True(files.ContainsKey("datenschutz/index.html")); Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]); } [Fact] public void Footer_contains_impressum_and_datenschutz_links() { var snap = MakeSnapshot( ("start", "Startseite", Blocks()), ("kontakt", "Kontakt", Blocks())); var startHtml = SiteRenderer.Render(snap)["index.html"]; Assert.Contains("impressum/index.html", startHtml); Assert.Contains("datenschutz/index.html", startHtml); // links in footer, not nav Assert.Contains("site-footer-link", startHtml); } [Fact] public void Footer_links_use_correct_relative_path_from_subpage() { var snap = MakeSnapshot( ("start", "Startseite", Blocks()), ("kontakt", "Kontakt", Blocks())); var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"]; // subpage needs ../ prefix for footer links Assert.Contains("../impressum/index.html", kontaktHtml); Assert.Contains("../datenschutz/index.html", kontaktHtml); } // ── 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); }