WEB-1: static site renderer (SiteRenderer, dry-run endpoint, 22 tests)
GerbilManagerWebAPI/Cms/SiteRenderer.cs:
- Render(JsonObject snapshot) -> IReadOnlyDictionary<string,string> (path->content)
- Covers all 6 block types: Heading, RichText, Image, Gallery, ContactInfo,
AbgabetiereList (auto-resolved animals from snapshot; empty placeholder)
- start -> index.html; other slugs -> {slug}/index.html; assets/site.css
- Draft pages excluded; nav links with aria-current for current page
- Minimal Markdown->HTML (headings, bold, italic, code, links, ul/ol) --
no extra dependencies, deterministic output
- H() encodes only the 5 HTML special chars (not umlauts) for UTF-8 pages
- Mobile-first CSS: site header+nav, animal card grid, responsive breakpoints,
warm off-white palette (style reference: Jimdo Kleine Chaoten)
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs:
- GET /api/render-site -- dry-run: returns [{path, size}] for each rendered file
GerbilManager.Tests/SiteRendererTests.cs:
- 21 unit tests (pure JsonObject, no DB): file paths, HTML structure, each block
type, draft exclusion, XSS encoding, all 6 seeded pages, Markdown inline tests
- 1 integration test (ApiFactory): /api/render-site returns CSS + index.html
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
331
GerbilManager.Tests/SiteRendererTests.cs
Normal file
331
GerbilManager.Tests/SiteRendererTests.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
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)[] 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("<!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_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);
|
||||
}
|
||||
Reference in New Issue
Block a user