removed fp6 from shop
All checks were successful
Deploy / build-deploy (push) Successful in 5m35s

This commit is contained in:
Jorijn van der Graaf 2026-09-05 18:38:39 +02:00
commit 25805a093a
20 changed files with 788 additions and 805 deletions

View file

@ -8,8 +8,8 @@ No permission is granted to copy, modify, distribute, or create derivative works
// The JSON-LD blocks are the machine-readable identity and offer records,
// born from a Google AI overview flatly asserting that nobody named Catcrafts
// sells Fairphone hardware. Parsed with our own JSON reader — the same one
// that must accept them — and checked for the facts.
// sells the hardware it then sold. Parsed with our own JSON reader — the same
// one that must accept them — and checked for the facts.
import std;
import Catcrafts.Shared;
@ -64,28 +64,7 @@ int main(int argc, char** argv) {
Check(ok, "home Organization schema parses and carries the VAT identity");
}
// ── the product record ────────────────────────────────────────────
// Variants are a ProductGroup: one variant Product per colour, each with
// its own single offer — not one Product with three prices.
const std::vector<Json::Value> productLd = ExtractLd(srv, "/shop/fp6-pmos");
const Json::Value* group = nullptr;
for (const Json::Value& doc : productLd) {
if (doc.Str("@type") == "ProductGroup") group = &doc;
}
{
bool variantsOk = false;
if (group) {
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
&& v->array.size() == 3) {
variantsOk = true;
for (const Json::Value& node : v->array) {
const Json::Value* offers = node.Find("offers");
variantsOk = variantsOk && offers && offers->IsObject();
}
}
}
Check(variantsOk, "product schema parses with one variant (and offer) per colour");
}
// ── the shop index ────────────────────────────────────────────────
{
bool listOk = false;
for (const Json::Value& doc : ExtractLd(srv, "/shop")) {
@ -96,80 +75,117 @@ int main(int argc, char** argv) {
}
Check(listOk, "shop index carries an ItemList of the product pages");
}
srv.BodyHas("/shop/fp6-pmos", "\"price\":\"573.80\"", "schema price is the checkout integer");
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
// reads. Shipping is published from the live carrier table at one unit's
// weight — the same integers checkout charges, so the listing cannot
// quote a rate the till won't honour; returns mirror the terms page.
srv.BodyHas("/shop/fp6-pmos", "OfferShippingDetails", "offers carry shipping details");
srv.BodyHas("/shop/fp6-pmos", "MerchantReturnPolicy", "offers carry a return policy");
srv.BodyHas("/shop/fp6-pmos", "\"sku\":\"fp6-pmos-green\"", "offers carry per-variant skus");
srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}",
"product carries the hardware brand");
// One entry per (transit tier, price) the carrier table produces, for the
// destinations the shop actually sells to — two, since the fixture prices
// NL/DE/GB/CH and checkout refuses DE and GB pending their producer
// registrations. Not a fixed property of the code: it is whatever the
// carrier prices INTERSECTED with where the shop sells, and publishing a
// rate to a country checkout would decline is an offer that cannot be
// accepted.
const Json::Value* shipping = nullptr;
if (group) {
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
&& !v->array.empty()) {
if (const Json::Value* offers = v->array[0].Find("offers")) {
shipping = offers->Find("shippingDetails");
}
// ── the product record ────────────────────────────────────────────
// Against whatever priced product the catalogue lists first, with every
// expected value read off that record: a listing coming or going is a
// content change, not an edit here. While no goods are listed (as since
// 2026-09-05) this block prints a note and is skipped.
if (const Product* pr = FirstPricedProduct()) {
const std::string shop = "/shop/" + pr->slug;
// Variants are a ProductGroup: one variant Product per colour, each
// with its own single offer — not one Product with three prices. A
// single-colour or colourless listing is a plain Product with one offer.
const bool grouped = pr->variants.size() > 1;
// Kept alive for the block: `record` and `firstOffer` point into it.
const std::vector<Json::Value> productLd = ExtractLd(srv, shop);
const Json::Value* record = nullptr;
for (const Json::Value& doc : productLd) {
if (doc.Str("@type") == (grouped ? "ProductGroup" : "Product")) record = &doc;
}
}
Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
"shipping details group the carrier's rates");
// The advertised rate IS the carrier's single-unit price. Neither a
// destination the table does not cover (AU) nor one the policy refuses
// (DE, priced at €25 in the fixture) is ever advertised.
if (shipping && shipping->IsArray()) {
std::vector<std::string> rates;
bool au = false;
bool refused = false;
for (const Json::Value& detail : shipping->array) {
if (const Json::Value* rate = detail.Find("shippingRate")) {
rates.emplace_back(rate->Str("value"));
const Json::Value* firstOffer = nullptr;
{
bool offersOk = false;
if (record && grouped) {
if (const Json::Value* v = record->Find("hasVariant"); v && v->IsArray()
&& v->array.size() == pr->variants.size()) {
offersOk = true;
for (const Json::Value& node : v->array) {
const Json::Value* offers = node.Find("offers");
offersOk = offersOk && offers && offers->IsObject();
}
if (!v->array.empty()) firstOffer = v->array[0].Find("offers");
}
} else if (record) {
firstOffer = record->Find("offers");
offersOk = firstOffer && firstOffer->IsObject();
}
if (const Json::Value* dest = detail.Find("shippingDestination")) {
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
for (const Json::Value& c : cc->array) {
au = au || c.string == "AU";
refused = refused || c.string == "DE" || c.string == "GB";
Check(offersOk, grouped
? "product schema parses with one variant (and offer) per colour"
: "product schema parses with its one offer");
}
srv.BodyHas(shop, "\"price\":\"" + Money::FormatMinor(pr->priceInclMinor) + "\"",
"schema price is the checkout integer");
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
// reads. Shipping is published from the live carrier table at one unit's
// weight — the same integers checkout charges, so the listing cannot
// quote a rate the till won't honour; returns mirror the terms page.
srv.BodyHas(shop, "OfferShippingDetails", "offers carry shipping details");
srv.BodyHas(shop, "MerchantReturnPolicy", "offers carry a return policy");
const std::string sku = pr->variants.empty()
? pr->slug : pr->slug + "-" + pr->variants[0].slug;
srv.BodyHas(shop, "\"sku\":\"" + sku + "\"", "offers carry per-variant skus");
if (!pr->brand.empty()) {
srv.BodyHas(shop, "\"brand\":{\"@type\":\"Brand\",\"name\":\"" + pr->brand + "\"}",
"product carries the hardware brand");
}
// One entry per (transit tier, price) the carrier table produces, for
// the destinations the shop actually sells to — two, since the fixture
// prices NL/DE/GB/CH and checkout refuses DE and GB pending their
// producer registrations. Not a fixed property of the code: it is
// whatever the carrier prices INTERSECTED with where the shop sells,
// and publishing a rate to a country checkout would decline is an
// offer that cannot be accepted.
const Json::Value* shipping = firstOffer ? firstOffer->Find("shippingDetails") : nullptr;
Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
"shipping details group the carrier's rates");
// The advertised rate IS the carrier's single-unit price. Neither a
// destination the table does not cover (AU) nor one the policy refuses
// (DE, priced at €25 in the fixture) is ever advertised.
if (shipping && shipping->IsArray()) {
std::vector<std::string> rates;
bool au = false;
bool refused = false;
for (const Json::Value& detail : shipping->array) {
if (const Json::Value* rate = detail.Find("shippingRate")) {
rates.emplace_back(rate->Str("value"));
}
if (const Json::Value* dest = detail.Find("shippingDestination")) {
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
for (const Json::Value& c : cc->array) {
au = au || c.string == "AU";
refused = refused || c.string == "DE" || c.string == "GB";
}
}
}
}
std::ranges::sort(rates);
Check(rates == std::vector<std::string>{ "15.00", "55.00" },
"published shipping rates come from the carrier table");
Check(!au, "an uncovered destination is not advertised");
Check(!refused, "a destination the policy refuses is not advertised");
}
std::ranges::sort(rates);
Check(rates == std::vector<std::string>{ "15.00", "55.00" },
"published shipping rates come from the carrier table");
Check(!au, "an uncovered destination is not advertised");
Check(!refused, "a destination the policy refuses is not advertised");
}
if (srv.ShopOpen()) {
srv.BodyHas("/shop/fp6-pmos", "schema.org/InStock",
"open shop maps to InStock availability");
if (srv.ShopOpen(pr->slug)) {
srv.BodyHas(shop, "schema.org/InStock", "open shop maps to InStock availability");
} else {
srv.BodyHas(shop, "schema.org/PreOrder", "coming-soon maps to PreOrder availability");
}
// og: tags are the link-preview card on Mastodon and Lemmy — where the
// traffic actually comes from. Per product, not per site: two listings
// that share a link-preview image are two posts that look like the
// same thing.
if (!pr->image.empty()) {
srv.BodyHas(shop, "og:image\" content=\"https://catcrafts.net" + pr->image,
"product og:image is the absolute photo URL");
}
} else {
srv.BodyHas("/shop/fp6-pmos", "schema.org/PreOrder",
"coming-soon maps to PreOrder availability");
std::println("note: the catalogue lists no priced product; the product-record "
"checks did not run and re-arm when one is listed");
}
// og: tags are the link-preview card on Mastodon and Lemmy — where the
// traffic actually comes from.
srv.BodyHas("/", "property=\"og:title\"", "home page has og:title");
srv.BodyHas("/shop/fp6-pmos", "og:image\" content=\"https://catcrafts.net/fp6-pmos.jpg",
"product og:image is the absolute photo URL");
// Per product, not per site: two phones that share a link-preview image
// are two Mastodon posts that look like the same phone.
srv.BodyHas("/shop/fp6plus-pmos",
"og:image\" content=\"https://catcrafts.net/fp6plus-pmos.jpg",
"the refresh previews with its own photo, not the Gen. 6's");
// ── the person-company weld ───────────────────────────────────────
// The about page: the founder must be named in the HTML, the Person

View file

@ -13,12 +13,15 @@ No permission is granted to copy, modify, distribute, or create derivative works
// payment links and reports "paid" once the marker file exists — which is how
// this suite simulates the customer paying.
//
// The lifecycle half runs only while the shop is OPEN; while coming-soon it
// asserts the closed behaviour instead. Launch day (status flip to
// "available" in Catcrafts.Shared-Content.cppm) re-arms the full suite with
// no edit here. The validation half runs in both states on purpose:
// validation happens before the coming-soon check, and that ordering is
// exactly what it pins.
// The goods half addresses whatever priced product the compiled catalogue
// lists first — slug, default colour, boxed weight — and derives every expected
// figure from that record and the harness's carrier table with the till's own
// Money arithmetic. It runs only while that product is OPEN; while coming-soon
// it asserts the closed behaviour instead, and while the catalogue lists no
// goods at all (as since 2026-09-05) it prints a note and is skipped — listing
// one re-arms it with no edit here. The validation half runs in both shop
// states on purpose: validation happens before the coming-soon check, and that
// ordering is exactly what it pins. The donation lifecycle runs regardless.
import std;
import Catcrafts.Shared;
@ -81,21 +84,46 @@ bool GpgVerifies(const std::filesystem::path& file) {
file.string()).c_str()) == 0;
}
void OpenShopLifecycle(TestServer& srv) {
// The carrier table the harness hands the server (kShippingFixture), as the
// ladders the totals below are derived from: one parcel under 2 kg ships for
// €15 at home and €55 to Switzerland, and the 10 kg band is what caps a parcel.
const std::vector<Money::ShipBracket> kNl{ { 2000, 1500 }, { 10000, 2900 } };
const std::vector<Money::ShipBracket> kCh{ { 2000, 5500 }, { 10000, 7900 } };
// What the till charges for `quantity` units at `unit` cents to `cc`: the
// parcel weighs quantity × boxed weight, the carrier prices that parcel, and
// ComputeTotals nets the LINE for an export. The same three calls the
// checkout handler makes, so a figure asserted here is one the shop derives,
// not one anybody typed.
std::int64_t Total(const Product& pr, std::int64_t unit, std::int64_t quantity,
std::span<const Money::ShipBracket> ladder, std::string_view cc) {
const std::int64_t shipping = Money::RateFor(ladder, pr.shipWeightGrams * quantity);
return Money::ComputeTotals(unit, quantity, shipping, cc).total;
}
void OpenShopLifecycle(TestServer& srv, const Product& pr) {
const std::string shop = "/shop/" + pr.slug;
const std::int64_t unit = pr.priceInclMinor; // the default (cheapest) colour
const std::int64_t nlTotal = Total(pr, unit, 1, kNl, "NL");
const std::string nlEuro = Money::FormatEuro(nlTotal);
// The one-parcel ceiling: the heaviest band any destination offers (10 kg
// in the fixture) divided by the boxed unit weight.
const std::int64_t maxUnits = Money::MaxUnitsFor(kNl, pr.shipWeightGrams);
// ── checkout ──────────────────────────────────────────────────────
// A valid submission answers 303 straight to the PAYMENT page — no
// interim stop. The fake rail's payUrl is the order page itself, so the
// token is still extractable from the Location and the browser flow works
// in dev.
const auto checkout = srv.Post("/shop/fp6-pmos", Good());
const auto checkout = srv.Post(shop, Good());
const std::string token = TokenOf(checkout);
Check(checkout.status == "303" && !token.empty(),
"POST checkout -> 303 straight to payment", checkout.status);
{
const std::string ledger = srv.OrdersText();
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
&& ledger.find("\"total_minor\":58880") != std::string::npos,
"order stored: NL total is €588.80 (green €573.80 + €15 shipping)");
&& ledger.find(std::format("\"total_minor\":{}", nlTotal)) != std::string::npos,
"order stored: NL total is the default colour plus €15 home shipping");
// No `pay` field in that submission, which is what a form with only
// one rail configured posts: it must land on the bank rail rather
// than nothing.
@ -108,8 +136,9 @@ void OpenShopLifecycle(TestServer& srv) {
const std::string orderPath = std::format("/order/{}", token);
{
const std::string page = srv.Body(orderPath);
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
"http-equiv=\"refresh\"", "€588.80" }) {
for (std::string_view probe : std::initializer_list<std::string_view>{
"awaiting payment", "Resume payment", "CC-",
"http-equiv=\"refresh\"", nlEuro }) {
Check(page.find(probe) != std::string::npos,
std::format("order page has {}", probe));
}
@ -128,11 +157,11 @@ void OpenShopLifecycle(TestServer& srv) {
// assertion that matters: it is what the reconciler later reads to decide
// WHICH provider may confirm the order, so a choice that renders but is
// not stored would mean crypto orders being asked about at the bank.
srv.BodyHas("/shop/fp6-pmos", "name=\"pay\"", "the form offers a payment choice");
srv.BodyHas("/shop/fp6-pmos", "value=\"crypto\"", "crypto is one of the choices");
srv.BodyHas("/shop/fp6-pmos", "value=\"bank\" checked", "bank is the pre-selected choice");
srv.BodyHas(shop, "name=\"pay\"", "the form offers a payment choice");
srv.BodyHas(shop, "value=\"crypto\"", "crypto is one of the choices");
srv.BodyHas(shop, "value=\"bank\" checked", "bank is the pre-selected choice");
const std::string tokenCrypto = TokenOf(srv.Post("/shop/fp6-pmos", Good("&pay=crypto")));
const std::string tokenCrypto = TokenOf(srv.Post(shop, Good("&pay=crypto")));
Check(!tokenCrypto.empty(), "a crypto order goes through");
if (!tokenCrypto.empty()) {
bool recorded = false;
@ -161,7 +190,7 @@ void OpenShopLifecycle(TestServer& srv) {
// so the form comes back with the choice highlighted rather than a bare
// 400.
{
const auto bogus = srv.Post("/shop/fp6-pmos", Good("&pay=invoice-me-later"));
const auto bogus = srv.Post(shop, Good("&pay=invoice-me-later"));
Check(bogus.status == "422", "an unknown payment method is refused", bogus.status);
Check(bogus.body.find("Pick one of the payment methods") != std::string::npos,
"the refusal names the payment field");
@ -171,13 +200,13 @@ void OpenShopLifecycle(TestServer& srv) {
// national currency line sourced from the build-time ECB rates. CH because
// it is the export destination that actually sells — North America is
// refused on insurance, and GB waits on its e-waste registrations.
const std::string tokenCh = TokenOf(srv.Post("/shop/fp6-pmos",
const std::string tokenCh = TokenOf(srv.Post(shop,
"email=ch%40example.org&name=Heidi&street=1%20Bahnhofstrasse&postal=8001&city=Zurich&country=CH"));
Check(!tokenCh.empty(), "CH checkout issues an order");
if (!tokenCh.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenCh));
// €474.21 goods (green net) + €55 world shipping = €529.21
Check(page.find("€529.21") != std::string::npos,
// Net of the default colour + €55 world shipping.
Check(page.find(Money::FormatEuro(Total(pr, unit, 1, kCh, "CH"))) != std::string::npos,
"export order total is ex-VAT + world shipping");
Check(page.find("Zero-rated export") != std::string::npos,
"export order states the VAT treatment");
@ -187,42 +216,55 @@ void OpenShopLifecycle(TestServer& srv) {
"conversion is labelled indicative");
}
// A two-unit white export order: unit €665.38, line €1330.76, net from the
// LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80.
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&color=white&quantity=2"));
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
if (!tokenWhite.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
Check(page.find("€1154.80") != std::string::npos,
"white ×2 export total nets the line, not the unit");
// A two-unit export order in the dearest colour (or the only price, for a
// colourless listing): the export net comes from the LINE total (unit × 2),
// not per unit, plus one parcel's world shipping at twice the weight.
const Variant* dear = nullptr;
for (const Variant& v : pr.variants) {
if (!dear || v.priceInclMinor > dear->priceInclMinor) dear = &v;
}
const std::int64_t dearUnit = dear ? dear->priceInclMinor : unit;
const std::string tokenTwo = TokenOf(srv.Post(shop,
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&quantity=2"
+ (dear ? "&color=" + dear->slug : std::string{})));
Check(!tokenTwo.empty(), "a two-unit checkout issues an order");
if (!tokenTwo.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenTwo));
Check(page.find(Money::FormatEuro(Total(pr, dearUnit, 2, kCh, "CH"))) != std::string::npos,
"two-unit export total nets the line, not the unit");
Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity");
Check(page.find("White") != std::string::npos, "order page names the colour");
if (dear) {
Check(page.find(dear->label) != std::string::npos, "order page names the colour");
}
}
// A colour we never listed must not buy anything, whatever the form claims.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&color=mauve"));
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=100"));
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=0"));
if (!pr.variants.empty()) {
srv.CheckStatus(shop, "422", "POST", Good("&color=no-such-colour"));
}
srv.CheckStatus(shop, "422", "POST", Good("&quantity=100"));
srv.CheckStatus(shop, "422", "POST", Good("&quantity=0"));
// Quantity is a free input with a technical ceiling, not a dropdown — a
// nine-unit order is business, not fraud.
Check(!TokenOf(srv.Post("/shop/fp6-pmos", Good("&quantity=9"))).empty(),
"a nine-unit order goes through");
srv.BodyHas("/shop/fp6-pmos", "type=\"number\"", "quantity is a number input, not a dropdown");
// multi-unit order within the parcel is business, not fraud.
const std::int64_t several = std::min<std::int64_t>(9, maxUnits);
Check(several > 1 && !TokenOf(srv.Post(shop, Good(std::format("&quantity={}", several)))).empty(),
std::format("a {}-unit order goes through", several));
srv.BodyHas(shop, "type=\"number\"", "quantity is a number input, not a dropdown");
// The ceiling is physical: the heaviest band any destination offers
// (10 kg in the fixture) divided by the boxed unit weight (700 g) = 14.
// The input advertises the BEST case across destinations; the per-country
// limit is enforced on submit, below.
srv.BodyHas("/shop/fp6-pmos", "max=\"14\"", "quantity input carries the one-parcel ceiling");
// (10 kg in the fixture) divided by the boxed unit weight. The input
// advertises the BEST case across destinations; the per-country limit is
// enforced on submit, below.
srv.BodyHas(shop, std::format("max=\"{}\"", maxUnits),
"quantity input carries the one-parcel ceiling");
// One order is one parcel. Fifteen units is 10.5 kg, past every band the
// fixture has, so it must be refused rather than quoted a rate the
// One order is one parcel. One unit past the ceiling is past every band
// the fixture has, so it must be refused rather than quoted a rate the
// carrier would not accept — and the refusal has to say what WOULD fit,
// or the buyer is left guessing.
{
const auto heavy = srv.Post("/shop/fp6-pmos", Good("&quantity=15"));
const auto heavy = srv.Post(shop, Good(std::format("&quantity={}", maxUnits + 1)));
Check(heavy.status == "422", "an over-weight order is refused", heavy.status);
Check(heavy.body.find("up to 14 per order") != std::string::npos,
Check(heavy.body.find(std::format("up to {} per order", maxUnits)) != std::string::npos,
"the too-heavy refusal says what fits");
Check(heavy.body.find("orders@catcrafts.net") != std::string::npos,
"the too-heavy refusal offers a way to order anyway");
@ -235,17 +277,17 @@ void OpenShopLifecycle(TestServer& srv) {
constexpr std::string_view kAu =
"email=au%40example.org&name=Alex&street=1%20George%20St&postal=2000&city=Sydney&country=AU";
{
const auto au = srv.Post("/shop/fp6-pmos", std::string(kAu));
const auto au = srv.Post(shop, std::string(kAu));
Check(au.status == "422", "an uncovered destination is refused", au.status);
Check(au.body.find("No carrier rate for AU") != std::string::npos,
"an uncovered destination is refused, naming the country");
// The country FIELD ERROR must be the carrier message, not the
// no-sale one. Matched on the error markup rather than the bare
// sentence: the no-sale line is standing copy above every buy form.
Check(au.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
Check(au.body.find("field__error\">Catcrafts can&#39;t ship there") == std::string::npos,
"an uncovered destination is not confused with a refused one");
const std::size_t before = LedgerLines(srv).size();
srv.Post("/shop/fp6-pmos", std::string(kAu));
srv.Post(shop, std::string(kAu));
Check(LedgerLines(srv).size() == before, "a refused destination writes no order");
}
@ -286,10 +328,11 @@ void OpenShopLifecycle(TestServer& srv) {
// registered identity, amounts — and a signature that verifies offline.
{
const auto invoice = srv.Get(std::format("/order/{}/invoice.md", token));
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
"Customer number: ", "Chico Mendesring 256",
"KVK 78437059", "NL003329281B38", "CC-",
"VAT 21% (NL)", "€588.80" }) {
for (std::string_view probe : std::initializer_list<std::string_view>{
"BEGIN PGP SIGNED MESSAGE", "# Invoice ",
"Customer number: ", "Chico Mendesring 256",
"KVK 78437059", "NL003329281B38", "CC-",
"VAT 21% (NL)", nlEuro }) {
Check(invoice.body.find(probe) != std::string::npos,
std::format("invoice has {}", probe));
}
@ -373,11 +416,12 @@ void OpenShopLifecycle(TestServer& srv) {
}
Check(!nlMail.empty(), "a confirmation email links the NL order");
if (!nlMail.empty()) {
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
"From: Catcrafts <info@catcrafts.net>",
"MIME-Version: 1.0", "€588.80", "incl. 21% NL VAT",
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
"filename=\"catcrafts-invoice-" }) {
for (std::string_view probe : std::initializer_list<std::string_view>{
"To: e2e@example.org", "Subject: Catcrafts order CC-",
"From: Catcrafts <info@catcrafts.net>",
"MIME-Version: 1.0", nlEuro, "incl. 21% NL VAT",
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
"filename=\"catcrafts-invoice-" }) {
Check(nlMail.find(probe) != std::string::npos,
std::format("email has {}", probe));
}
@ -451,24 +495,26 @@ void OpenShopLifecycle(TestServer& srv) {
}
}
void ComingSoon(TestServer& srv) {
void ComingSoon(TestServer& srv, const Product& pr) {
const std::string shop = "/shop/" + pr.slug;
// A perfectly valid order must be refused while the shop is closed: after
// validation (so the field checks below still exercise the parser) and
// before any rail or ledger is touched.
srv.CheckStatus("/shop/fp6-pmos", "409", "POST", Good());
srv.CheckStatus(shop, "409", "POST", Good());
Check(srv.OrdersText().empty(), "refused order writes nothing to the ledger");
std::println("shop is coming-soon; the checkout, order-lifecycle and invoice "
"checks re-arm when the status flips to available");
}
void AlwaysOnValidation(TestServer& srv) {
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
void AlwaysOnValidation(TestServer& srv, const Product& pr) {
const std::string shop = "/shop/" + pr.slug;
srv.CheckStatus(shop, "422", "POST",
"name=Ada&street=x&postal=1&city=y&country=NL"); // no email
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=nonsense&" + Good()); // bad email (dup keeps first)
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=a%40b.example&country=NL"); // missing address
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot
srv.CheckStatus(shop, "422", "POST", Good("&website=spam")); // honeypot
// Destinations the shop refuses (Money::SellsTo). Well-formed, real
// addresses: the refusal is policy, not a shape check, so it has to hold for
@ -477,20 +523,20 @@ void AlwaysOnValidation(TestServer& srv) {
// whether the shop is open or not, and it is the assertion that would catch
// the gate being lost in a refactor.
const std::size_t before = LedgerLines(srv).size();
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us");
// Sanctioned destinations (Money::SanctionedCountries) refuse through the
// same always-on gate — this refusal is the law, so of all the checks in
// this file it is the one that must survive every refactor.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru");
// Destinations off the shipping allow-list (Money::ShipsTo). DE and GB are
// the pointed cases: the fixture PRICES both, so a rate exists and the parcel
@ -503,19 +549,19 @@ void AlwaysOnValidation(TestServer& srv) {
// no order form for a field error to land in. What is worth proving over real
// HTTP is that the refusal holds at all, in both states — which is what 422
// says here.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=de%40example.org&name=Klaus&street=1%20Hauptstr&postal=10115&city=Berlin&country=DE");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=no%40example.org&name=Kari&street=1%20Karl%20Johans&postal=0154&city=Oslo&country=NO");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=tr%40example.org&name=Emre&street=1%20Istiklal&postal=34430&city=Istanbul&country=TR");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=br%40example.org&name=Ana&street=1%20Paulista&postal=01310&city=Sao%20Paulo&country=BR");
// And the default that makes an allow-list worth having: a well-formed code
// nobody ever considered is refused without appearing on any list.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
srv.CheckStatus(shop, "422", "POST",
"email=zz%40example.org&name=Sam&street=1%20Main&postal=0000&city=Nowhere&country=ZZ");
// Refused in validation means nothing reached the ledger and no payment
// link was ever created.
@ -524,7 +570,7 @@ void AlwaysOnValidation(TestServer& srv) {
}
// The donation item is open in BOTH shop states — it is the soft opening the
// coming-soon phone waits behind — so this whole lifecycle runs
// goods wait behind, listed or not — so this whole lifecycle runs
// unconditionally: create with a buyer-named amount, settle on the fake rail,
// confirm there is no invoice and no VAT, and watch /financials book it under
// donations rather than sales.
@ -554,7 +600,16 @@ void DonationLifecycle(TestServer& srv) {
// live EURC payment proved that a buyer staring at "awaiting payment"
// after their wallet said done is a support email in the making. The
// fake rail's "<marker>.seen" file is that state's test handle.
//
// The rail must be UNPAID first. The open-shop half (when it runs) leaves
// the paid marker behind, and a marker already present pays this donation
// the moment it arrives — the seen state could then never show. Every
// order placed before this point has already settled, so nothing else
// notices the marker going.
{
std::error_code ec;
std::filesystem::remove(std::filesystem::path(
srv.Orders().string() + ".fake-paid"), ec);
const std::string tokenSeen =
TokenOf(srv.Post("/shop/donation", "amount=2&pay=crypto"));
Check(!tokenSeen.empty(), "a crypto donation goes through");
@ -641,7 +696,7 @@ void DonationLifecycle(TestServer& srv) {
"a donation is not worded as an export");
Check(page.find("invoice.md") == std::string::npos,
"a paid donation offers no invoice download");
Check(page.find("flashed and tested") == std::string::npos,
Check(page.find("prepared and tested") == std::string::npos,
"a paid donation promises no dispatch");
}
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
@ -746,11 +801,12 @@ void DonationLifecycle(TestServer& srv) {
// The re-rendered form only exists when the shop is open; while coming-soon a
// rejection answers with the coming-soon page instead.
void RejectedFormEcho(TestServer& srv) {
void RejectedFormEcho(TestServer& srv, const Product& pr) {
const std::string shop = "/shop/" + pr.slug;
// A rejected submission must come back with the values still in it —
// losing a filled-in form is how a sale gets abandoned.
{
const auto rejected = srv.Post("/shop/fp6-pmos",
const auto rejected = srv.Post(shop,
"email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD");
for (std::string_view probe : { "value=\"bad\"", "value=\"NLD\"", "value=\"Ada\"",
"value=\"Main 1\"", "value=\"Delft\"" }) {
@ -762,11 +818,14 @@ void RejectedFormEcho(TestServer& srv) {
}
// A refused destination says why, in the form, with the address still in
// it — the visitor should learn the shop does not sell there, not that
// something went wrong.
// something went wrong. The wording is kRegulatoryMessage's, as a FIELD
// error (the same sentence also rides in the preview blob, so the error
// markup is what is matched; the apostrophe is escaped as the renderer
// escapes it).
{
const auto refused = srv.Post("/shop/fp6-pmos",
const auto refused = srv.Post(shop,
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
Check(refused.body.find("does not sell or ship to the United States or Canada")
Check(refused.body.find("field__error\">Catcrafts can&#39;t ship there")
!= std::string::npos,
"refusal explains itself on the form");
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
@ -776,30 +835,34 @@ void RejectedFormEcho(TestServer& srv) {
// one — the buyer should learn the law forbids the sale, not wonder what
// insurance has to do with Moscow.
{
const auto refused = srv.Post("/shop/fp6-pmos",
const auto refused = srv.Post(shop,
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
Check(refused.body.find("EU sanctions prohibit") != std::string::npos,
"sanctions refusal explains itself on the form");
Check(refused.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
Check(refused.body.find("field__error\">Catcrafts can&#39;t ship there") == std::string::npos,
"sanctions refusal is not worded as the policy one");
}
// The buy panel warns before anyone fills it in, and the preview script
// carries the same lists so it cannot quote a total the server would
// refuse.
srv.BodyHas("/shop/fp6-pmos", "does not sell or ship to the United States or Canada",
"buy panel states where the shop does not sell");
srv.BodyHas("/shop/fp6-pmos", "cannot sell or ship to Russia, Belarus or North Korea",
// The buy panel warns before anyone fills it in — positively, as the
// allow-list is the whole policy — and the preview script carries the
// same lists so it cannot quote a total the server would refuse: `w` is
// the shipping allow-list (a country is refused by its ABSENCE), `s` the
// sanctioned countries. No deny-list survives in the payload
// (ShouldShipContent pins the exact contents against the fixture).
srv.BodyHas(shop, "Catcrafts currently ships to the Netherlands",
"buy panel states where the shop ships");
srv.BodyHas(shop, "cannot sell or ship to Russia, Belarus or North Korea",
"buy panel states where the law forbids selling");
srv.BodyHas("/shop/fp6-pmos", "&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]",
"total preview knows the refused destinations");
srv.BodyHas("/shop/fp6-pmos", "&quot;s&quot;:[&quot;RU&quot;,&quot;BY&quot;,&quot;KP&quot;]",
srv.BodyHas(shop, "&quot;w&quot;:[&quot;NL&quot;,",
"total preview carries the shipping allow-list");
srv.BodyLacks(shop, "&quot;x&quot;:[", "no deny-list survives in the preview payload");
srv.BodyHas(shop, "&quot;s&quot;:[&quot;RU&quot;,&quot;BY&quot;,&quot;KP&quot;]",
"total preview knows the sanctioned destinations");
// The honeypot message must not name the trap, or it teaches the next
// bot. Only the ERROR NOTICE is inspected: the re-rendered form
// legitimately contains the name="website" field itself — that IS the
// trap, re-armed.
{
const auto pot = srv.Post("/shop/fp6-pmos", Good("&website=x"));
const auto pot = srv.Post(shop, Good("&website=x"));
std::smatch m;
Check(std::regex_search(pot.body, m, std::regex(R"lit(notice--error">([^<]*))lit")),
"honeypot rejection renders an error notice");
@ -826,7 +889,7 @@ void RejectedFormEcho(TestServer& srv) {
//
// Its own server, because a rail roster is fixed at startup. Donations are
// what this asserts against: they are open in BOTH shop states, so this runs
// whatever fp6-pmos's status is.
// whatever the goods' status is, or whether any are listed.
void OneRailDown(const char* binary) {
ServerOptions options;
options.extraArgs = { "--rail=off", "--crypto-rail=fake-crypto" };
@ -856,19 +919,24 @@ int main(int argc, char** argv) {
options.mailer = true;
TestServer srv(argv[1], 8217, options);
if (srv.ShopOpen()) {
OpenShopLifecycle(srv);
const Product* pr = FirstPricedProduct();
if (!pr) {
std::println("note: the catalogue lists no priced product; the goods checkout, "
"order-lifecycle and invoice checks did not run and re-arm when one "
"is listed");
} else if (srv.ShopOpen(pr->slug)) {
OpenShopLifecycle(srv, *pr);
} else {
ComingSoon(srv);
ComingSoon(srv, *pr);
}
AlwaysOnValidation(srv);
if (pr) AlwaysOnValidation(srv, *pr);
// The donation item is open in both shop states — that is the point of it.
DonationLifecycle(srv);
if (srv.ShopOpen()) {
RejectedFormEcho(srv);
if (pr && srv.ShopOpen(pr->slug)) {
RejectedFormEcho(srv, *pr);
}
OneRailDown(argv[1]);

View file

@ -10,61 +10,106 @@ No permission is granted to copy, modify, distribute, or create derivative works
// charges, the currency conversions the shop card carries, and the copy
// around them. Runs in both shop states — coming-soon asserts the closed
// shape, launch re-arms the open one with no edit here.
//
// The goods half addresses whatever priced product the compiled catalogue
// lists first — its slug, its variants and its photo are read off the record,
// and every expected figure is formatted with the same Money routines the
// renderer uses. While the catalogue lists no goods (as since 2026-09-05) that
// half prints a note and is skipped; the donation checks run regardless.
import std;
import Catcrafts.Shared;
import Catcrafts.E2eHarness;
using namespace Catcrafts;
using namespace Catcrafts::E2e;
int main(int argc, char** argv) {
TestServer srv(argv[1], 8216);
namespace {
void PricedProduct(TestServer& srv, const Product& pr) {
const std::string shop = "/shop/" + pr.slug;
const std::string incl = Money::FormatEuro(pr.priceInclMinor);
const std::string net = Money::FormatEuro(Money::NetFromGross(pr.priceInclMinor));
// The price is rendered from the same integers the checkout charges, with
// the derived ex-VAT twin alongside — asserting both pins the arithmetic.
srv.BodyHas("/shop/fp6-pmos", "€573.80",
"product page shows the from-price (green supplier + €60.50 gross markup)");
srv.BodyHas("/shop/fp6-pmos", "€474.21", "product page shows the derived ex-VAT price");
srv.BodyHas("/shop/fp6-pmos", ">from<", "product page marks the price as a from-price");
srv.BodyHas("/shop", "€573.80", "shop card shows the from-price");
// Every colour is priced in the selector, and the form carries the exact
// data blob the preview computes from.
srv.BodyHas("/shop/fp6-pmos", "Black €579.80", "colour selector prices black");
srv.BodyHas("/shop/fp6-pmos", "White €665.38", "colour selector prices white");
if (srv.ShopOpen()) {
srv.BodyHas("/shop/fp6-pmos", "data-cc=", "form embeds the pricing blob");
srv.BodyHas("/shop/fp6-pmos", "id=\"cc-total\"", "live total element present");
srv.BodyHas(shop, incl, "product page shows the (from-)price");
srv.BodyHas(shop, net, "product page shows the derived ex-VAT price");
srv.BodyHas("/shop", incl, "shop card shows the (from-)price");
// "from" is a claim that other colours cost more, so it appears exactly
// when there is more than one colour to choose from.
if (pr.variants.size() > 1) {
srv.BodyHas(shop, ">from<", "product page marks the price as a from-price");
Check(pr.CheapestVariant() && pr.CheapestVariant()->priceInclMinor == pr.priceInclMinor,
"the advertised from-price is the cheapest colour's");
} else {
srv.BodyHas("/shop/fp6-pmos", "Coming soon", "coming-soon notice on the buy panel");
srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge");
srv.BodyLacks("/shop/fp6-pmos", "<form", "no order form while coming soon");
srv.BodyLacks(shop, ">from<", "a single-price product is not a from-price");
}
// Every colour is priced in the selector, label and euro amount together,
// and the form carries the exact data blob the preview computes from.
if (srv.ShopOpen(pr.slug)) {
for (const Variant& v : pr.variants) {
srv.BodyHas(shop, v.label + " " + Money::FormatEuro(v.priceInclMinor),
"colour selector prices " + v.slug);
}
srv.BodyHas(shop, "data-cc=", "form embeds the pricing blob");
srv.BodyHas(shop, "id=\"cc-total\"", "live total element present");
} else {
srv.BodyHas(shop, "Coming soon", "coming-soon notice on the buy panel");
srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge");
srv.BodyLacks(shop, "<form", "no order form while coming soon");
}
// The Gen. 6+ beside it: one launch price in all three colours, so the
// "from" price and every selector row are the same number — and the
// ex-VAT twin is still derived from it rather than typed in.
srv.BodyHas("/shop/fp6plus-pmos", "€709.50",
"refresh page shows the from-price (€649.00 supplier + €60.50 gross markup)");
srv.BodyHas("/shop/fp6plus-pmos", "€586.36", "refresh page shows the derived ex-VAT price");
srv.BodyHas("/shop", "€709.50", "shop card shows the refresh from-price");
srv.BodyHas("/shop/fp6plus-pmos", "Cobalt Blue €709.50",
"colour selector prices the colour only this model ships in");
srv.BodyHas("/shop/fp6plus-pmos", "12 GB RAM",
"refresh page states the spec that makes it a different phone");
srv.BodyHas("/shop/fp6plus-pmos", "src=\"/fp6plus-pmos.jpg\"",
"refresh page embeds its own photo");
srv.BodyHas("/shop", "src=\"/fp6plus-pmos.jpg\"", "shop card embeds the refresh thumbnail");
Check(std::filesystem::exists("images/fp6plus-pmos.jpg"),
"refresh photo exists in the repo");
srv.BodyLacks("/shop/fp6plus-pmos", "stock Fairphone (Gen. 6),",
"refresh page does not caption its specs as the Gen. 6's");
// The donation item: open while the phones above may not be — the shop's
// soft opening. Its card quotes no price (there is none), its page is a
// form asking an amount and, optionally, an email — never an address.
// The photo: on the page, on the card, in the link preview, and actually
// present in the repo — the file itself is Caddy's to serve (static
// asset), so its presence is asserted against the tree, not this server.
if (!pr.image.empty()) {
const std::string src = "src=\"" + pr.image + "\"";
srv.BodyHas(shop, src, "product page embeds the photo");
srv.BodyHas("/shop", src, "shop card embeds the thumbnail");
Check(std::filesystem::exists("images" + pr.image),
"product photo exists in the repo", pr.image);
}
// The spec lede is generated from the brand, so it introduces THIS sheet
// and no other device's.
if (!pr.specs.empty()) {
srv.BodyHas(shop, "a stock " + pr.brand + ", unmodified",
"the spec lede names the brand, not a model");
}
srv.BodyLacks(shop, "not yet verified", "no unverified-emergency-calling caveat on the page");
srv.BodyLacks(shop, "Reserve one", "no reservation form survives");
// The shop card: one euro number as the crawler/no-JS text, every
// supported currency pre-formatted server-side as a data attribute for
// the script to pick from. Converted amounts carry "~". GBP converts the
// ex-VAT price; SEK (an EU member's currency) converts the VAT-inclusive
// price. USD and CAD are absent on purpose — the shop refuses those
// destinations, so it does not quote a local price to them either.
srv.BodyHas("/shop", "class=\"price__single\"", "shop card renders the single-number price");
srv.BodyHas("/shop", "data-gbp=\"", "shop card carries a GBP conversion");
srv.BodyHas("/shop", "data-sek=\"~kr ", "shop card carries an SEK conversion");
srv.BodyHas("/shop", "data-world=\"" + net + "\"", "shop card carries the euro export fallback");
srv.BodyLacks("/shop", "data-usd=", "no USD price for a country the shop refuses");
srv.BodyLacks("/shop", "data-cad=", "no CAD price for a country the shop refuses");
// The product page gets the same headline element, so a British visitor
// sees ~£ at the top there too, and the buy card states the customs
// position plainly.
srv.BodyHas(shop, "data-gbp=\"", "product page headline carries the conversion");
srv.BodyHas(shop, "indicative only", "buy card says converted prices are indicative");
srv.BodyHas(shop, "customs authority", "buy card names whose problem import charges are");
srv.BodyLacks(shop, "collected on arrival", "the vague customs phrasing is gone");
// The label must not claim the Dutch rate is an EU-wide one.
srv.BodyLacks(shop, "EU VAT", "price label does not call 21% an EU-wide rate");
}
void Donation(TestServer& srv) {
// The donation item: open whatever the goods are doing — the shop's soft
// opening. Its card quotes no price (there is none), its page is a form
// asking an amount and, optionally, an email — never an address.
srv.BodyHas("/shop", "/shop/donation", "shop grid lists the donation item");
srv.BodyHas("/shop", "any amount", "donation card quotes no price");
srv.BodyHas("/shop/donation", "name=\"amount\"", "donation form asks for an amount");
srv.BodyHas("/shop/donation", ">Donate</button>",
"donation form submits to payment");
srv.BodyHas("/shop/donation", ">Donate</button>", "donation form submits to payment");
srv.BodyLacks("/shop/donation", "name=\"street\"",
"donation form asks no address — nothing ships");
srv.BodyLacks("/shop/donation", "name=\"quantity\"",
@ -79,44 +124,21 @@ int main(int argc, char** argv) {
// No price, no conversions, no preview: the donation page ships NO
// executable script at all — stricter than the shop pages' one-script rule.
srv.BodyLacks("/shop/donation", "<script>", "donation page ships no executable script");
srv.BodyHas("/shop/fp6-pmos", "src=\"/fp6-pmos.jpg\"", "product page embeds the photo");
srv.BodyHas("/shop", "src=\"/fp6-pmos.jpg\"", "shop card embeds the thumbnail");
// The image file itself is Caddy's to serve (static asset), so its
// presence is asserted against the repo, not this server.
Check(std::filesystem::exists("images/fp6-pmos.jpg"), "product photo exists in the repo");
// 112 was verified against a live network on 2026-08-18, so the caveat
// that used to sit above the spec sheet is gone from the served page and
// must not creep back in.
srv.BodyLacks("/shop/fp6-pmos", "not yet verified",
"no unverified-emergency-calling caveat on the page");
srv.BodyLacks("/shop", "reservation", "no reservation copy survives on /shop");
srv.BodyLacks("/shop/fp6-pmos", "Reserve one", "no reservation form survives");
}
// The shop card: one euro number as the crawler/no-JS text, every
// supported currency pre-formatted server-side as a data attribute for
// the script to pick from. Converted amounts carry "~". GBP converts the
// ex-VAT price; SEK (an EU member's currency) converts the VAT-inclusive
// price. USD and CAD are absent on purpose — the shop refuses those
// destinations, so it does not quote a local price to them either.
srv.BodyHas("/shop", "class=\"price__single\"", "shop card renders the single-number price");
srv.BodyHas("/shop", "data-gbp=\"", "shop card carries a GBP conversion");
srv.BodyHas("/shop", "data-sek=\"~kr ", "shop card carries an SEK conversion");
srv.BodyHas("/shop", "data-world=\"€474.21\"", "shop card carries the euro export fallback");
srv.BodyLacks("/shop", "data-usd=", "no USD price for a country the shop refuses");
srv.BodyLacks("/shop", "data-cad=", "no CAD price for a country the shop refuses");
// The product page gets the same headline element, so a British visitor
// sees ~£ at the top there too, and the buy card states the customs
// position plainly.
srv.BodyHas("/shop/fp6-pmos", "data-gbp=\"", "product page headline carries the conversion");
srv.BodyHas("/shop/fp6-pmos", "indicative only",
"buy card says converted prices are indicative");
srv.BodyHas("/shop/fp6-pmos", "customs authority",
"buy card names whose problem import charges are");
srv.BodyLacks("/shop/fp6-pmos", "collected on arrival",
"the vague customs phrasing is gone");
// The label must not claim the Dutch rate is an EU-wide one.
srv.BodyLacks("/shop/fp6-pmos", "EU VAT", "price label does not call 21% an EU-wide rate");
} // namespace
int main(int argc, char** argv) {
TestServer srv(argv[1], 8216);
if (const Product* pr = FirstPricedProduct()) {
PricedProduct(srv, *pr);
} else {
std::println("note: the catalogue lists no priced product; the goods checks "
"did not run and re-arm when one is listed");
}
Donation(srv);
return Finish();
}

View file

@ -11,21 +11,28 @@ No permission is granted to copy, modify, distribute, or create derivative works
// backend exists: a client-side router cannot produce one.
import std;
import Catcrafts.Shared;
import Catcrafts.E2eHarness;
using namespace Catcrafts;
using namespace Catcrafts::E2e;
int main(int argc, char** argv) {
TestServer srv(argv[1], 8210);
// ── status codes ──────────────────────────────────────────────────
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos",
"/shop/fp6plus-pmos", "/shop/donation", "/projects",
for (std::string_view p : { "/", "/about", "/shop", "/shop/donation", "/projects",
"/posts", "/demos", "/demos/raytracer", "/financials",
"/legal/privacy", "/legal/terms", "/legal/imprint",
"/feed.xml", "/sitemap.xml", "/api/healthz" }) {
srv.CheckStatus(std::string(p), "200");
}
// Every catalogue entry has a page, whatever its status: a coming-soon or
// withdrawn listing stays up with its form closed, and only a slug the
// catalogue never held is a 404.
for (const Product& pr : Content::Products()) {
srv.CheckStatus("/shop/" + pr.slug, "200");
}
// Trailing slashes must normalise, not 404 or duplicate the canonical URL.
srv.CheckStatus("/projects/", "200");
srv.CheckStatus("/shop/", "200");
@ -72,9 +79,11 @@ int main(int argc, char** argv) {
srv.HeaderHas("/sitemap.xml", "content-type", "application/xml", "sitemap content-type");
// ── sitemap and feed content ──────────────────────────────────────
srv.BodyHas("/sitemap.xml", "/shop/fp6-pmos", "sitemap lists the product");
srv.BodyHas("/sitemap.xml", "/shop/fp6plus-pmos",
"sitemap lists the refresh — it comes off the catalogue, not a second list");
// Product URLs come off the catalogue, not a second list, so every entry
// is advertised and nothing that is not listed can be.
for (const Product& pr : Content::Products()) {
srv.BodyHas("/sitemap.xml", "/shop/" + pr.slug, "sitemap lists " + pr.slug);
}
srv.BodyHas("/sitemap.xml", "/about", "sitemap lists the about page");
srv.BodyHas("/sitemap.xml", "/legal/privacy", "sitemap lists the privacy page");
srv.BodyHas("/sitemap.xml", "/demos", "sitemap lists the demos page");
@ -84,11 +93,13 @@ int main(int argc, char** argv) {
srv.BodyHas("/feed.xml", "<feed xmlns=\"http://www.w3.org/2005/Atom\">", "feed is Atom");
// ── abuse ─────────────────────────────────────────────────────────
srv.CheckStatus("/shop/fp6-pmos", "413", "POST",
// The body guards run for any listed product before its form is read, so
// the donation item (the one entry every catalogue has) is the target.
srv.CheckStatus("/shop/donation", "413", "POST",
"email=a%40b.example&name=" + std::string(20000, 'x')
+ "&street=x&postal=1&city=y&country=NL");
{
const auto json = srv.Post("/shop/fp6-pmos", "{}", "application/json");
const auto json = srv.Post("/shop/donation", "{}", "application/json");
Check(json.status == "415", "POST with a JSON content-type -> 415", json.status);
}
srv.CheckStatus("/shop/nope", "404", "POST",

View file

@ -93,7 +93,7 @@ std::string TokenOf(const Crafter::HTTPResponse& res) {
// Every order this suite places is a donation, for the same reason the donation
// item exists in the other suites: it is purchasable in BOTH shop states, so
// this runs whether or not fp6-pmos has been opened, and it needs no shipping
// this runs whether or not any priced product is listed or open, and it needs no shipping
// address or rate table.
struct Order {
std::string token;

View file

@ -26,263 +26,136 @@ void Check(bool ok, std::string_view what, std::string_view got = {}) {
got.empty() ? "" : " got: ", got);
}
// ── a priced product, built by hand ───────────────────────────────────
// Since 2026-09-05 the catalogue lists the donation item alone: the goods that
// opened the shop were withdrawn. The renderer's contract for priced goods —
// the ProductGroup record, the merchant fields, the carrier-table filter, the
// spec lede, the preview blob — must not lapse while nothing exercises it, so
// this fixture stands in for the next listing. It carries every optional field
// a real one would: three colours at distinct prices (so from-price and default
// selection are decidable), a boxed weight that lands in the fixture ladders'
// cheap band, a brand for the spec lede, a photo path and a warranty text. The
// stickers are supplier + €60.50, the same rule a real listing follows.
Product Fixture() {
Product pr;
pr.slug = "fixture-handheld";
pr.name = "Fixture Handheld";
pr.brand = "Acme";
pr.tagline = "A stand-in priced product for the renderer contract.";
pr.status = "coming-soon";
pr.shipWeightGrams = 700;
pr.image = "/fixture-handheld.jpg";
pr.summary = "Stands in for a priced listing.";
pr.warranty = "Two years from Catcrafts, worldwide, one counter: every claim goes to "
"Catcrafts, whatever turns out to be broken. The full terms are on the "
"terms page.";
pr.variants = {
{ "green", "Forest Green", 51330 + 6050 },
{ "black", "Black", 51930 + 6050 },
{ "white", "White", 60488 + 6050 },
};
pr.specs = {
{ "Display", "6.31″ OLED" },
{ "Memory", "8 GB RAM" },
};
pr.priceInclMinor = pr.CheapestVariant()->priceInclMinor;
return pr;
}
// ── the compiled-in catalogue ─────────────────────────────────────────
// What is asserted against the shipped data is the SHAPE every listing must
// have, over whatever is listed, plus the donation item's own contract. No
// slug, price or model is written here: a listing coming or going is a content
// change, not a test edit.
void CatalogueContract() {
using namespace Catcrafts::Money;
const auto& products = Content::Products();
// Three entries: the two phones, and the donation item that soft-opens
// the shop. The Gen. 6 stays FIRST — it is the headline, and the suites
// below address Products()[0] as the priced product — and the donation
// stays LAST.
Check(products.size() == 3, "content: three products");
if (products.size() == 3) {
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
// Payment costs are the one accepted deviation, and on a bank
// transfer they are zero.
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(!products.empty(), "content: the catalogue is not empty");
// The donation item: exactly one, LAST (it is the odd entry out, not what
// the shop is about), and available — it is what the shop is open FOR
// whatever the goods are doing. Buyer names the amount, so no price, no
// variants, no weight — and the Buyable() price check is waived for
// exactly this shape.
std::size_t donations = 0;
for (const Product& p : products) donations += p.donation ? 1 : 0;
Check(donations == 1, "content: exactly one donation item");
const Product& don = products.back();
Check(don.donation && don.slug == "donation", "content: the donation item comes last");
Check(don.Buyable() && !don.ComingSoon(), "content: the donation item is on sale");
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 goods 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");
}
// Every priced listing, whatever it is. These are the fields the checkout,
// the carrier lookup and the renderers dereference; a listing missing one
// is a content bug that must not sell.
for (const Product& pr : products) {
if (pr.donation) continue;
Check(ParseRoute("/shop/" + pr.slug).kind == RouteKind::Product,
"content: the slug is a URL", pr.slug);
Check(pr.Buyable() || pr.ComingSoon() || pr.status == "unavailable",
"content: status is available, coming-soon or unavailable", pr.slug);
Check(pr.priceInclMinor > 0, "content: a priced product has a price", pr.slug);
if (!pr.variants.empty()) {
// The from-price is the cheapest variant, derived so the two can
// never disagree, and no colour may be listed unpriced.
Check(pr.CheapestVariant()
&& pr.CheapestVariant()->priceInclMinor == pr.priceInclMinor,
"content: from-price is the cheapest variant", pr.slug);
for (const Variant& v : pr.variants) {
Check(v.priceInclMinor > 0 && !v.slug.empty() && !v.label.empty(),
"content: every colour is priced and named", pr.slug + "/" + v.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");
}
// This used to assert the emergency-calling caveat was present. It
// came off on 2026-08-18, when imsd called 112 over a live network in
// an approved test session, so the assertion inverts: the page must
// not go back to telling buyers that path is unverified. A future
// safety claim may fill this field again — it just cannot be that one.
Check(pr.safetyNote.find("not yet verified") == std::string::npos,
"content: no stale unverified-emergency-calling caveat");
// 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. Zero would quote a rate the carrier does not honour.
Check(pr.shipWeightGrams > 0, "content: one boxed unit has a weight", pr.slug);
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. Three of these four are priced on purpose
// and must still not be advertised: US is refused on insurance, DE
// and GB on their missing producer registrations. The carrier will
// happily quote all three, which is exactly why the filter is worth
// asserting — CH is the only one here besides home that sells.
const std::vector<ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "CH", { { 2000, 2450 } } },
{ "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 3300 } } },
{ "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");
// And nothing the shop refuses is advertised, whatever the carrier
// quotes for it — by rate, so a filter that dropped the country code
// but kept the price would still be caught.
Check(pp.meta.jsonLd.find("\"9.95\"") == std::string::npos
&& pp.meta.jsonLd.find("\"33.00\"") == std::string::npos
&& pp.meta.jsonLd.find("\"17.94\"") == std::string::npos,
"schema: refused destinations are never advertised");
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 Gen. 6+: the same contract as the phone above, asserted against
// its own numbers. That the two SHARE the pricing rule is the point —
// a second device must not quietly become a second pricing policy.
const Product& plus = products[1];
Check(plus.slug == "fp6plus-pmos", "content: the refresh is the second listing");
Check(plus.brand == "Fairphone", "content: the refresh names its manufacturer");
Check(plus.Buyable() || plus.ComingSoon(),
"content: the refresh is buyable or deliberately coming soon");
Check(plus.variants.size() == 3, "content: three colours on the refresh");
// Launch price in every colour, so all three stickers are €709.50 —
// and each still nets exactly €50 after VAT, by the same arithmetic
// the invoice and the checkout use.
for (std::string_view slug : { "green", "black", "blue" }) {
const Variant* v = plus.FindVariant(slug);
Check(v && v->priceInclMinor == 70950,
"content: refresh colour = 649.00 supplier + 60.50 gross markup", slug);
Check(v && Money::NetFromGross(v->priceInclMinor)
- Money::NetFromGross(64900) == 5000,
"content: refresh colour nets the supplier price plus exactly €50", slug);
}
Check(plus.FindVariant("white") == nullptr,
"content: a colour this model doesn't ship in is null");
Check(plus.CheapestVariant()
&& plus.priceInclMinor == plus.CheapestVariant()->priceInclMinor,
"content: refresh from-price is the cheapest variant");
// Same box, same 193 g device, so the same bracket and the same
// per-parcel ceiling the phone above is checked against.
Check(plus.shipWeightGrams == 700,
"content: the refresh leaves in the same 700 g parcel");
// One warranty text for both phones, not two that can drift — the
// reason it is a shared constant rather than a second paste.
Check(plus.warranty == pr.warranty,
"content: both phones carry the identical warranty text");
Check(plus.specs.size() == pr.specs.size(),
"content: the refresh states every spec row the Gen. 6 does");
// The refresh IS the faster silicon — if these two rows ever match the
// Gen. 6's, the listing is selling the wrong phone.
{
bool soc = false, ram = false;
for (const Spec& s : plus.specs) {
soc = soc || (s.label == "Processor" && s.value.find("7s Gen 4") != std::string::npos);
ram = ram || (s.label == "Memory" && s.value.find("12 GB") != std::string::npos);
}
Check(soc && ram, "content: the refresh lists its own SoC and 12 GB of RAM");
}
// Its own photo, not the Gen. 6's — the two phones ship in different
// colours, so sharing one render would picture a phone this listing
// cannot sell. Published, not merely committed: a file that the page,
// the link preview and the schema record all omit is a photo nobody
// ever sees, which is the failure the checks below actually catch.
Check(plus.image == "/fp6plus-pmos.jpg", "content: the refresh names its own photo");
Check(plus.image != pr.image, "content: and not the Gen. 6's");
// The pictured colour is the pre-selected one. With a single price
// across the range that is purely a question of list order, so it is
// worth pinning: a reorder would leave the page showing blue and
// selling green by default.
Check(plus.CheapestVariant() && plus.CheapestVariant()->slug == "blue",
"content: the default colour is the one the photo shows");
{
const auto pp = Views::RenderProduct(plus, Rates{});
// The spec lede is generated from the brand, so it must introduce
// THIS sheet — the old markup hardcoded "Fairphone (Gen. 6)" and
// would now caption the refresh's specs with the other phone's name.
Check(pp.main.View().find("a stock Fairphone, unmodified") != std::string_view::npos,
"refresh page: the spec lede names the brand, not a model");
Check(pp.main.View().find("stock Fairphone (Gen. 6)") == std::string_view::npos,
"refresh page: and does not caption these specs as the Gen. 6's");
Check(pp.main.View().find("product__photo") != std::string_view::npos,
"refresh page: the photo renders");
Check(pp.meta.ogImage == "/fp6plus-pmos.jpg",
"refresh page: the link preview names its own photo");
Check(pp.meta.jsonLd.find("\"image\":\"https://catcrafts.net/fp6plus-pmos.jpg\"")
!= std::string::npos,
"refresh page: and the schema record carries it absolute");
}
// The donation item: the shop's soft opening. Available (it is what
// the shop is open FOR) while both phones stay 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[2];
Check(don.slug == "donation" && don.donation,
"content: the donation item comes last");
Check(don.Buyable() && !don.ComingSoon(),
"content: the donation item is on sale while the phones are 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");
"content: warranty is written, not a placeholder", pr.slug);
Check(pr.safetyNote.find("not yet verified") == std::string::npos,
"content: no stale unverified-emergency-calling caveat", pr.slug);
// Published, not merely committed: the photo the page, the link preview
// and the schema record name has to exist in the tree, or nobody sees it.
if (!pr.image.empty()) {
Check(std::filesystem::exists("images" + pr.image),
"content: the photo exists in the repo", pr.image);
}
// Its record parses with our own JSON parser and is sold by the
// registered organisation — the join the identity graph depends on.
const auto pp = Views::RenderProduct(pr, Rates{});
Check(Json::Parse(pp.meta.jsonLd).has_value(),
"content: the product record parses", pr.slug);
Check(pp.meta.jsonLd.find("https://catcrafts.net/#organization") != std::string::npos,
"content: the offer is sold by the organization node", pr.slug);
}
// The spec lede is "a stock <brand>, unmodified", so a spec sheet with no
// brand renders a sentence with a hole in it. Cheaper to assert than to
@ -301,6 +174,134 @@ void CatalogueContract() {
Check(!Content::Demos().empty(), "content: demos present");
}
// ── the product page, against the fixture ─────────────────────────────
// The renderer's contract for a priced listing, pinned on the hand-built
// product above so it holds while the catalogue lists none.
void RendererContract() {
using namespace Catcrafts::Money;
const Product pr = Fixture();
Check(pr.FindVariant("mauve") == nullptr, "fixture: unknown colour is null");
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
"fixture: cheapest is green");
// 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. net(retail) - net(supplier) must be exactly 5000 minor, in the
// same arithmetic the invoice and checkout use.
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 && NetFromGross(v->priceInclMinor) - NetFromGross(supplier) == 5000,
"fixture: variant nets the supplier price plus exactly €50", slug);
}
{
// A ladder straddling the boxed 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,
"fixture: the shipped weight lands in the cheap carrier bracket");
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
"fixture: and caps one parcel at fourteen units");
}
// 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 carrier data, so the render needs a
// table. Three of these four are priced on purpose and must still not
// be advertised: US is refused by policy, DE and GB on their missing
// producer registrations. The carrier will happily quote all three,
// which is exactly why the filter is worth asserting — CH is the only
// one here besides home that sells.
const std::vector<ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "CH", { { 2000, 2450 } } },
{ "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 3300 } } },
{ "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");
Check(pp.meta.jsonLd.find("\"brand\":{\"@type\":\"Brand\",\"name\":\"Acme\"}")
!= std::string::npos,
"schema: the group carries the hardware brand");
// Merchant-grade fields: shipping, returns, sku, group id — what
// Merchant Center's website-crawl feed reads (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\":\"fixture-handheld-green\"") != 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");
// And nothing the shop refuses is advertised, whatever the carrier
// quotes for it — by rate, so a filter that dropped the country code
// but kept the price would still be caught.
Check(pp.meta.jsonLd.find("\"9.95\"") == std::string::npos
&& pp.meta.jsonLd.find("\"33.00\"") == std::string::npos
&& pp.meta.jsonLd.find("\"17.94\"") == std::string::npos,
"schema: refused destinations are never advertised");
Check(pp.meta.jsonLd.find("\"US\"") == std::string::npos,
"schema: a refused destination is never advertised, priced or not");
Check(pp.meta.jsonLd.find("https://catcrafts.net/#organization") != std::string::npos,
"schema: offers are sold by the organization node");
// The photo travels three ways: the page, the link preview, the record.
Check(pp.main.View().find("product__photo") != std::string_view::npos,
"product page: the photo renders");
Check(pp.meta.ogImage == pr.image, "product page: the link preview names the photo");
Check(pp.meta.jsonLd.find("\"image\":\"https://catcrafts.net/fixture-handheld.jpg\"")
!= std::string::npos,
"product page: and the schema record carries it absolute");
// The spec lede is generated from the brand, so it introduces THIS
// sheet — and claims nothing about any particular model or software.
Check(pp.main.View().find("a stock Acme, unmodified") != std::string_view::npos,
"product page: the spec lede names the brand, not a model");
Check(pp.main.View().find("postmarketOS") == std::string_view::npos,
"product page: the renderer names no software of its own");
Check(pp.main.View().find("<h2 class=\"section__title\">Warranty</h2>")
!= std::string_view::npos,
"product page: the warranty section renders where a text exists");
// 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");
// Coming-soon publishes the price and closes the form.
Check(bare.meta.jsonLd.find("schema.org/PreOrder") != std::string::npos,
"schema: coming-soon maps to PreOrder availability");
Check(bare.main.View().find("<form") == std::string_view::npos
&& bare.main.View().find("Coming soon") != std::string_view::npos,
"product page: coming-soon renders the notice and no order form");
}
}
// ── 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
@ -366,8 +367,11 @@ void IdentityGraph() {
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{});
{
// The offer side of the join, on the fixture: the catalogue may list
// no priced product (the donation publishes no offer at all), and the
// join must hold for the next one regardless.
auto pp = Views::RenderProduct(Fixture(), Rates{});
Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: offers are sold by the organization node");
}
@ -382,9 +386,9 @@ void IdentityGraph() {
}
// ── 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.
// The shipped catalogue holds each status at most once (today: the donation
// alone), 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
@ -454,16 +458,15 @@ void SaleGates() {
}
// ── 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
// RenderCheckoutForm renders only for a Buyable priced product, and the
// shipped catalogue lists none, so nothing in it renders the form: the suites
// that read this attribute over HTTP sit behind a ShopOpen() gate. Flip the
// fixture 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];
Product pr = Fixture();
pr.status = "available";
Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders");
@ -583,6 +586,7 @@ void AdvertisedUrls() {
int main() {
CatalogueContract();
RendererContract();
IdentityGraph();
SaleGates();
CheckoutPreviewData();

View file

@ -12,8 +12,10 @@ No permission is granted to copy, modify, distribute, or create derivative works
// (the timezone price hint); everything else ships none at all.
import std;
import Catcrafts.Shared;
import Catcrafts.E2eHarness;
using namespace Catcrafts;
using namespace Catcrafts::E2e;
namespace {
@ -53,10 +55,14 @@ int main(int argc, char** argv) {
// Placeholders are dev-only markers; one reaching production is a content
// bug (an imprint that says PLACEHOLDER once shipped exactly that way).
for (std::string_view pg : { "/legal/privacy", "/legal/terms", "/legal/imprint",
"/shop/fp6-pmos", "/shop/fp6plus-pmos", "/financials" }) {
"/financials" }) {
srv.BodyLacks(std::string(pg), "PLACEHOLDER",
std::format("{} ships no placeholder markers", pg));
}
for (const Product& pr : Content::Products()) {
srv.BodyLacks("/shop/" + pr.slug, "PLACEHOLDER",
std::format("/shop/{} ships no placeholder markers", pr.slug));
}
// Shop pages are the one exception to script-free: they carry exactly ONE
// EXECUTABLE inline script — the timezone price hint, whose tag is the
@ -65,8 +71,15 @@ int main(int argc, char** argv) {
// rule. Pin the shape hard: inline only (no src=, so nothing external can
// ever ride in under this exception), no network APIs, and the page must
// remain complete without it — both prices in the markup regardless.
for (std::string_view pg : { "/shop", "/shop/fp6-pmos", "/shop/fp6plus-pmos" }) {
const std::string body = srv.Body(std::string(pg));
// The pages under the rule: the shop index and every PRICED product page.
// The donation page has no price to convert and ships no script at all
// (ShouldSellTheShopFront pins that), so it is not in this list.
std::vector<std::string> pricedPages{ "/shop" };
for (const Product& pr : Content::Products()) {
if (!pr.donation) pricedPages.push_back("/shop/" + pr.slug);
}
for (const std::string& pg : pricedPages) {
const std::string body = srv.Body(pg);
const std::size_t n = CountOccurrences(body, "<script>");
Check(n == 1,
std::format("{} carries exactly one executable script (the price hint)", pg),
@ -87,8 +100,10 @@ int main(int argc, char** argv) {
}
Check(!network, std::format("{} script makes no network calls", pg));
}
srv.BodyHas("/shop/fp6-pmos", "cc-noneu", "price hint tags the non-EU outcome");
srv.BodyHas("/shop/fp6-pmos", "cc-eu", "price hint tags the confirmed-EU outcome too");
if (const Product* pr = FirstPricedProduct()) {
srv.BodyHas("/shop/" + pr->slug, "cc-noneu", "price hint tags the non-EU outcome");
srv.BodyHas("/shop/" + pr->slug, "cc-eu", "price hint tags the confirmed-EU outcome too");
}
// The renderer loads only where a demo entry declares needsWasm — the
// demo LIST is a content page and must stay free of it.
@ -127,7 +142,7 @@ int main(int argc, char** argv) {
// it is dead weight and one more thing that could retarget a future
// relative link.
srv.BodyLacks("/posts", "<base", "/posts has no base tag");
srv.BodyLacks("/shop/fp6-pmos", "<base", "/shop/<slug> has no base tag");
srv.BodyLacks("/shop/donation", "<base", "/shop/<slug> has no base tag");
// ── home page actions ─────────────────────────────────────────────
srv.BodyHas("/", "Browse projects", "home links to projects");

View file

@ -28,6 +28,10 @@ module;
export module Catcrafts.E2eHarness;
import std;
import Crafter.Network;
// For the compiled-in catalogue: the suites address whatever priced product it
// lists rather than a slug written into them, so a listing coming or going is
// a content change and not a test edit.
import Catcrafts.Shared;
namespace fs = std::filesystem;
@ -62,6 +66,18 @@ inline std::string ReadFile(const fs::path& p) {
return buf.str();
}
// The priced product the goods suites run against: the first non-donation
// entry of the compiled catalogue, or nullptr while it lists none — the state
// since 2026-09-05, when the two handsets were withdrawn. A suite with goods
// checks prints a note and skips them in that case; the donation item keeps
// the shared order lifecycle covered regardless.
inline const Catcrafts::Product* FirstPricedProduct() {
for (const Catcrafts::Product& p : Catcrafts::Content::Products()) {
if (!p.donation) return &p;
}
return nullptr;
}
inline std::size_t CountOccurrences(std::string_view haystack, std::string_view needle) {
if (needle.empty()) return 0;
std::size_t n = 0;
@ -302,9 +318,12 @@ public:
return Body(path);
}
// Open shop or coming-soon? The pricing blob (data-cc) exists only on the
// real order form, so its presence is the probe.
bool ShopOpen() { return Body("/shop/fp6-pmos").find("data-cc=") != std::string::npos; }
// Open shop or coming-soon, for one priced product? The pricing blob
// (data-cc) exists only on the real order form, so its presence is the
// probe.
bool ShopOpen(std::string_view slug) {
return Body("/shop/" + std::string(slug)).find("data-cc=") != std::string::npos;
}
private:
void Spawn(const std::vector<std::string>& argv) {