2026-08-15 00:54:05 +02:00
|
|
|
/*
|
|
|
|
|
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();
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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) {
|
2026-08-15 00:54:05 +02:00
|
|
|
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");
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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");
|
2026-08-15 00:54:05 +02:00
|
|
|
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
|
2026-08-17 11:04:03 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
|
|
|
|
|
"content: cheapest is green");
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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");
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
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");
|
|
|
|
|
}
|
2026-08-17 11:04:03 +02:00
|
|
|
|
|
|
|
|
// 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");
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
}
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
// ── 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(""x":["US","CA"]") != std::string_view::npos,
|
|
|
|
|
"checkout: the preview carries the no-sale list verbatim");
|
|
|
|
|
Check(html.find(""s":["RU","BY","KP"]")
|
|
|
|
|
!= 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(""g":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(""green":57380") != std::string_view::npos
|
|
|
|
|
&& html.find(""black":57980") != std::string_view::npos
|
|
|
|
|
&& html.find(""white":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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
int main() {
|
|
|
|
|
CatalogueContract();
|
|
|
|
|
IdentityGraph();
|
2026-08-17 11:04:03 +02:00
|
|
|
SaleGates();
|
|
|
|
|
CheckoutPreviewData();
|
|
|
|
|
LocalisedPriceBasis();
|
|
|
|
|
AdvertisedUrls();
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
if (failures != 0) {
|
|
|
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|