Files
GerbilManager/GerbilManager.Tests/SiteRendererTests.cs
Gulum 97acaf6b10 WEB-6a: Oeffentliche Webseite — Gestaltung & Grundseiten
- SiteRenderer: neues warmes, mobile-first CSS (Georgia-Headings, Scrollnav,
  site-footer-nav mit Impressum/Datenschutz-Links)
- AbgabetiereList: Gruppen-Karten statt Einzelkarten (Becken-Gruppierung,
  Geschlecht-Label, Verfuegbar-Badge, Foto-Strip, pro-Tier-Detail-Zeilen
  mit Farbschlag + geboren-am + Persoenlichkeit/CharacterNote)
- SiteSnapshotService: ForSale-Snapshot um gender, dateOfBirth, characterNote
  erweitert
- Migration AddImpressumDatenschutzPages: Impressum + Datenschutz als
  Platzhalter-Seiten (footer-only, kein navOrder-Eintrag)
- SeedCms: Impressum/Datenschutz auch in HasData (SQLite-Tests)
- Tests: CmsTests angepasst (8 Seiten), 6 neue SiteRendererTests
  (Gruppen-Karte, Geschlecht, Badge, DateOfBirth, Solo-Tier,
   Impressum/Datenschutz, Footer-Links)
Gate: dotnet 131/131 + vitest 78/78 + e2e 94/94 + build clean
2026-06-06 15:15:54 +02:00

411 lines
16 KiB
C#

using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Cms;
namespace GerbilManager.Tests;
/// <summary>
/// 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.
/// </summary>
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("<!DOCTYPE html>", html);
Assert.Contains("<title>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("&lt;script&gt;", 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);
}