/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // catcrafts-server — the native product. // // Serves the server-rendered pages (crawlers and no-JS clients get real HTML), // runs the shop — orders, the payment rails, the reconciler — and doubles // as the test harness for Catcrafts.Shared. // // The harness half is not filler. Catcrafts.Shared is the security boundary // for every piece of markup the site emits, and it is target-neutral precisely // so it can be tested somewhere with a debugger, sanitizers and a normal test // loop instead of only inside a wasm module in a browser tab. `--selftest` // is how the shared code gets executed rather than merely compiled. // // crafter-build -- --product=server && ./bin/Catcrafts.Server-*/catcrafts-server --selftest import std; import Catcrafts.Shared; import Catcrafts.Server; using namespace Catcrafts; namespace { int failures = 0; void Check(bool ok, std::string_view what, std::string_view got = {}) { if (ok) return; ++failures; std::println(std::cerr, "FAIL: {}{}{}", what, got.empty() ? "" : " got: ", got); } void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) { Check(actual.View() == expected, what, actual.View()); } void RunSelfTest() { using namespace Catcrafts::Html; // ── Escape ──────────────────────────────────────────────────────── CheckEq(Escape("plain"), "plain", "escape: passthrough"); CheckEq(Escape("ab"), "a>b", "escape: gt"); CheckEq(Escape("a&b"), "a&b", "escape: amp"); CheckEq(Escape("say \"hi\""), "say "hi"", "escape: dquote"); CheckEq(Escape("it's"), "it's", "escape: squote"); // Ampersand must be escaped first or the other replacements get // double-encoded; a single pass makes that ordering bug impossible. CheckEq(Escape("<"), "<", "escape: no double-encode"); CheckEq(Escape(""), "<script>alert(1)</script>", "escape: script tag"); // Non-ASCII passes through untouched — the output is UTF-8, and // entity-encoding it would just bloat the page. CheckEq(Escape("café ✓ 日本"), "café ✓ 日本", "escape: utf-8 passthrough"); CheckEq(Escape(""), "", "escape: empty"); // ── Num ─────────────────────────────────────────────────────────── CheckEq(Num(0), "0", "num: zero"); CheckEq(Num(-42), "-42", "num: negative"); CheckEq(Num(9007199254740993LL), "9007199254740993", "num: beyond double precision"); // ── Attr ────────────────────────────────────────────────────────── CheckEq(Attr("class", "card"), " class=\"card\"", "attr: basic"); CheckEq(Attr("data-x", "a\"b"), " data-x=\"a"b\"", "attr: value escaped"); CheckEq(Attr("class", ""), "", "attr: empty value omits attribute"); // An invalid name is a programming error, not user data. Emitting // nothing is safer than emitting mangled markup. CheckEq(Attr("on error", "x"), "", "attr: invalid name rejected"); CheckEq(Attr("x>"), "
<script>alert(1)</script>
", "md: html is text, never markup"); CheckEq(md(")"), R"(a " b & c
", "md: quotes and ampersands escaped"); // A code span is verbatim text, and verbatim is exactly where an escaper // is most often forgotten. CheckEq(md("``"), "<b>
plain text
", "md: paragraph"); // Demotion by one: the page h1 is the post title, so a body's own top-level // heading is a section within it. CheckEq(md("# Heading"), "#nothashtag
", "md: # without a space is not a heading"); CheckEq(md("> quoted"), R"()", "md: blockquote"); // The quoted lines are re-parsed, so a multi-paragraph quote keeps its // paragraphs instead of collapsing into one run-on line. CheckEq(md("> one\n>\n> two"), R"(quoted
)", "md: blockquote keeps its paragraphs"); CheckEq(md("- a\n- b"), R"(one
two
a\tb\n",
"md: fenced code is verbatim");
// An unterminated fence must not swallow the document into nothing.
Check(md("```\nx").View().find("x") != std::string_view::npos,
"md: unterminated fence still renders its content");
// ── inline ────────────────────────────────────────────────────────
CheckEq(md("**bold**"), "bold
", "md: strong");
CheckEq(md("*em*"), "em
", "md: emphasis");
CheckEq(md("2 * 3 * 4"), "2 * 3 * 4
", "md: spaced asterisks stay literal");
// Underscores are deliberately inert: these posts paste kernel symbol
// names into prose, and italicising half of one is worse than not
// italicising a word that used the underscore form.
CheckEq(md("kworker/u16:8-qc_ufs_qos_swq"),
"kworker/u16:8-qc_ufs_qos_swq
", "md: underscores are not emphasis");
CheckEq(md("\\*literal\\*"), "*literal*
", "md: backslash escape");
CheckEq(md("[label](https://x.example/y)"),
R"()", "md: link");
// Bare addresses are pasted constantly in these posts; leaving them inert
// would strip most of the outbound value out of the page.
CheckEq(md("see https://x.example/y"),
R"()",
"md: bare URL autolinked");
// ── embedded media ────────────────────────────────────────────────
// A paragraph that is nothing but images becomes the same media block the
// cards use, rather than a of pictures.
CheckEq(md(""),
R"(
)",
"md: image-only paragraph is a media block");
Check(md("text ").View().starts_with("text
media;
PostMedia img;
img.src = "/media/x.webp";
img.kind = "image";
img.avif = "/media/x.avif";
img.fallback = "/media/x.png";
img.width = 800;
img.height = 600;
media.push_back(img);
PostMedia vid;
vid.src = "/media/v.mp4";
vid.kind = "video";
vid.poster = "/media/v.poster.webp";
vid.fallback = "/media/v.h264.mp4";
vid.width = 1080;
vid.height = 1920;
media.push_back(vid);
const auto out = md("", media);
Check(out.View().find(R"(width="800" height="600")") != std::string_view::npos,
"md: inline image carries its dimensions", out.View());
// Routed through :Media, so a body image gets the same format ladder a
// card image does rather than a second, plainer implementation.
Check(out.View().find(R"()")
!= std::string_view::npos
&& out.View().find(R"(src="/media/x.png")") != std::string_view::npos,
"md: inline image gets the avif/png ladder", out.View());
// An inline video gets the same treatment a headline one does,
// fallback source and all.
const auto vout = md("", media);
Check(vout.View().find(R"(poster="/media/v.poster.webp")") != std::string_view::npos
&& vout.View().find("codecs=av01") != std::string_view::npos
&& vout.View().find(R"( > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
}
// Post pages: the routing, the loader's guard on what becomes a URL, and the
// schema.org joins that keep every post attributed to the one Organization and
// the one Person the rest of the site describes.
void RunPostSelfTest() {
// ── routing ───────────────────────────────────────────────────────
Check(ParseRoute("/posts").kind == RouteKind::Posts, "route: /posts is the list");
Check(ParseRoute("/posts/hello-world").kind == RouteKind::Post, "route: /posts/");
Check(ParseRoute("/posts/hello-world").slug == "hello-world", "route: post slug captured");
Check(ParseRoute("/posts/hello-world/").kind == RouteKind::Post,
"route: trailing slash normalised");
Check(ParseRoute("/posts/Hello").kind == RouteKind::NotFound,
"route: uppercase slug is not a post URL");
Check(ParseRoute("/posts/../etc").kind == RouteKind::NotFound,
"route: traversal never reaches a lookup");
Check(NavKindFor(RouteKind::Post) == RouteKind::Posts,
"route: a post page highlights the Posts nav entry");
Check(NavKindFor(RouteKind::LegacyBlog) == RouteKind::Posts,
"route: the retired /blog URL highlights it too");
Check(NavKindFor(RouteKind::Shop) == RouteKind::Shop, "route: a nav route is its own entry");
// ── the loader ────────────────────────────────────────────────────
{
const auto posts = LoadPosts(R"([
{"title":"Good","slug":"good-post","permalink":"https://i.example/post/1",
"body":"Hello.","published":"2026-01-01T00:00:00Z",
"body_media":[{"src":"/media/a.webp","kind":"image","w":10,"h":20}]},
{"title":"Bad slug","slug":"NOT A SLUG","permalink":"https://i.example/post/2",
"body":"Hello."},
{"title":"No body","slug":"no-body","permalink":"https://i.example/post/3"}
])");
Check(posts.size() == 3, "posts: all three load");
if (posts.size() == 3) {
Check(posts[0].HasPage() && posts[0].slug == "good-post", "posts: valid slug kept");
Check(posts[0].body == "Hello.", "posts: body loaded");
Check(posts[0].bodyMedia.size() == 1 && posts[0].bodyMedia[0].width == 10,
"posts: body media loaded with dimensions");
// A slug that could never match a route would render a "read more"
// link to a 404 this site points at itself.
Check(posts[1].slug.empty() && !posts[1].HasPage(),
"posts: malformed slug is dropped, costing the page");
// A title and a link out is not a page worth minting a URL for.
Check(!posts[2].HasPage(), "posts: no body means no page");
}
Views::SiteContent content;
content.posts = posts;
Check(content.FindPost("good-post") != nullptr, "posts: found by slug");
Check(content.FindPost("no-body") == nullptr, "posts: a pageless post is not findable");
Check(content.FindPost("nope") == nullptr, "posts: unknown slug is not found");
// Which means the route 404s rather than rendering an empty article.
Check(Views::RenderRoute(ParseRoute("/posts/no-body"), content).status == 404,
"posts: a pageless slug is a real 404");
Check(Views::RenderRoute(ParseRoute("/posts/good-post"), content).status == 200,
"posts: a real post renders");
}
// ── the page ──────────────────────────────────────────────────────
{
Post p;
p.title = "Working GPS!";
p.slug = "working-gps";
p.permalink = "https://lemmy.example/post/42";
p.community = "linuxphones@lemmy.example";
p.published = "2026-06-26T23:16:25Z";
p.excerpt = "A short summary.";
p.body = "# How\n\nIt works.";
PostMedia m;
m.src = "/media/shot.webp";
m.kind = "image";
p.media.push_back(m);
const auto page = Views::RenderPost(p);
Check(page.meta.canonical == "/posts/working-gps",
"post page: canonical is this site, not the instance");
Check(page.meta.ogType == "article", "post page: og:type is article");
Check(page.meta.ogImage == "/media/shot.webp", "post page: og:image from the post media");
Check(page.meta.description == p.excerpt, "post page: description is the excerpt");
Check(page.main.View().find("How
") != std::string_view::npos,
"post page: the body is rendered, not escaped away");
// The whole reason the body is hosted: the thread is still one click
// away, and the reader is told where the discussion is.
Check(page.main.View().find(p.permalink) != std::string_view::npos,
"post page: still links the thread");
// The identity graph, same joins every other page makes. A typo'd @id
// still renders and still validates — it just quietly splits this post
// away from the entity the rest of the site describes.
auto ld = Json::Parse(page.meta.jsonLd);
Check(ld && ld->IsObject() && ld->Str("@type") == "BlogPosting",
"post schema: parses as a BlogPosting");
if (ld && ld->IsObject()) {
Check(ld->Str("url") == "https://catcrafts.net/posts/working-gps",
"post schema: url is the on-site page");
Check(ld->Str("discussionUrl") == p.permalink,
"post schema: the thread is the discussion, not the content");
Check(ld->Str("datePublished") == p.published, "post schema: publication date");
const Json::Value* author = ld->Find("author");
const Json::Value* publisher = ld->Find("publisher");
Check(author && author->Str("@id") == "https://catcrafts.net/about#person",
"post schema: authored by the Person node on /about");
Check(publisher && publisher->Str("@id") == "https://catcrafts.net/#organization",
"post schema: published by the Organization node");
}
// A post with a page is advertised as one from the list and from home;
// one without keeps pointing at the thread, because there is nothing
// here to send the reader to.
const std::array one{ p };
const auto list = Views::RenderPosts(one);
Check(list.main.View().find(R"(href="/posts/working-gps")") != std::string_view::npos,
"posts list: links the on-site page");
Check(list.main.View().find("Read the full post") != std::string_view::npos,
"posts list: offers the full post");
// Inside the excerpt paragraph, trailing the text — not a row of its
// own below the media, where it was the same offer made a screen
// further down.
Check(list.main.View().find(
R"(A short summary. )"
R"(Read the full post
)") != std::string_view::npos,
"posts list: read-more trails the excerpt", list.main.View());
// With no excerpt there is no sentence to continue, so it falls back to
// a row rather than vanishing with the paragraph that would have held it.
Post unexcerpted = p;
unexcerpted.excerpt.clear();
const std::array bare{ unexcerpted };
const auto listBare = Views::RenderPosts(bare);
Check(listBare.main.View().find("Read the full post") != std::string_view::npos,
"posts list: an excerptless post still offers the full post");
Post bodyless = p;
bodyless.body.clear();
const std::array none{ bodyless };
const auto listNone = Views::RenderPosts(none);
Check(listNone.main.View().find("Read the full post") == std::string_view::npos,
"posts list: no read-more without a page to read");
Check(listNone.main.View().find(p.permalink) != std::string_view::npos,
"posts list: a pageless post still links its thread");
}
}
void RunJsonSelfTest() {
using namespace Catcrafts::Json;
auto ok = [](std::string_view text) { return Parse(text).has_value(); };
auto bad = [](std::string_view text) { return !Parse(text).has_value(); };
// ── shapes ────────────────────────────────────────────────────────
Check(ok("{}"), "json: empty object");
Check(ok("[]"), "json: empty array");
Check(ok(" \n\t {\"a\": 1} \n "), "json: surrounding whitespace");
Check(ok("[1,2,3]"), "json: number array");
Check(ok("{\"a\":{\"b\":[true,false,null]}}"), "json: nesting");
// ── malformed input must be rejected, not partially accepted ──────
Check(bad("{"), "json: unterminated object");
Check(bad("[1,]"), "json: trailing comma");
Check(bad("{\"a\":1,}"), "json: trailing comma in object");
Check(bad("{'a':1}"), "json: single quotes");
Check(bad("\"unterminated"), "json: unterminated string");
Check(bad("{\"a\" 1}"), "json: missing colon");
Check(bad("nul"), "json: bad literal");
Check(bad("{} garbage"), "json: trailing content rejected");
Check(bad("[1,2] [3]"), "json: concatenated documents rejected");
Check(bad("\"raw\nnewline\""), "json: control char in string");
Check(bad("01"), "json: leading zero");
Check(bad("+1"), "json: leading plus");
Check(bad("1."), "json: trailing decimal point");
Check(bad(".5"), "json: bare fraction");
Check(bad("1e"), "json: empty exponent");
Check(bad("1e+"), "json: exponent sign with no digits");
Check(bad("-"), "json: lone minus");
Check(bad("1e400"), "json: out of double range");
Check(ok("0"), "json: zero");
Check(ok("-0"), "json: negative zero");
Check(ok("0.5"), "json: leading zero with fraction");
Check(ok("-1.5e-3"), "json: full number grammar");
Check(ok("1E+2"), "json: capital exponent");
Check(bad(""), "json: empty input");
// ── string decoding ───────────────────────────────────────────────
auto strOf = [](std::string_view doc) -> std::string {
auto v = Parse(doc);
if (!v || !v->IsObject()) return "";
return std::string(v->Str("k"));
};
Check(strOf(R"({"k":"a\"b"})") == "a\"b", "json: escaped quote");
Check(strOf(R"({"k":"a\\b"})") == "a\\b", "json: escaped backslash");
Check(strOf(R"({"k":"a\nb"})") == "a\nb", "json: newline escape");
Check(strOf(R"({"k":"A"})") == "A", "json: \\u ascii");
Check(strOf(R"({"k":"é"})") == "é", "json: \\u latin-1");
Check(strOf(R"({"k":"日"})") == "日", "json: \\u BMP");
// Astral plane arrives as a UTF-16 surrogate pair. Encoding each half
// separately yields invalid UTF-8 — emoji in Lemmy post titles are
// exactly this case, so it has to be combined.
Check(strOf(R"({"k":"😺"})") == "\U0001F63A", "json: surrogate pair -> emoji");
Check(strOf(R"({"k":"\ud83d"})") == "�", "json: lone high surrogate -> U+FFFD");
Check(strOf(R"({"k":"\ude3a"})") == "�", "json: lone low surrogate -> U+FFFD");
Check(strOf(R"({"k":"raw é ✓"})") == "raw é ✓", "json: raw utf-8 passthrough");
// ── accessors ─────────────────────────────────────────────────────
auto doc = Parse(R"({"s":"x","n":42,"neg":-7,"b":true,"nul":null})");
Check(doc.has_value(), "json: accessor doc parses");
if (doc) {
Check(doc->Str("s") == "x", "json: Str");
Check(doc->Int("n") == 42, "json: Int");
Check(doc->Int("neg") == -7, "json: Int negative");
Check(doc->Bool("b"), "json: Bool");
Check(doc->Str("missing", "fallback") == "fallback", "json: Str fallback");
Check(doc->Int("missing", 99) == 99, "json: Int fallback");
// Wrong-typed field falls back rather than reinterpreting.
Check(doc->Int("s", 5) == 5, "json: type mismatch falls back");
Check(doc->Find("missing") == nullptr, "json: Find absent");
Check(doc->Find("nul") != nullptr && doc->Find("nul")->IsNull(),
"json: present-null distinguishable from absent");
}
// ── depth guard ───────────────────────────────────────────────────
std::string deep(200, '[');
Check(bad(deep), "json: deep nesting rejected, not stack overflow");
}
void RunFormSelfTest() {
using namespace Catcrafts::Form;
// ── urlencoded parsing ────────────────────────────────────────────
auto parse = [](std::string_view b) { return ParseUrlEncoded(b); };
auto f = parse("email=a%40b.example&country=NL");
Check(f.has_value(), "form: basic body parses");
if (f) {
Check(f->Get("email") == "a@b.example", "form: %40 decodes to @");
Check(f->Get("country") == "NL", "form: second field");
Check(f->Get("missing").empty(), "form: absent field is empty");
Check(!f->Has("missing"), "form: Has() distinguishes absent");
}
Check(parse("a=1&&b=2")->Size() == 2, "form: empty segment tolerated");
Check(parse("a=1&")->Size() == 1, "form: trailing & tolerated");
Check(parse("flag")->Has("flag"), "form: valueless key present");
Check(parse("")->Size() == 0, "form: empty body");
Check(parse("q=hello+world")->Get("q") == "hello world", "form: + is space");
Check(parse("q=a%2Bb")->Get("q") == "a+b", "form: %2B is a literal plus");
Check(parse("n=caf%C3%A9")->Get("n") == "café", "form: utf-8 percent-decoding");
Check(parse("n=100%")->Get("n") == "100%", "form: malformed escape passes through");
Check(parse("n=%zz")->Get("n") == "%zz", "form: non-hex escape passes through");
// A field name is not allowed to be empty — "=x" is malformed, not a field.
Check(!parse("=x").has_value(), "form: empty field name rejected");
// Oversized input must be refused outright rather than truncated: acting on
// half a form is worse than refusing it.
Check(!parse(std::string(kMaxBodyBytes + 1, 'a')).has_value(), "form: oversized body rejected");
Check(!parse("a=" + std::string(kMaxFieldBytes + 1, 'x')).has_value(), "form: oversized field rejected");
// ── email shape ───────────────────────────────────────────────────
Check(LooksLikeEmail("a@b.example"), "email: minimal");
Check(LooksLikeEmail("first.last+tag@sub.domain.example"), "email: tagged, subdomain");
Check(!LooksLikeEmail("no-at-sign"), "email: no @");
Check(!LooksLikeEmail("@domain.example"), "email: empty local part");
Check(!LooksLikeEmail("user@"), "email: empty domain");
Check(!LooksLikeEmail("a@b@c.example"), "email: two @");
Check(!LooksLikeEmail("user@dotless"), "email: dotless domain");
Check(!LooksLikeEmail("user@.example"), "email: domain starts with dot");
Check(!LooksLikeEmail("a b@c.example"), "email: embedded space");
// Header-injection characters must never survive into anything that later
// builds an email envelope.
Check(!LooksLikeEmail("a@b.example\nBcc: x@y.example"), "email: newline rejected");
Check(!LooksLikeEmail("a@b.example\r\nSubject: x"), "email: CRLF rejected");
Check(!LooksLikeEmail("a,b@c.example"), "email: comma rejected");
Check(!LooksLikeEmail(""), "email: angle brackets rejected");
Check(!LooksLikeEmail(std::string(250, 'a') + "@b.example"), "email: over 254 chars rejected");
// ── country code ──────────────────────────────────────────────────
Check(LooksLikeCountryCode("NL"), "country: uppercase");
Check(LooksLikeCountryCode("ca"), "country: lowercase accepted");
Check(!LooksLikeCountryCode("NLD"), "country: three letters rejected");
Check(!LooksLikeCountryCode("N"), "country: one letter rejected");
Check(!LooksLikeCountryCode("N1"), "country: digit rejected");
Check(!LooksLikeCountryCode(""), "country: empty rejected");
Check(Upper("nl") == "NL", "country: normalised to upper");
// ── trimming ──────────────────────────────────────────────────────
Check(Trim(" x ") == "x", "trim: spaces");
Check(Trim("\t\r\nx\n") == "x", "trim: tabs and newlines");
Check(Trim(" ").empty(), "trim: all whitespace");
// ── checkout validation ───────────────────────────────────────────
constexpr std::string_view kGoodOrder =
"email=a%40b.example&name=Ada&street=Main%20St%201&postal=1234AB&city=Delft&country=nl";
auto validate = [](std::string_view body) {
return ValidateCheckout(*ParseUrlEncoded(body));
};
auto good = validate(kGoodOrder);
Check(good.Ok(), "checkout: valid submission accepted");
Check(good.value.country == "NL", "checkout: country uppercased");
Check(good.value.street == "Main St 1", "checkout: street decoded and kept");
Check(!validate("name=Ada&street=x&postal=1&city=y&country=NL").Ok(),
"checkout: missing email rejected");
Check(!validate("email=a%40b.example&street=x&postal=1&city=y&country=NL").Ok(),
"checkout: missing name rejected");
Check(!validate("email=a%40b.example&name=Ada&postal=1&city=y&country=NL").Ok(),
"checkout: missing street rejected");
Check(!validate("email=a%40b.example&name=Ada&street=x&city=y&country=NL").Ok(),
"checkout: missing postal rejected");
Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&country=NL").Ok(),
"checkout: missing city rejected");
Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y").Ok(),
"checkout: missing country rejected");
Check(!validate("email=nonsense&name=Ada&street=x&postal=1&city=y&country=NL").Ok(),
"checkout: bad email rejected");
// Every problem is reported at once — a form that surfaces one error per
// submission makes people resubmit to discover the rest.
Check(validate("email=&name=&street=&postal=&city=&country=").errors.size() == 6,
"checkout: errors accumulate");
// Honeypot: a filled hidden field means a bot. The message must not name
// the trap, or it teaches the next one how to pass.
auto pot = validate(std::string(kGoodOrder) + "&website=http%3A%2F%2Fspam");
Check(!pot.Ok(), "checkout: honeypot rejects");
Check(pot.errors.size() == 1 && pot.errors[0].message.find("honeypot") == std::string::npos
&& pot.errors[0].message.find("website") == std::string::npos,
"checkout: honeypot failure does not name the trap");
Check(!validate("email=a%40b.example&name=" + std::string(200, 'x')
+ "&street=x&postal=1&city=y&country=NL").Ok(),
"checkout: overlong name rejected");
// Colour and quantity: shape checks here, catalogue checks in the handler.
Check(validate(std::string(kGoodOrder) + "&color=green&quantity=2").Ok(),
"checkout: colour and quantity accepted");
Check(validate(std::string(kGoodOrder) + "&quantity=2").value.quantity == 2,
"checkout: quantity parsed");
Check(validate(kGoodOrder).value.quantity == 1, "checkout: quantity defaults to 1");
Check(!validate(std::string(kGoodOrder) + "&quantity=0").Ok(),
"checkout: zero quantity rejected");
Check(validate(std::string(kGoodOrder) + "&quantity=9").Ok(),
"checkout: bulk quantity welcome");
Check(validate(std::string(kGoodOrder) + "&quantity=99").Ok(),
"checkout: the technical ceiling itself is fine");
Check(!validate(std::string(kGoodOrder) + "&quantity=100").Ok(),
"checkout: past the technical ceiling rejected");
Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(),
"checkout: non-numeric quantity rejected");
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
"checkout: oversized colour rejected");
// The payment choice. Absent is a form that offered none (one rail
// configured, or the no-JS fallback page) and the handler resolves it to
// bank — the validator's job is only to refuse a word it does not know
// rather than let it fall through to a default the buyer never picked.
Check(validate(kGoodOrder).value.payChoice.empty(),
"checkout: absent payment choice stays empty");
Check(validate(std::string(kGoodOrder) + "&pay=bank").value.payChoice
== Catcrafts::Form::kPayBank,
"checkout: bank choice parsed");
Check(validate(std::string(kGoodOrder) + "&pay=crypto").value.payChoice
== Catcrafts::Form::kPayCrypto,
"checkout: crypto choice parsed");
{
auto bogus = validate(std::string(kGoodOrder) + "&pay=free");
Check(!bogus.Ok(), "checkout: unknown payment choice rejected");
Check(bogus.errors.size() == 1 && bogus.errors[0].field == "pay",
"checkout: the payment refusal hangs off the payment field");
}
// Destinations the shop refuses. Well-formed, real country codes — the
// refusal is policy, so it has to survive every spelling the form accepts,
// and it must not spill onto other non-EU destinations.
auto withCountry = [&](std::string_view cc) {
return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country="
+ std::string(cc));
};
Check(!withCountry("US").Ok(), "checkout: US refused");
Check(!withCountry("CA").Ok(), "checkout: CA refused");
Check(!withCountry("us").Ok(), "checkout: lowercase US refused too");
Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell");
Check(withCountry("NL").Ok(), "checkout: EU unaffected");
{
auto us = withCountry("US");
Check(us.errors.size() == 1 && us.errors[0].field == "country",
"checkout: refusal is a country error, nothing else");
Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage,
"checkout: refusal says where the shop does not sell");
Check(us.value.country == "US", "checkout: refused country echoed back");
}
// The shipping refusals. These are templates rather than plain strings
// because the buy page fills the same ones client-side, so the substitution
// has to work on both {cc} and {n} — a template that silently kept its
// placeholder would ship "up to {n} per order" to a real buyer.
{
using namespace Catcrafts::Form;
const std::string none = NoShippingMessage("BR");
Check(none.find("BR") != std::string::npos
&& none.find("{cc}") == std::string::npos,
"shipping copy: the uncovered-country message names the country");
const std::string heavy = TooHeavyMessage("JP", 3);
Check(heavy.find("JP") != std::string::npos && heavy.find("3") != std::string::npos
&& heavy.find("{n}") == std::string::npos,
"shipping copy: the too-heavy message names the country and the limit");
const std::string nofit = TooHeavyMessage("JP", 0);
Check(nofit.find("JP") != std::string::npos
&& nofit.find("up to") == std::string::npos,
"shipping copy: with nothing fitting it does not promise a quantity");
Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL",
"shipping copy: every placeholder is filled, not just the first");
// Both messages must offer the way out, since the shop is refusing
// business it would otherwise take.
Check(none.find("orders@catcrafts.net") != std::string::npos
&& heavy.find("orders@catcrafts.net") != std::string::npos
&& nofit.find("orders@catcrafts.net") != std::string::npos,
"shipping copy: every refusal names a human to email");
}
// A rejected field must still come back, or the visitor has to retype the
// one thing they got wrong — the fastest way to lose a submission.
auto rejected = validate("email=notanemail&name=Ada&street=Main%201&postal=1&city=y&country=NLD");
Check(!rejected.Ok(), "checkout: invalid pair rejected");
Check(rejected.value.email == "notanemail", "checkout: invalid email echoed back");
Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed");
Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved");
}
void RunMoneySelfTest() {
using namespace Catcrafts::Money;
// ── formatting ────────────────────────────────────────────────────
Check(FormatMinor(58000) == "580.00", "money: wire format");
Check(FormatMinor(47934) == "479.34", "money: wire format with cents");
Check(FormatMinor(5) == "0.05", "money: sub-unit");
Check(FormatMinor(0) == "0.00", "money: zero");
Check(FormatEuro(58000) == "€580", "money: whole euros displayed bare");
Check(FormatEuro(47934) == "€479.34", "money: cents displayed when present");
// ── VAT arithmetic ────────────────────────────────────────────────
// €580.00 gross at 21%: net = 58000/1.21 = 47933.88... -> 47934 half-up.
Check(NetFromGross(58000) == 47934, "vat: net from €580 gross");
// The derived pair must reconstruct plausibly: net + vat == gross.
Check(58000 - NetFromGross(58000) == 10066, "vat: vat portion exact");
Check(NetFromGross(0) == 0, "vat: zero");
Check(NetFromGross(121) == 100, "vat: €1.21 -> €1.00 exactly");
// The gross-up direction, used to charge carrier costs without eating
// the VAT slice: €7.13 cost -> €8.63 charged, and the pair round-trips.
Check(GrossFromNet(713) == 863, "vat: gross from €7.13 net");
Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips");
Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly");
Check(GrossFromNet(0) == 0, "vat: gross-up zero");
// ── zones and membership ──────────────────────────────────────────
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
Check(!IsEuCountry("GB"), "eu: UK left");
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america");
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
Check(ZoneFor("DE") == Zone::Eu, "zone: eu");
Check(ZoneFor("GB") == Zone::World, "zone: world");
// ── destinations the shop refuses ─────────────────────────────────
// Zones still classify US and CA (the arithmetic is destination-blind, and
// keeping it that way means one policy switch, not two); the sale is what
// stops, in SellsTo.
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells");
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"),
"policy: the rest of the world still sells");
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
// ── carrier weight brackets ───────────────────────────────────────
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
// 20 kg, with the 20 kg band deliberately CHEAPER than the 10 kg one —
// real carrier tariffs do that, and picking the tightest band rather than
// the cheapest one that carries the parcel would overcharge for it.
{
const std::vector ladder{ { 2000, 895 }, { 10000, 1650 },
{ 20000, 1490 } };
Check(RateFor(ladder, 700) == 895, "brackets: one unit takes the 2 kg band");
Check(RateFor(ladder, 2000) == 895, "brackets: the ceiling is inclusive");
Check(RateFor(ladder, 2001) == 1490,
"brackets: cheapest band that CARRIES it, not the tightest");
Check(RateFor(ladder, 20001) == 0, "brackets: above every band is no price");
Check(RateFor({}, 700) == 0, "brackets: an uncovered country has no price");
Check(MaxUnitsFor(ladder, 700) == 28, "brackets: units that fit one parcel");
Check(MaxUnitsFor(ladder, 25000) == 0,
"brackets: a unit heavier than every band fits nothing");
Check(MaxUnitsFor(ladder, 0) == 0, "brackets: no weight, no answer");
Check(MaxUnitsFor({}, 700) == 0, "brackets: no ladder, nothing fits");
// The table-level lookups the handler and the page both go through.
const std::vector table{ { "NL", ladder }, { "JP", { { 2000, 4250 } } } };
Check(RateFor(LadderFor(table, "NL"), 700) == 895, "table: NL priced");
Check(RateFor(LadderFor(table, "JP"), 2100) == 0,
"table: JP has one light band, so two units are unshippable");
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
}
// ── order totals ──────────────────────────────────────────────────
// NL: gross + shipping, VAT included in both.
auto nl = ComputeTotals(58000, 1, 1500, "NL");
Check(nl.goods == 58000 && nl.shipping == 1500 && nl.total == 59500,
"totals: NL");
Check(nl.vatIncluded, "totals: NL includes VAT");
Check(nl.vatCharged == 59500 - NetFromGross(59500), "totals: NL VAT covers shipping");
auto de = ComputeTotals(58000, 1, 2500, "DE");
Check(de.goods == 58000 && de.shipping == 2500 && de.total == 60500,
"totals: EU");
// Export: net goods, world shipping, no VAT.
auto gb = ComputeTotals(58000, 1, 5500, "GB");
Check(gb.goods == 47934 && gb.shipping == 5500 && gb.total == 53434,
"totals: export");
Check(!gb.vatIncluded && gb.vatCharged == 0, "totals: export carries no VAT");
// Quantity: the export net is derived from the LINE total, not per unit —
// per-unit rounding times qty would differ by a cent here, and the JS
// preview mirrors this exact formula.
auto gb2 = ComputeTotals(57500, 2, 5500, "GB");
Check(gb2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
Check(gb2.goods == 95041, "totals: 2× green export net exact");
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
// ── the compiled-in catalogue ─────────────────────────────────────
// Content is code now; these assertions are the contract the shop pages
// rely on, checked against the actual shipped data.
{
const auto& products = Content::Products();
Check(products.size() == 1, "content: one product");
if (products.size() == 1) {
const Product& pr = products[0];
Check(pr.slug == "fp6-pmos", "content: product slug");
// Coming-soon is the pre-launch state; launch flips it to
// "available" and this check keeps passing either way.
Check(pr.Buyable() || pr.ComingSoon(),
"content: product is buyable or deliberately coming soon");
Check(pr.variants.size() == 3, "content: three colours");
// Cost-plus pricing, derived in code: supplier + €50, exactly.
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330,
"content: green = 513.30 supplier + 50 markup");
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930,
"content: black = 519.30 supplier + 50 markup");
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488,
"content: white = 604.88 supplier + 50 markup");
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant");
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
"content: cheapest is green");
Check(pr.safetyNote.find("112") != std::string::npos
&& pr.safetyNote.find("not yet verified") != std::string::npos,
"content: emergency-calling safety warning present and honest");
Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100,
"content: warranty is written, not a placeholder");
// The product page's schema.org record must parse with our own
// JSON parser and carry one variant Product per colour, each
// with its ONE offer — built from the same integers the
// checkout charges.
{
// The listing's shipping block is now carrier data, so the
// render needs a table. US is priced here on purpose: the
// carrier will happily quote it and the shop still must not
// advertise it.
const std::vector feedTable{
{ "NL", { { 2000, 895 } } },
{ "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 2450 } } },
{ "US", { { 2000, 1794 } } },
};
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
auto ld = Json::Parse(pp.meta.jsonLd);
bool variantsOk = false;
if (ld && ld->IsObject()) {
if (const Json::Value* v = ld->Find("hasVariant");
v && v->IsArray() && v->array.size() == pr.variants.size()) {
variantsOk = true;
for (const Json::Value& node : v->array) {
const Json::Value* o = node.Find("offers");
variantsOk = variantsOk && node.Str("@type") == "Product"
&& o && o->IsObject();
}
}
}
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
"schema: product JSON-LD parses, one variant per colour");
// Merchant-grade fields: shipping, returns, sku, group id —
// what Merchant Center's website-crawl feed reads at launch
// (productGroupID is its item_group_id).
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: variants carry shipping, returns, sku and group id");
// The published rates ARE the carrier's, at one unit's weight.
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
"schema: shipping rates come from the carrier table");
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
"schema: a refused destination is never advertised, priced or not");
// No table: no shipping claim. The listing loses the merchant
// block rather than inventing a rate — the whole point of
// dropping the zone fallback.
auto bare = Views::RenderProduct(pr, Rates{});
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
"schema: with no carrier table the offer publishes no shipping");
Check(Json::Parse(bare.meta.jsonLd).has_value()
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: and the rest of the record still parses");
}
}
Check(!Content::Projects().empty(), "content: projects present");
Check(Content::LegalPages().size() == 3, "content: three legal pages");
Check(Content::AboutPage().sections.size() >= 3
&& !Content::AboutPage().sections[0].body.empty()
&& Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf")
!= std::string::npos,
"content: about page names the founder");
Check(!Content::Demos().empty(), "content: demos present");
}
// ── the identity graph ────────────────────────────────────────────
// "Catcrafts" is two common words with no space, so it competes with a
// decade of kids' craft blogs, Etsy and a Minecraft server on the
// singular domain. The way out is not prose: it is one registered entity
// that every page points at by @id. Those joins are worth asserting
// because breaking one is silent — a typo'd @id still renders, still
// validates as JSON-LD, and still splits the graph back into three
// same-named strangers, which is the exact failure this markup exists to
// prevent.
{
constexpr std::string_view kOrgId = "https://catcrafts.net/#organization";
auto home = Views::RenderHome(Content::Projects(), std::span{});
auto ld = Json::Parse(home.meta.jsonLd);
const Json::Value* org = nullptr;
const Json::Value* site = nullptr;
if (ld && ld->IsObject()) {
if (const Json::Value* g = ld->Find("@graph"); g && g->IsArray()) {
for (const Json::Value& node : g->array) {
if (node.Str("@type") == "Organization") org = &node;
if (node.Str("@type") == "WebSite") site = &node;
}
}
}
Check(org && site, "schema: home graph parses, carries Organization and WebSite");
if (org && site) {
Check(org->Str("@id") == kOrgId, "schema: organization node is identified");
Check(site->Str("@id") == "https://catcrafts.net/#website",
"schema: website node is identified");
// The join that makes two nodes one entity rather than two.
const Json::Value* publisher = site->Find("publisher");
Check(publisher && publisher->IsObject() && publisher->Str("@id") == kOrgId,
"schema: website is published by the organization node");
// The navigational query "catcrafts" is answered from the site
// entity, so the spellings people type belong on it.
const Json::Value* alt = site->Find("alternateName");
Check(alt && alt->IsArray() && !alt->array.empty(),
"schema: website carries the spellings people type");
// Registry numbers, typed. These are the part no name-twin can
// produce — each one is checkable against a public register,
// which also means a wrong value is worse than no value.
bool kvk = false, vat = false, eori = false;
if (const Json::Value* ids = org->Find("identifier"); ids && ids->IsArray()) {
for (const Json::Value& id : ids->array) {
if (id.Str("propertyID") == "KVK") kvk = id.Str("value") == "78437059";
if (id.Str("propertyID") == "VAT") vat = id.Str("value") == "NL003329281B38";
if (id.Str("propertyID") == "EORI") eori = id.Str("value") == "NL1900095326";
}
}
Check(kvk && vat && eori, "schema: KVK, VAT and EORI present and exact");
// The name-twin guard. "Cat Crafts" with a space is the generic
// craft phrase owned by everyone else; claiming it as an alternate
// name argues for merging this entity into the corpus it needs to
// stay distinct from.
Check(home.meta.jsonLd.find("Cat Crafts") == std::string::npos,
"schema: the spaced generic is not claimed as a brand name");
}
// Cross-page joins: both must name the SAME @id the home page defines.
auto about = Views::RenderAbout(Content::AboutPage());
Check(about.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: about joins the founder to the organization node");
if (!Content::Products().empty()) {
auto pp = Views::RenderProduct(Content::Products()[0], Rates{});
Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: offers are sold by the organization node");
}
// The person join, same mechanism in the other direction: home's
// founder and about's mainEntity must name one Person node, or "who
// founded Catcrafts" splits into two same-named strangers too.
constexpr std::string_view kPersonId = "https://catcrafts.net/about#person";
Check(home.meta.jsonLd.find(kPersonId) != std::string::npos
&& about.meta.jsonLd.find(kPersonId) != std::string::npos,
"schema: founder and about name one Person node");
}
// ── the Sendcloud response parser ─────────────────────────────────
// Weights are the kilogram strings the API sends; every Find() below asks
// for a parcel weight, because a price without a weight is not a thing this
// table has any more.
{
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"Other Method","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":1.00}]},
{"name":"DHL For You Home","min_weight":"0.001","max_weight":"2.000",
"countries":[
{"iso_2":"NL","price":6.25},
{"iso_2":"DE","price":8.20},
{"iso_2":"CA","price":42.50},
{"iso_2":"XX","price":0},
{"iso_2":"TOOLONG","price":5.00}]},
{"name":"DHL For You Home","min_weight":"2.000","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":9.95},{"iso_2":"DE","price":13.40}]},
{"name":"DHL For You Home","countries":[{"iso_2":"BE","price":1.00}]}]})",
"DHL For You");
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
Check(table.Find("NL", 700) == 625, "sendcloud: NL price to cents");
Check(table.Find("DE", 700) == 820, "sendcloud: 8.20 rounds exactly");
Check(table.Find("CA", 700) == 4250, "sendcloud: CA price");
Check(table.Find("XX", 700) == 0, "sendcloud: zero price dropped");
Check(table.Find("TOOLONG", 700) == 0, "sendcloud: malformed iso dropped");
// The bug the old parser had: it stopped at the FIRST matching method,
// so every parcel was priced at whichever band came first and the
// heavier bands were invisible.
Check(table.Find("NL", 2100) == 995 && table.Find("DE", 2100) == 1340,
"sendcloud: every weight band of a matched method is kept");
Check(table.Find("NL", 11000) == 0,
"sendcloud: past the heaviest band there is no price");
Check(table.Find("BE", 700) == 0,
"sendcloud: a method with no weight range is unusable, not unlimited");
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
"sendcloud: malformed payload yields nothing");
// Comma-separated merge: courier for Europe, post for the world; the
// earlier FILTER keeps any country both cover — including that
// country's heavier bands, which must not leak in from the later one.
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000","countries":[
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
{"name":"PostNL Parcels non-EU","min_weight":"0.001","max_weight":"2.000",
"countries":[
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
{"iso_2":"DE","price":99.99}]},
{"name":"PostNL Parcels non-EU","min_weight":"2.000","max_weight":"20.000",
"countries":[{"iso_2":"CA","price":48.10},{"iso_2":"DE","price":99.99}]}]})",
"DPD Home, PostNL Parcels non-EU");
Check(merged.Find("NL", 700) == 713 && merged.Find("CA", 700) == 2395,
"sendcloud: merged table covers both filters");
Check(merged.Find("CA", 5000) == 4810, "sendcloud: heavier band from the later filter");
Check(merged.Find("DE", 700) == 1049,
"sendcloud: earlier filter wins a shared country");
Check(merged.Find("DE", 12000) == 0,
"sendcloud: and owns it outright — no band from the loser");
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
"sendcloud: merged method names recorded, deduplicated per band");
// Two services under one filter publishing the same ceiling: the
// cheaper is the only sensible quote, since both carry the parcel.
const auto dup = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":9.00}]},
{"name":"DPD Home Signed","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":7.50}]}]})", "DPD Home");
Check(dup.Find("NL", 700) == 750, "sendcloud: duplicate band keeps the cheaper");
}
// ── indicative conversion ─────────────────────────────────────────
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
auto gbp = CurrencyFor("GB");
Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP");
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
if (gbp) {
Check(FormatIndicative(*gbp, 920) == "≈ £920", "fx: display form");
}
// A country the shop refuses gets no localised price either — the two
// tables are kept consistent on purpose, so this is a real invariant and
// not a coincidence of the current list.
for (const std::string_view cc : NoSaleCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: refused destinations have no display currency", cc);
}
// ── order tokens and references ───────────────────────────────────
Check(IsOrderToken("0123456789abcdef0123456789abcdef"), "token: valid shape");
Check(!IsOrderToken("0123456789ABCDEF0123456789ABCDEF"), "token: uppercase rejected");
Check(!IsOrderToken("0123456789abcdef0123456789abcde"), "token: short rejected");
Check(!IsOrderToken("0123456789abcdef0123456789abcdeg"), "token: non-hex rejected");
const std::string tok = Server::NewOrderToken();
Check(IsOrderToken(tok), "token: generator emits valid tokens", tok);
Check(Server::NewOrderToken() != tok, "token: not constant");
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
"reference: derived and uppercased");
// ── the wire-amount parser (both providers quote strings) ─────────
using Server::ParseAmountToMinor;
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
Check(ParseAmountToMinor("614.5") == 61450, "amount: one fraction digit");
Check(ParseAmountToMinor("0.01") == 1, "amount: one cent");
Check(!ParseAmountToMinor("614.005").has_value(), "amount: three decimals rejected");
Check(!ParseAmountToMinor("-1.00").has_value(), "amount: negative rejected");
Check(!ParseAmountToMinor("+1.00").has_value(), "amount: sign rejected");
Check(!ParseAmountToMinor("1e3").has_value(), "amount: exponent rejected");
Check(!ParseAmountToMinor("1.").has_value(), "amount: trailing dot rejected");
Check(!ParseAmountToMinor(".5").has_value(), "amount: bare fraction rejected");
Check(!ParseAmountToMinor("").has_value(), "amount: empty rejected");
Check(!ParseAmountToMinor("1 000.00").has_value(), "amount: separator rejected");
// ── the Mollie payment parser ─────────────────────────────────────
{
const auto p1 = Server::ParseMolliePayment(R"({
"resource":"payment","id":"tr_7UhSN1zuXS","status":"open","method":null,
"amount":{"value":"578.30","currency":"EUR"},
"_links":{"checkout":{"href":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","type":"text/html"}}})");
Check(p1.has_value(), "mollie: open payment parses");
if (p1) {
Check(p1->id == "tr_7UhSN1zuXS", "mollie: id");
Check(p1->status == "open", "mollie: status");
Check(p1->amountMinor == 57830, "mollie: amount to cents");
Check(p1->checkoutUrl == "https://www.mollie.com/checkout/select-method/7UhSN1zuXS",
"mollie: checkout link");
Check(p1->method.empty(), "mollie: null method is empty");
}
const auto p2 = Server::ParseMolliePayment(R"({
"id":"tr_x","status":"paid","method":"ideal",
"amount":{"value":"578.30","currency":"EUR"},"_links":{}})");
Check(p2 && p2->status == "paid" && p2->method == "ideal",
"mollie: paid payment carries the method");
const auto p3 = Server::ParseMolliePayment(R"({
"id":"tr_y","status":"paid","amount":{"value":"578.30","currency":"USD"}})");
Check(p3 && p3->amountMinor == 0, "mollie: non-EUR amount refuses to count");
Check(!Server::ParseMolliePayment("garbage").has_value(),
"mollie: malformed payload rejected");
Check(!Server::ParseMolliePayment(R"({"status":"open"})").has_value(),
"mollie: missing id rejected");
}
// ── the CoinGate order parser ─────────────────────────────────────
//
// The id is a JSON NUMBER at CoinGate, which is the one shape difference
// from Mollie that could silently produce an empty payment id — an order
// that can never be polled. Both spellings are pinned here.
{
const auto c1 = Server::ParseCoingateOrder(R"({
"id":538,"status":"new","title":"CC-ABCDEF catcrafts.net",
"price_amount":"578.30","price_currency":"EUR","receive_currency":"EUR",
"payment_url":"https://pay.coingate.com/invoice/abc-123"})");
Check(c1.has_value(), "coingate: new order parses");
if (c1) {
Check(c1->id == "538", "coingate: numeric id becomes decimal text");
Check(c1->status == "new", "coingate: status");
Check(c1->priceMinor == 57830, "coingate: price to cents");
Check(c1->payUrl == "https://pay.coingate.com/invoice/abc-123",
"coingate: payment url");
Check(c1->payCurrency.empty(), "coingate: no coin picked yet");
}
const auto c2 = Server::ParseCoingateOrder(R"({
"id":"539","status":"paid","pay_currency":"BTC",
"price_amount":"578.3","price_currency":"EUR"})");
Check(c2 && c2->id == "539", "coingate: string id also accepted");
Check(c2 && c2->status == "paid" && c2->payCurrency == "BTC",
"coingate: paid order carries the coin");
Check(c2 && c2->priceMinor == 57830,
"coingate: one-decimal amount is still cents");
const auto c3 = Server::ParseCoingateOrder(R"({
"id":540,"status":"paid","price_amount":"578.30","price_currency":"USD"})");
Check(c3 && c3->priceMinor == 0, "coingate: non-EUR amount refuses to count");
Check(!Server::ParseCoingateOrder("garbage").has_value(),
"coingate: malformed payload rejected");
Check(!Server::ParseCoingateOrder(R"({"status":"new"})").has_value(),
"coingate: missing id rejected");
Check(!Server::ParseCoingateOrder(R"({"id":541})").has_value(),
"coingate: missing status rejected");
}
// ── request provenance ────────────────────────────────────────────
//
// The rate limiter keys on this, so getting the WRONG end of the header
// is not a cosmetic bug: the leftmost entry is client-controlled, and
// trusting it would hand every attacker an endless supply of identities.
{
using Server::ClientAddressFromForwarded;
Check(ClientAddressFromForwarded("203.0.113.7") == "203.0.113.7",
"forwarded: single entry");
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
"forwarded: rightmost entry wins");
// The attack this exists to defeat: a client that sends its own header
// to look like a different peer. Caddy appends the truth on the right.
Check(ClientAddressFromForwarded("1.1.1.1, 2.2.2.2, 203.0.113.7") == "203.0.113.7",
"forwarded: spoofed prefix ignored");
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
"forwarded: padding trimmed");
Check(ClientAddressFromForwarded("2001:db8::1") == "2001:db8::1",
"forwarded: ipv6 passes through");
Check(ClientAddressFromForwarded("").empty(), "forwarded: empty stays empty");
// No header at all means nothing proxied this request; the caller must
// see an empty peer and fall back to the global budget.
Check(ClientAddressFromForwarded("198.51.100.4, ").empty(),
"forwarded: empty last entry is no peer");
}
{
using Server::OriginAllowed;
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net"),
"origin: same origin allowed");
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net/"),
"origin: trailing slash on the base normalised");
// A non-browser client (curl, the e2e suite) sends no Origin and
// cannot be a cross-site forgery — there is no session to ride on.
Check(OriginAllowed("", "https://catcrafts.net"), "origin: absent allowed");
Check(!OriginAllowed("https://evil.example", "https://catcrafts.net"),
"origin: foreign origin refused");
// Neither a subdomain nor a lookalike is us.
Check(!OriginAllowed("https://catcrafts.net.evil.example", "https://catcrafts.net"),
"origin: suffix lookalike refused");
Check(!OriginAllowed("https://shop.catcrafts.net", "https://catcrafts.net"),
"origin: subdomain refused");
// Scheme is part of an origin: http is not https.
Check(!OriginAllowed("http://catcrafts.net", "https://catcrafts.net"),
"origin: scheme mismatch refused");
// A sandboxed iframe posts Origin: null. Present, and not us.
Check(!OriginAllowed("null", "https://catcrafts.net"), "origin: null refused");
Check(!OriginAllowed("https://catcrafts.net", ""),
"origin: unconfigured base refuses rather than accepts all");
// dev.sh serves on localhost and sets --redirect-base to match.
Check(OriginAllowed("http://localhost:8080", "http://localhost:8080"),
"origin: dev localhost base matches");
}
// ── the invoice builder ───────────────────────────────────────────
{
Server::OrderRecord o;
o.token = "0123456789abcdef0123456789abcdef";
o.reference = "CC-TEST01";
o.invoiceNumber = "f57c6512-f012-4b91-adb3-077876480178-7";
o.invoicedAt = "2026-08-05T10:00:00Z";
o.createdAt = "2026-08-05T09:55:00Z";
o.paidVia = "ideal";
o.buyer = { "b@example.org", "Ada Lovelace", "Main St 1", "1234AB",
"Delft", "NL" };
o.quantity = 2;
o.unitMinor = 56330;
o.goodsMinor = 112660;
o.shippingMinor = 863;
o.totalMinor = 113523;
o.vatIncluded = true;
const std::string eu = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
Check(eu.find("# Invoice f57c6512-f012-4b91-adb3-077876480178-7") != std::string::npos,
"invoice: number heading");
Check(eu.find("* Customer number: f57c6512-f012-4b91-adb3-077876480178") != std::string::npos,
"invoice: customer series shown separately");
Check(eu.find("* Invoice number: 7") != std::string::npos,
"invoice: sequence within the series");
Check(eu.find("Chico Mendesring 256") != std::string::npos, "invoice: seller address");
Check(eu.find("3315NN Dordrecht") != std::string::npos, "invoice: seller city");
Check(eu.find("KVK 78437059") != std::string::npos, "invoice: KVK");
Check(eu.find("NL003329281B38") != std::string::npos, "invoice: VAT id");
Check(eu.find("CC-TEST01") != std::string::npos, "invoice: order reference");
Check(eu.find("Ada Lovelace") != std::string::npos, "invoice: buyer name");
Check(eu.find("Fairphone 6 — Forest Green") != std::string::npos,
"invoice: item names the colour");
Check(eu.find("VAT 21% (NL)") != std::string::npos, "invoice: EU VAT line");
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
o.vatIncluded = false;
o.buyer.country = "GB";
o.goodsMinor = 93107;
o.shippingMinor = 2395;
o.totalMinor = 95502;
const std::string ex = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
// ── the order confirmation email ──────────────────────────────
// Same order, EU shape again; the attachment stands in for the
// clearsigned invoice — the builder must carry it verbatim.
o.vatIncluded = true;
o.buyer.country = "NL";
o.goodsMinor = 112660;
o.shippingMinor = 863;
o.totalMinor = 113523;
const std::string mail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts ",
"https://catcrafts.net/order/0123456789abcdef0123456789abcdef",
"SIGNED-INVOICE-STAND-IN\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(mail.find("From: Catcrafts \n") != std::string::npos,
"email: From header");
Check(mail.find("To: b@example.org\n") != std::string::npos, "email: To header");
Check(mail.find("Subject: Catcrafts order CC-TEST01 confirmed\n") != std::string::npos,
"email: subject carries the reference");
Check(mail.find("Date: Fri, 08 Aug 2026 10:00:00 +0000\n") != std::string::npos,
"email: date header");
Check(mail.find("Message-ID: <0123456789abcdef0123456789abcdef@catcrafts.net>\n")
!= std::string::npos,
"email: message id from the token");
Check(mail.find("MIME-Version: 1.0\n") != std::string::npos, "email: mime version");
Check(mail.find("multipart/mixed") != std::string::npos, "email: multipart");
Check(mail.find("Fairphone 6 — Forest Green × 2") != std::string::npos,
"email: item names colour and quantity");
Check(mail.find("€1135.23") != std::string::npos, "email: total");
Check(mail.find("incl. 21% NL VAT") != std::string::npos, "email: EU VAT wording");
Check(mail.find("* Paid via: ideal\n") != std::string::npos, "email: payment method");
Check(mail.find("https://catcrafts.net/order/0123456789abcdef0123456789abcdef")
!= std::string::npos,
"email: order page link");
Check(mail.find("filename=\"catcrafts-invoice-"
"f57c6512-f012-4b91-adb3-077876480178-7.md\"") != std::string::npos,
"email: attachment filename is the invoice number");
Check(mail.find("SIGNED-INVOICE-STAND-IN\n") != std::string::npos,
"email: attachment body verbatim");
Check(mail.find("--=_cc_0123456789abcdef0123456789abcdef--\n") != std::string::npos,
"email: multipart closes");
Check(mail.find("KVK 78437059") != std::string::npos, "email: footer identity");
// The export wording mirrors the invoice's VAT treatment.
o.vatIncluded = false;
o.buyer.country = "GB";
o.totalMinor = 95502;
const std::string exMail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts ",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(exMail.find("zero-rated export") != std::string::npos,
"email: export VAT wording");
Check(exMail.find("€955.02") != std::string::npos, "email: export total");
// A single unit does not advertise a quantity.
o.quantity = 1;
const std::string one = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts ",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(one.find("Forest Green ×") == std::string::npos, "email: qty 1 stays silent");
// The last line of defence: an address that could smuggle a header
// yields NO message at all, however it got into the record.
o.buyer.email = "a@b.example\nBcc: leak@evil.example";
Check(Server::BuildOrderConfirmationEmail(
o, "F", "", "x", "u", "S", "D").empty(),
"email: header-injecting address yields no message");
}
// ── rates loader ──────────────────────────────────────────────────
const Rates r = LoadRates(
R"({"date":"2026-08-04","micro_per_eur":{"USD":1083400,"CAD":1489000}})");
Check(r.date == "2026-08-04", "rates: date");
Check(r.Find("USD") == 1'083'400, "rates: lookup");
Check(r.Find("XXX") == 0, "rates: absent is zero");
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
// ── the financials page ───────────────────────────────────────────
{
const Financials fin = LoadFinancials(
R"({"as_of":"2026-08-14",)"
R"("donations":{"count":3,"total_minor":4500},)"
R"("recurring":[{"label":"Hosting","total_minor":1200},)"
R"({"label":"Insurance","total_minor":3600}],)"
R"("single":[{"label":"Inventory","total_minor":230000}]})");
Check(fin.Loaded(), "financials: loads");
Check(fin.donationCount == 3 && fin.donationsMinor == 4500,
"financials: donations aggregate");
Check(fin.recurring.size() == 2 && fin.recurring[0].label == "Hosting"
&& fin.recurring[1].totalMinor == 3600,
"financials: recurring categories in order");
Check(fin.single.size() == 1 && fin.single[0].label == "Inventory",
"financials: one-off categories");
Check(fin.ExpensesMinor() == 234800, "financials: expense total");
Check(!LoadFinancials("garbage").Loaded(),
"financials: malformed input yields none");
Check(!LoadFinancials(R"({"donations":{"count":1,"total_minor":1}})").Loaded(),
"financials: undated figures stay unpublished");
Check(LoadFinancials(R"({"as_of":"2026-08-14","recurring":[{"total_minor":5}]})")
.recurring.empty(),
"financials: a category without a label is dropped");
Check(ParseRoute("/financials").kind == RouteKind::Financials,
"route: /financials");
Check(ParseRoute("/financials/").kind == RouteKind::Financials,
"route: /financials/ normalises");
bool inSitemap = false;
for (std::string_view p : SitemapPaths()) inSitemap = inSitemap || p == "/financials";
Check(inSitemap, "route: /financials is in the sitemap");
const LegalPage& notes = Content::FinancialsPage();
Check(notes.slug == "financials" && !notes.lede.empty()
&& notes.sections.size() >= 2,
"content: financials notes present");
Check(notes.lede.find("never published") != std::string::npos,
"content: financials lede states the privacy promise");
// The rendered page: live sales plus the bank aggregates, with the
// machine-readable copy the e2e suite reads.
const Views::RenderedPage fp = Views::RenderFinancials(2, 113745, fin);
Check(fp.status == 200, "financials: renders");
Check(fp.main.View().find("data-fin-sales-minor=\"113745\"") != std::string_view::npos
&& fp.main.View().find("data-fin-expenses-minor=\"234800\"")
!= std::string_view::npos,
"financials: machine-readable totals");
Check(fp.main.View().find("€1137.45") != std::string_view::npos
&& fp.main.View().find("€1182.45") != std::string_view::npos,
"financials: income rows and their total render");
Check(fp.main.View().find("Hosting") != std::string_view::npos
&& fp.main.View().find("€2348") != std::string_view::npos,
"financials: expense categories and their total render");
// Before the bank figures exist the page says so instead of lying
// with zeros — and publishes no donation figures at all.
const Views::RenderedPage bare = Views::RenderFinancials(0, 0, Financials{});
Check(bare.main.View().find("data-fin-sales-count=\"0\"") != std::string_view::npos
&& bare.main.View().find("not been published yet") != std::string_view::npos
&& bare.main.View().find("data-fin-donations-count") == std::string_view::npos,
"financials: unpublished bank figures say so and publish nothing");
// Lifetime sales: ever-paid counts, awaiting doesn't, a refund after
// payment stays counted, a hand-shipped legacy order counts too.
Server::OrderRecord paid;
paid.totalMinor = 56330;
paid.paidAt = "2026-08-14T00:00:00Z";
paid.status = "paid";
Server::OrderRecord waiting;
waiting.totalMinor = 99999;
Server::OrderRecord refunded;
refunded.totalMinor = 56930;
refunded.paidAt = "2026-08-14T00:00:00Z";
refunded.status = "cancelled";
Server::OrderRecord shipped;
shipped.totalMinor = 200;
shipped.status = "shipped";
const std::array orders{ paid, waiting, refunded, shipped };
const Server::SalesSummary sum = Server::SummarizeSales(orders);
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
"financials: sales count ever-paid orders only");
Check(Server::SummarizeSales({}).count == 0,
"financials: empty ledger sums to zero");
}
// ── the bunq mutation callback ────────────────────────────────────
//
// The callback is the only path by which a stranger's money reaches a
// public number on this site, so its parser, its classifier and above all
// its default-deny behaviour are pinned here. A rule that accidentally
// claims everything, or a classifier that treats an unrecognised transfer
// as a donation, would publish a figure that is simply untrue.
{
using Server::ParseSignedAmountToMinor;
Check(ParseSignedAmountToMinor("25.00") == 2500, "bunq: positive amount");
Check(ParseSignedAmountToMinor("-12.50") == -1250, "bunq: outgoing is negative");
Check(ParseSignedAmountToMinor("+5") == 500, "bunq: explicit plus");
Check(!ParseSignedAmountToMinor("1.234").has_value(), "bunq: too many decimals");
Check(!ParseSignedAmountToMinor("nonsense").has_value(), "bunq: non-numeric");
Check(!ParseSignedAmountToMinor("").has_value(), "bunq: empty amount");
// A realistic payload: the mutation is nested two wrappers deep, and
// the parser finds it by SHAPE so a wrapper rename cannot silently
// turn every callback into a no-op.
constexpr std::string_view kPayload =
R"({"NotificationUrl":{"target_url":"https://catcrafts.net/api/bunq/s",)"
R"("category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{)"
R"("id":4823,"created":"2026-08-14 09:31:02.123456","monetary_account_id":9911,)"
R"("amount":{"currency":"EUR","value":"25.00"},)"
R"("description":"Thanks for imsd!",)"
R"("counterparty_alias":{"iban":"NL55BUNQ2025123456","display_name":"A Donor"}}}}})";
const auto m = Server::ParseBunqMutation(kPayload);
Check(m.has_value(), "bunq: nested payload parses");
if (m) {
Check(m->id == "4823", "bunq: numeric id travels as text");
Check(m->amountMinor == 2500 && m->currency == "EUR", "bunq: amount and currency");
Check(m->account == "9911", "bunq: monetary account");
Check(m->counterpartyIban == "NL55BUNQ2025123456", "bunq: counterparty iban");
// The time of day never survives the parser: an exact timestamp
// is the one field that would let a watcher pin a donation to a
// person who mentioned donating.
Check(m->created == "2026-08-14", "bunq: only the date is kept");
}
Check(!Server::ParseBunqMutation("garbage").has_value(), "bunq: malformed payload");
Check(!Server::ParseBunqMutation(R"({"NotificationUrl":{"category":"MUTATION"}})")
.has_value(),
"bunq: a notification with no mutation yields nothing");
const Server::FinancialRules rules = Server::LoadFinancialRules(
R"({"donation_accounts":[9911],)"
R"("rules":[)"
R"({"iban":"NL01OWNSELF0000000","group":"ignore"},)"
R"({"description_contains":"hetzner","group":"recurring","label":"Hosting"},)"
R"({"iban":"DE02SUPPLIER000000","group":"single","label":"Inventory"},)"
R"({"group":"single","label":"Claims everything"},)"
R"({"iban":"NL03TYPO0000000000","group":"nonsense","label":"X"},)"
R"({"iban":"NL04NOLABEL0000000","group":"recurring"}]})");
Check(rules.donationAccounts.size() == 1 && rules.donationAccounts[0] == "9911",
"bunq: numeric donation account loads as text");
// Three of the six survive: the criterion-less rule would claim every
// mutation, the typo'd group is not a category, and an expense with
// no label has nothing to render as.
Check(rules.rules.size() == 3, "bunq: unsafe rules are dropped at load");
// Incoming on the donation account, claimed by no explicit rule.
Check(m && Server::ClassifyMutation(*m, rules).group == "donations",
"bunq: incoming on the donation account is a donation");
Server::BankMutation x = *m;
// Money LEAVING the donation account is not a gift to this company.
x.amountMinor = -2500;
Check(Server::ClassifyMutation(x, rules).group.empty(),
"bunq: outgoing on the donation account is not a donation");
// An explicit ignore beats the donation-account default, which is how
// the owner's own transfer between accounts stays out of the total.
x = *m;
x.counterpartyIban = "nl01ownself0000000";
Check(Server::ClassifyMutation(x, rules).group == "ignore",
"bunq: an explicit rule beats the donation default, case-insensitively");
// Foreign currency is never folded into a euro total.
x = *m;
x.currency = "USD";
Check(Server::ClassifyMutation(x, rules).group.empty(),
"bunq: non-euro is never counted");
// Default-deny: an ordinary transfer from a stranger, on an account
// that is not the donation one, is withheld rather than guessed at.
x = *m;
x.account = "1234";
x.counterpartyIban = "NL99UNKNOWN0000000";
x.description = "";
Check(Server::ClassifyMutation(x, rules).group.empty(),
"bunq: an unmatched mutation is withheld, not guessed");
Server::BankMutation bill;
bill.currency = "EUR";
bill.amountMinor = -1200;
bill.description = "HETZNER ONLINE GMBH invoice";
bill.created = "2026-08-15";
const Server::MutationClass billClass = Server::ClassifyMutation(bill, rules);
Check(billClass.group == "recurring" && billClass.label == "Hosting",
"bunq: description matching, case-insensitively");
// Folding into the aggregates.
Financials fin;
Server::ApplyMutation(fin, Server::ClassifyMutation(*m, rules), *m);
Check(fin.donationCount == 1 && fin.donationsMinor == 2500,
"bunq: a donation moves the count and the total");
Check(fin.asOf == "2026-08-14", "bunq: as-of follows the mutation date");
Server::ApplyMutation(fin, billClass, bill);
Check(fin.recurring.size() == 1 && fin.recurring[0].label == "Hosting"
&& fin.recurring[0].totalMinor == 1200,
"bunq: an outgoing bill becomes a positive expense");
Check(fin.asOf == "2026-08-15", "bunq: as-of advances");
// A supplier refund reduces the category rather than appearing as
// income, and never drags the as-of date backwards.
Server::BankMutation refund = bill;
refund.amountMinor = 500;
refund.created = "2026-08-01";
Server::ApplyMutation(fin, billClass, refund);
Check(fin.recurring[0].totalMinor == 700, "bunq: a refund reduces its category");
Check(fin.asOf == "2026-08-15", "bunq: as-of never moves backwards");
// An unclassified mutation touches nothing at all.
const Financials before = fin;
Server::ApplyMutation(fin, Server::MutationClass{}, *m);
Check(fin.donationCount == before.donationCount
&& fin.ExpensesMinor() == before.ExpensesMinor(),
"bunq: an unclassified mutation changes no total");
}
}
std::string ReadFile(const std::filesystem::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return {};
std::ostringstream buf;
buf << in.rdbuf();
return buf.str();
}
// Load content/ from disk. The wasm host reads the same bytes out of the VFS
// instead; the loaders are shared, so only the source of the bytes differs.
// Content loader for the CLI modes (--render, --routes, --sitemap, --feed).
//
// Must stay in step with Server::LoadContent, which the --serve path uses. They
// are separate because the CLI wants a value it can pass around while the server
// keeps process-wide state — but a field added to one and forgotten in the other
// shows up as content silently missing from exactly one code path, which is how
// products came to be absent from --routes and --sitemap while the live server
// served them fine.
Views::SiteContent LoadContent(const std::filesystem::path& root) {
Views::SiteContent c;
c.projects = Content::Projects();
c.products = Content::Products();
c.legal = Content::LegalPages();
c.demos = Content::Demos();
c.posts = LoadPosts(ReadFile(root / "posts.json"));
c.rates = LoadRates(ReadFile(root / "rates.json"));
return c;
}
} // namespace
int main(int argc, char** argv) {
const std::vector args(argv + 1, argv + argc);
const auto has = [&](std::string_view f) {
return std::find(args.begin(), args.end(), f) != args.end();
};
if (has("--selftest")) {
RunSelfTest();
RunJsonSelfTest();
RunFormSelfTest();
RunMoneySelfTest();
RunMediaSelfTest();
RunMarkdownSelfTest();
RunPostSelfTest();
if (failures == 0) {
std::println("Catcrafts.Shared self-test: all assertions passed");
return 0;
}
std::println(std::cerr, "Catcrafts.Shared self-test: {} failure(s)", failures);
return 1;
}
// --render : emit the full server-rendered document for a route.
//
// This is the SSR path in miniature, and it is how the markup gets
// inspected without a browser: same renderers, same content files, same
// output the server will eventually put on the wire.
if (args.size() >= 2 && args[0] == "--render") {
const Views::SiteContent content = LoadContent("content");
const Route route = ParseRoute(args[1]);
const Views::RenderedPage page = Views::RenderRoute(route, content);
std::print("{}", Views::RenderDocument(
page,
Views::RenderNav(NavKindFor(route.kind)),
Views::RenderFooter(),
/*bootScripts=*/"", // no wasm on a plain server render
/*cssHref=*/"/styles.css"));
return 0;
}
// --sitemap / --feed: generated from the same route table and Post model
// the pages use, so they cannot drift from what the site actually serves.
// The checked-in sitemap.xml this replaces still listed three blog posts
// that no longer exist.
//
// Html::Escape's output is valid XML text: & < > " are
// shared with XML, and it emits an apostrophe as the numeric reference
// ' rather than the HTML-only '. So no separate XML escaper.
if (has("--sitemap")) {
const Views::SiteContent content = LoadContent("content");
std::print("\n"
"\n");
for (std::string_view p : SitemapPaths()) {
std::print(" https://catcrafts.net{} \n",
Html::Escape(p).Str());
}
// Product URLs come from the loaded catalogue rather than a second
// hardcoded list, so the sitemap cannot advertise a product that does
// not exist or miss one that does.
for (const Product& pr : content.products) {
std::print(" https://catcrafts.net/shop/{} \n",
Html::Escape(pr.slug).Str());
}
// Same rule as the served sitemap: only posts that actually have a
// page. Both must agree, because this is the copy baked into the wasm
// bundle and that one is what a crawler fetches.
for (const Post& po : content.posts) {
if (!po.HasPage()) continue;
std::print(" https://catcrafts.net/posts/{} \n",
Html::Escape(po.slug).Str());
}
std::print(" \n");
return 0;
}
if (has("--feed")) {
const Views::SiteContent content = LoadContent("content");
std::print("{}", Views::RenderAtomFeed(content.posts));
return 0;
}
// --routes: status + title for every route, for a quick smoke check.
if (has("--routes")) {
const Views::SiteContent content = LoadContent("content");
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
"/financials",
"/order/0123456789abcdef0123456789abcdef",
"/order/not-a-token",
"/legal/privacy", "/legal/imprint",
"/legal/terms", "/legal/nope",
"/projects", "/posts", "/posts/nope", "/demos",
"/demos/raytracer", "/demos/nope", "/demo",
"/projects/", "/blog", "/blog/hello-world", "/nope" }) {
const Route r = ParseRoute(p);
const Views::RenderedPage page = Views::RenderRoute(r, content);
std::println("{:<22} status={} bytes={:<6} title={}",
p, page.status, page.main.Size(), page.meta.title);
}
return 0;
}
// --serve [port] [--content=DIR] [--webroot=DIR]
//
// Plaintext HTTP/1.1 for Caddy to reverse-proxy to; see
// Catcrafts.Server-Http.cpp for why not HTTP/3.
//
// Both directories are options rather than fixed paths because the
// development layout and the deployed layout differ: in the repo the
// content sits in ./content and the wasm bundle under ./bin/Catcrafts.Net-*/,
// while on the server the content is installed next to the binary and the
// bundle IS the webroot Caddy serves.
if (!args.empty() && args[0] == "--serve") {
std::uint16_t port = 8081;
std::filesystem::path contentDir = "content";
std::filesystem::path webroot;
// Default alongside the content in dev; the systemd unit points this at
// /var/lib/catcrafts, which is deliberately NOT the web root — that
// directory is publicly served and wiped by rsync --delete each deploy.
std::filesystem::path ordersPath = "orders.jsonl";
// Payment rail selection, one slot per payment choice the buyer gets.
// Flags beat environment beats default, and the default for each slot
// is "the provider whose key is set, off otherwise" — so a box with no
// credentials serves the whole site minus checkout instead of refusing
// to start, and a box with only one key offers only that one method.
//
// bank MOLLIE_API_KEY iDEAL, cards, transfer
// crypto COINGATE_API_KEY on-chain and Lightning, settled to EUR
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
const char* coingateKey = std::getenv("COINGATE_API_KEY");
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
std::string cryptoMode = coingateKey && *coingateKey ? "coingate" : "off";
bool coingateSandbox = [] {
const char* v = std::getenv("COINGATE_SANDBOX");
return v && std::string_view(v) == "1";
}();
std::filesystem::path railState;
std::string redirectBase = [] {
const char* v = std::getenv("ORDER_REDIRECT_BASE");
return v && *v ? std::string(v) : std::string("https://catcrafts.net");
}();
for (std::size_t i = 1; i < args.size(); ++i) {
const std::string_view a = args[i];
if (a.starts_with("--content=")) {
contentDir = a.substr(10);
} else if (a.starts_with("--webroot=")) {
webroot = a.substr(10);
} else if (a.starts_with("--orders=")) {
ordersPath = a.substr(9);
} else if (a.starts_with("--rail=")) {
railMode = a.substr(7);
} else if (a.starts_with("--crypto-rail=")) {
cryptoMode = a.substr(14);
} else if (a.starts_with("--rail-state=")) {
railState = a.substr(13);
} else if (a.starts_with("--redirect-base=")) {
redirectBase = a.substr(16);
} else {
std::uint32_t parsed = 0;
if (std::from_chars(a.data(), a.data() + a.size(), parsed).ec == std::errc{}
&& parsed > 0 && parsed <= 65535) {
port = static_cast(parsed);
} else {
std::println(std::cerr, "--serve: unrecognised argument '{}'", a);
return 2;
}
}
}
// The bundle's index.html supplies the