Hardware that runs the software from the )"
- R"(projects page. Assembled to order and flashed. Please allow up to a )"
- R"(week before dispatch. Or fund the work directly: the donation item )"
- R"(takes any amount.)"
+ R"(
Everything sold here funds the open-source )"
+ R"(work on the projects page. Or fund the work directly: the donation )"
+ R"(item takes any amount.)"
R"(
)"
R"(
{}
)",
cards.empty() ? Raw(R"(
No products listed.
)") : Join(cards));
@@ -1362,18 +1361,19 @@ export RenderedPage RenderProduct(const Product& product,
// donation has neither a spec sheet nor a warranty, and an empty table
// under a manufacturer-specific lede would be nonsense on its page.
//
- // The lede names the BRAND, never a model. It used to say "Fairphone
- // (Gen. 6)" in the markup, which read true while the catalogue held one
- // phone and went silently false the moment it held two — a spec sheet
- // introduced as some other device's sheet is worse than no lede at all.
+ // The lede names the BRAND, never a model. It used to hardcode one model
+ // name, which read true while the catalogue held one device and went
+ // silently false the moment it held two — a spec sheet introduced as some
+ // other device's sheet is worse than no lede at all. It also claims
+ // nothing about the software on the device: that is the product's own
+ // summary's job, and this renderer serves whatever the catalogue lists.
// A spec sheet with no brand to name is a content bug, asserted against in
// ShouldShipContent rather than papered over with a second sentence here.
const SafeHtml specsSection = product.specs.empty() ? SafeHtml{} : Format(
R"()"
R"(
Specifications
)"
R"(
The hardware is a stock {}, unmodified. )"
- R"({}'s spec sheet is this product's spec sheet, )"
- R"(and all of it works under postmarketOS.
)"
+ R"({}'s spec sheet is this product's spec sheet.
GPG-clearsigned markdown. It verifies with )"
diff --git a/tests/ShouldEmitStructuredData/main.cpp b/tests/ShouldEmitStructuredData/main.cpp
index f5024f9..7a4a0c1 100644
--- a/tests/ShouldEmitStructuredData/main.cpp
+++ b/tests/ShouldEmitStructuredData/main.cpp
@@ -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 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 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 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 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{ "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{ "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
diff --git a/tests/ShouldProcessCheckout/main.cpp b/tests/ShouldProcessCheckout/main.cpp
index bd3a6e2..3380add 100644
--- a/tests/ShouldProcessCheckout/main.cpp
+++ b/tests/ShouldProcessCheckout/main.cpp
@@ -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 kNl{ { 2000, 1500 }, { 10000, 2900 } };
+const std::vector 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 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{
+ "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(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'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{
+ "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 ",
- "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{
+ "To: e2e@example.org", "Subject: Catcrafts order CC-",
+ "From: Catcrafts ",
+ "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 ".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'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'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", ""x":["US","CA"]",
- "total preview knows the refused destinations");
- srv.BodyHas("/shop/fp6-pmos", ""s":["RU","BY","KP"]",
+ srv.BodyHas(shop, ""w":["NL",",
+ "total preview carries the shipping allow-list");
+ srv.BodyLacks(shop, ""x":[", "no deny-list survives in the preview payload");
+ srv.BodyHas(shop, ""s":["RU","BY","KP"]",
"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]);
diff --git a/tests/ShouldSellTheShopFront/main.cpp b/tests/ShouldSellTheShopFront/main.cpp
index c01d957..acf8bbe 100644
--- a/tests/ShouldSellTheShopFront/main.cpp
+++ b/tests/ShouldSellTheShopFront/main.cpp
@@ -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", "