catcrafts.net/tests/ShouldShipContent/main.cpp
Jorijn van der Graaf abbd616b40
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
donation item, shop soft open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:04:03 +02:00

486 lines
26 KiB
C++

/*
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.
*/
// The compiled-in catalogue and the schema.org identity graph. Content is
// code now; these assertions are the contract the shop pages rely on,
// checked against the actual shipped data.
import std;
import Catcrafts.Shared;
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);
}
// ── the compiled-in catalogue ─────────────────────────────────────────
void CatalogueContract() {
using namespace Catcrafts::Money;
const auto& products = Content::Products();
// Two entries: the phone, and the donation item that soft-opens the shop.
// The phone stays FIRST — it is the headline, and the suites below
// address Products()[0] as the priced product.
Check(products.size() == 2, "content: two products");
if (products.size() == 2) {
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 + €60.50 gross markup,
// exactly — the gross-up of the €50 Catcrafts keeps after VAT. The
// margin identity itself (net of retail = net of supplier + 5000) is
// asserted below; these pin the resulting stickers.
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 57380,
"content: green = 513.30 supplier + 60.50 gross markup");
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 57980,
"content: black = 519.30 supplier + 60.50 gross markup");
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 66538,
"content: white = 604.88 supplier + 60.50 gross markup");
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
Check(pr.priceInclMinor == 57380, "content: from-price is the cheapest variant");
// The pricing rule as the user states it: after shipping (a pass-
// through) and VAT, every unit sold walks away with €50.00 — however
// the supplier moves. Checked against the COMPILED catalogue, per
// variant, in the same arithmetic the invoice and checkout use:
// net(retail) - net(supplier) must be exactly 5000 minor. Shipping
// has its own round-trip guarantee in ShouldComputeMoney, and
// Mollie's per-transaction fee is the one accepted deviation.
for (const auto& [slug, supplier] :
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
const Variant* v = pr.FindVariant(slug);
Check(v && Money::NetFromGross(v->priceInclMinor)
- Money::NetFromGross(supplier) == 5000,
"content: variant nets the supplier price plus exactly €50", slug);
}
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
"content: cheapest is green");
// The boxed weight of one outgoing parcel. Not decoration: this
// single integer picks the carrier's weight bracket, so it decides
// the shipping cents added to every order AND the per-country
// quantity ceiling. Regressed to 0 or off by an order of magnitude,
// the shop quotes a rate the carrier does not honour on real parcels.
Check(pr.shipWeightGrams == 700, "content: one boxed unit weighs 700 g");
{
// A ladder straddling that weight. 700 g fits both bands, and
// RateFor takes the CHEAPEST band that can carry it — 895, not
// the tighter-looking 5500 — while the ceiling comes off the
// heaviest band: 10000 / 700 = 14 units to a parcel.
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 5500 } };
Check(RateFor(ladder, pr.shipWeightGrams) == 895,
"content: the shipped weight lands in the cheap carrier bracket");
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
"content: and caps one parcel at fourteen units");
}
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<ShipRates> 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");
}
// The donation item: the shop's soft opening. Available (it is what
// the shop is open FOR) while the phone stays coming-soon; buyer
// names the amount, so no price, no variants, no weight — and the
// Buyable() price check is waived for exactly this shape.
const Product& don = products[1];
Check(don.slug == "donation" && don.donation,
"content: the second product is the donation item");
Check(don.Buyable() && !don.ComingSoon(),
"content: the donation item is on sale while the phone is not");
Check(don.priceInclMinor == 0 && don.variants.empty()
&& don.shipWeightGrams == 0,
"content: a donation has no price, no colours and no parcel");
Check(don.warranty.empty() && don.specs.empty(),
"content: a donation carries no spec sheet and no warranty");
// Its page: a donation form (amount + optional email), no price line,
// no product JSON-LD — an offer with no amount is a claim shopping
// crawlers can only misread — and no Fairphone sections.
{
const auto dp = Views::RenderProduct(don, Rates{});
const std::string_view html = dp.main.View();
Check(html.find("name=\"amount\"") != std::string_view::npos,
"donation page: the form asks for an amount");
Check(html.find("name=\"street\"") == std::string_view::npos
&& html.find("name=\"country\"") == std::string_view::npos,
"donation page: no address is asked for — nothing ships");
Check(html.find("field__req") == std::string_view::npos
|| html.find("Email <span") == std::string_view::npos,
"donation page: email is not marked required");
Check(html.find("price--product") == std::string_view::npos,
"donation page: no price line for an unpriced item");
Check(html.find("Specifications") == std::string_view::npos
&& html.find("Warranty") == std::string_view::npos,
"donation page: no spec or warranty section renders");
Check(dp.meta.jsonLd.empty(),
"donation page: no product JSON-LD is published");
Check(!dp.meta.geoPriceHint,
"donation page: no price-hint script — nothing to convert");
}
}
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.
void IdentityGraph() {
constexpr std::string_view kOrgId = "https://catcrafts.net/#organization";
auto home = Views::RenderHome(Content::Projects(), std::span<const Post>{});
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 sale gates, built by hand ─────────────────────────────────────
// The shipped catalogue is one product in one status with three colours, so
// asserting against it can only ever exercise one arm of each guard. These
// products exist to reach the others.
//
// Buyable() is the ONLY server-side gate on POST /checkout, and it is a
// conjunction: status AND a price. ComingSoon() is separately what decides
// whether a visitor is told "the shop has not opened yet" or "temporarily
// unavailable", so the two are pinned apart rather than as one either/or.
void SaleGates() {
Product priced;
priced.status = "available";
priced.priceInclMinor = 56330;
Check(priced.Buyable() && !priced.ComingSoon(),
"status: available with a price is the one buyable state");
// The half the catalogue can never exercise. An "available" product with
// no price is what a from-price sync that failed to run leaves behind
// (Content::Products derives priceInclMinor from CheapestVariant); if
// this arm of the conjunction regressed, checkout would mint a real order
// record and a live payment link for €0.
Product unpriced;
unpriced.status = "available";
unpriced.priceInclMinor = 0;
Check(!unpriced.Buyable(), "status: an unpriced product cannot be bought");
// The donation arm of the same conjunction: no price by definition, yet
// buyable — and ONLY because the flag says the buyer names the amount.
// The status half still gates it like anything else.
Product gift;
gift.status = "available";
gift.donation = true;
Check(gift.Buyable(), "status: an available donation needs no price");
gift.status = "unavailable";
Check(!gift.Buyable(), "status: a withdrawn donation is not buyable either");
Product withdrawn;
withdrawn.status = "unavailable";
withdrawn.priceInclMinor = 56330;
Check(!withdrawn.Buyable() && !withdrawn.ComingSoon(),
"status: unavailable is neither for sale nor coming soon");
Product soon;
soon.status = "coming-soon";
soon.priceInclMinor = 56330;
Check(soon.ComingSoon() && !soon.Buyable(),
"status: coming-soon publishes a price without opening orders");
// A product with no colours is a supported catalogue shape — priceInclMinor
// is then simply the price. The null return is the only thing standing
// between that shape and the two call sites that dereference the result
// (the checkout form's default selection, and the checkout handler).
Product plain;
plain.priceInclMinor = 56330;
Check(plain.CheapestVariant() == nullptr,
"variants: no colours means no cheapest colour");
Check(plain.FindVariant("green") == nullptr && plain.FindVariant("") == nullptr,
"variants: nothing is ever found in an empty colour list");
// The loop compares with a strict <, so a tie keeps the listed order.
// That is what stops the advertised "from" price and the form's default
// selection from naming different colours when two cost the same.
Product tied;
tied.priceInclMinor = 56330;
tied.variants = {
{ "green", "Forest Green", 56330 },
{ "black", "Black", 56330 },
};
Check(tied.CheapestVariant() && tied.CheapestVariant()->slug == "green",
"variants: equally priced colours keep the listed order");
}
// ── the product page's live-total blob ────────────────────────────────
// RenderCheckoutForm renders only for a Buyable product, and the shipped
// catalogue is coming-soon, so today nothing renders it: the two suites that
// read this attribute both sit behind a ShopOpen() gate. Flip a copy to
// "available" and read the markup here instead — the refusal lists in that
// blob are what make the on-page total decline in exactly the places
// checkout declines, and a page that quotes a total for a sanctioned or
// no-sale destination invites an order that must then be refused.
void CheckoutPreviewData() {
if (Content::Products().empty()) return;
Product pr = Content::Products()[0];
pr.status = "available";
Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders");
const std::vector<Money::ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "DE", { { 2000, 995 } } },
};
const auto page = Views::RenderProduct(pr, Rates{}, feedTable, {}, {}, true);
const std::string_view html = page.main.View();
// The blob is a JSON document inside an HTML attribute, so every quote
// arrives escaped — matching the escaped form is matching what the
// browser actually parses back out.
Check(html.find("&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]") != std::string_view::npos,
"checkout: the preview carries the no-sale list verbatim");
Check(html.find("&quot;s&quot;:[&quot;RU&quot;,&quot;BY&quot;,&quot;KP&quot;]")
!= std::string_view::npos,
"checkout: the preview carries the sanctions list verbatim");
// The unit weight, which is what selects a bracket out of the carrier
// table the same blob carries.
Check(html.find("&quot;g&quot;:700") != std::string_view::npos,
"checkout: the preview knows one unit's shipping weight");
// Per-colour unit prices: the preview multiplies these, so they are the
// same integers the checkout charges or the two disagree on screen.
Check(html.find("&quot;green&quot;:57380") != std::string_view::npos
&& html.find("&quot;black&quot;:57980") != std::string_view::npos
&& html.find("&quot;white&quot;:66538") != std::string_view::npos,
"checkout: every colour is priced in the preview blob");
}
// ── which euro amount a localised price converts ──────────────────────
// The headline price rides along as one pre-formatted attribute per
// currency, and the basis differs by EU membership: a member's currency
// converts the VAT-inclusive price the buyer pays, everyone else's converts
// the ex-VAT export price. Invert that test and a Swedish visitor sees a
// figure 21% below what their card is charged, or a British one 21% above
// the export price — both while every existing assertion (which only checks
// that the attributes exist) still passes.
void LocalisedPriceBasis() {
// €121.00 inclusive: net = 12100 * 10000 / 12100 = 10000 exactly, so the
// two bases are a clean €121 and €100 with no rounding to reason about.
Product pr;
pr.slug = "basis-probe";
pr.name = "Basis probe";
pr.status = "coming-soon";
pr.priceInclMinor = 12100;
// 1 EUR = 1 unit in both currencies, so the printed number can only
// report which euro amount the conversion started from.
Rates rates;
rates.date = "2026-01-01";
rates.microPerEur.emplace_back("SEK", 1'000'000);
rates.microPerEur.emplace_back("GBP", 1'000'000);
const auto page = Views::RenderProduct(pr, rates);
const std::string_view html = page.main.View();
// SE is an EU member: base 12100, converted whole and half-up ->
// (12100 * 1e6 + 5e7) / 1e8 = 121.
Check(html.find(R"(data-sek="~kr 121")") != std::string_view::npos,
"price: an EU member's currency converts the VAT-inclusive price");
// GB is not: base 10000 -> 100.
Check(html.find(R"(data-gbp="~£100")") != std::string_view::npos,
"price: a non-EU currency converts the ex-VAT export price");
// The euro fallback that no-JS visitors and crawlers read is the export
// price, in the same units the two above were derived from.
Check(html.find(R"(data-world="100")") != std::string_view::npos,
"price: the export euro price is the world default");
}
// ── the URLs this site advertises as its own ──────────────────────────
// The sitemap is the list of URLs the site asks crawlers to index; the nav
// is what a visitor clicks. Both are hand-maintained lists, so a renamed
// legal slug or a retired path left behind advertises a 404 — or a 301 — as
// canonical, silently and forever. Nothing else cross-checks them: the route
// suite walks a literal list of its own rather than SitemapPaths().
void AdvertisedUrls() {
Views::SiteContent site;
site.legal = Content::LegalPages();
for (const std::string_view path : SitemapPaths()) {
const Route r = ParseRoute(path);
Check(r.kind != RouteKind::NotFound,
"sitemap: every advertised path resolves to a page", path);
// A route carrying a canonical target is a redirect whatever it
// renders, and asking a crawler to index a redirect is asking it to
// index a non-canonical URL.
Check(r.canonicalRedirect.empty(),
"sitemap: no advertised path is itself a redirect", path);
// Parsing only proves the URL is SHAPED like a legal page; the
// dispatcher's lookup is what decides whether it renders one.
if (r.kind == RouteKind::Legal) {
Check(site.FindLegal(r.slug) != nullptr,
"sitemap: every advertised legal slug names a real page", path);
}
}
for (const NavItem& item : NavItems()) {
Check(ParseRoute(item.href).kind == item.kind,
"nav: every nav entry parses to the route it claims", item.href);
}
}
} // namespace
int main() {
CatalogueContract();
IdentityGraph();
SaleGates();
CheckoutPreviewData();
LocalisedPriceBasis();
AdvertisedUrls();
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}