This commit is contained in:
parent
eb655b24b6
commit
25805a093a
20 changed files with 788 additions and 805 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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'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]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue