This commit is contained in:
parent
eb655b24b6
commit
25805a093a
20 changed files with 788 additions and 805 deletions
|
|
@ -661,7 +661,7 @@ donation, which ships nothing and is a gift rather than a purchase.
|
||||||
whole withdrawal period, ask only for what identifies the order, **not**
|
whole withdrawal period, ask only for what identifies the order, **not**
|
||||||
require creating an account, and confirm immediately on a durable medium.
|
require creating an account, and confirm immediately on a durable medium.
|
||||||
**Sanction for not having one: the withdrawal period extends from 14 days to
|
**Sanction for not having one: the withdrawal period extends from 14 days to
|
||||||
twelve months**, so every EU phone sale stays unwindable for a year.
|
twelve months**, so every EU sale of goods stays unwindable for a year.
|
||||||
The order page is the natural home, since it already identifies exactly one
|
The order page is the natural home, since it already identifies exactly one
|
||||||
order through an unguessable link and therefore needs no login. Sketch:
|
order through an unguessable link and therefore needs no login. Sketch:
|
||||||
`POST /order/<token>/withdraw`, a button rendered while the window is open
|
`POST /order/<token>/withdraw`, a button rendered while the window is open
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 125 KiB |
12
project.cpp
12
project.cpp
|
|
@ -389,13 +389,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
// EnableWasiBrowserRuntime — sets <title>/<link> tags since the
|
// EnableWasiBrowserRuntime — sets <title>/<link> tags since the
|
||||||
// Dom partition has no head-element access.
|
// Dom partition has no head-element access.
|
||||||
cfg.files.emplace_back(fs::path("catcrafts-head.js"));
|
cfg.files.emplace_back(fs::path("catcrafts-head.js"));
|
||||||
// Product photography. Both renders are © Fairphone under CC BY-SA 4.0 and
|
// Product photography goes here as cfg.files entries (images/<slug>.jpg,
|
||||||
// are cropped and recompressed here — the Gen. 6 one came via Wikimedia
|
// referenced by Product::image as "/<slug>.jpg"), NOT in cfg.assets: that
|
||||||
// Commons, the Gen. 6+ one from Fairphone's own Flickr. The attribution and
|
// pipeline transcodes to .ctex, which <img> cannot decode. None today: the
|
||||||
// the adaptation notice the licence requires live on /legal/imprint. NOT in
|
// catalogue lists only the donation item. When a licensed render returns,
|
||||||
// cfg.assets: that pipeline transcodes to .ctex, which <img> cannot decode.
|
// its attribution and adaptation notice belong on /legal/imprint.
|
||||||
cfg.files.emplace_back(fs::path("images/fp6-pmos.jpg"));
|
|
||||||
cfg.files.emplace_back(fs::path("images/fp6plus-pmos.jpg"));
|
|
||||||
// ECB rates ride with the content so the wasm-rendered shop (backend-down
|
// ECB rates ride with the content so the wasm-rendered shop (backend-down
|
||||||
// fallback) can emit the same indicative prices the server does.
|
// fallback) can emit the same indicative prices the server does.
|
||||||
cfg.files.emplace_back(fs::path("content/rates.json"));
|
cfg.files.emplace_back(fs::path("content/rates.json"));
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ std::string BuildOrderConfirmationEmail(const OrderRecord& o,
|
||||||
"(gpg --verify) without this shop's help. Keep the file with your "
|
"(gpg --verify) without this shop's help. Keep the file with your "
|
||||||
"records — receipts are not hosted forever.\n\n";
|
"records — receipts are not hosted forever.\n\n";
|
||||||
|
|
||||||
m += "Devices are sourced, flashed and tested to order — allow up to a "
|
m += "Goods are sourced, prepared and tested to order. Allow up to a "
|
||||||
"week between payment and dispatch. The order page tracks it:\n\n";
|
"week between payment and dispatch. The order page tracks it:\n\n";
|
||||||
m += std::format(" {}\n\n", orderUrl);
|
m += std::format(" {}\n\n", orderUrl);
|
||||||
m += "That link is the key to the order: anyone holding it can read the "
|
m += "That link is the key to the order: anyone holding it can read the "
|
||||||
|
|
|
||||||
|
|
@ -125,8 +125,8 @@ int main(int argc, char** argv) {
|
||||||
// --routes: status + title for every route, for a quick smoke check.
|
// --routes: status + title for every route, for a quick smoke check.
|
||||||
if (has("--routes")) {
|
if (has("--routes")) {
|
||||||
const Views::SiteContent content = LoadContent("content");
|
const Views::SiteContent content = LoadContent("content");
|
||||||
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos",
|
for (std::string_view p : { "/", "/about", "/shop",
|
||||||
"/shop/fp6plus-pmos", "/shop/donation", "/shop/nope",
|
"/shop/donation", "/shop/nope",
|
||||||
"/financials",
|
"/financials",
|
||||||
"/order/0123456789abcdef0123456789abcdef",
|
"/order/0123456789abcdef0123456789abcdef",
|
||||||
"/order/not-a-token",
|
"/order/not-a-token",
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,14 @@ import :Money;
|
||||||
|
|
||||||
namespace Catcrafts::Content {
|
namespace Catcrafts::Content {
|
||||||
|
|
||||||
// The flat markup on every variant — "whatever it costs me + 50", where the
|
// The flat markup on every priced variant — "whatever it costs me + 50",
|
||||||
// €50 is what Catcrafts KEEPS. Retail is VAT-inclusive, so a markup added to
|
// where the €50 is what Catcrafts KEEPS. No priced product is listed today;
|
||||||
// it is taxed with the rest of the price: a flat +5000 would net only €41.32
|
// the rule and its proof stay here for the next one. Retail is VAT-inclusive,
|
||||||
// once the VAT on it is remitted. Grossed up once here (€60.50 on the
|
// so a markup added to it is taxed with the rest of the price: a flat +5000
|
||||||
// sticker), the walk-away margin is exactly €50 per unit — on EU sales and
|
// would net only €41.32 once the VAT on it is remitted. Grossed up once here
|
||||||
// on exports alike, since the export price is the net of the same retail.
|
// (€60.50 on the sticker), the walk-away margin is exactly €50 per unit — on
|
||||||
|
// EU sales and on exports alike, since the export price is the net of the
|
||||||
|
// same retail.
|
||||||
inline constexpr std::int64_t kMarkupNetMinor = 5000;
|
inline constexpr std::int64_t kMarkupNetMinor = 5000;
|
||||||
inline constexpr std::int64_t kMarkupMinor = Money::GrossFromNet(kMarkupNetMinor);
|
inline constexpr std::int64_t kMarkupMinor = Money::GrossFromNet(kMarkupNetMinor);
|
||||||
// Exactness is not luck: NetFromGross strips a gross amount additively only
|
// Exactness is not luck: NetFromGross strips a gross amount additively only
|
||||||
|
|
@ -56,187 +58,36 @@ static_assert(kMarkupMinor * 10000 % (10000 + Money::kVatRateBp) == 0,
|
||||||
static_assert(kMarkupMinor * 10000 / (10000 + Money::kVatRateBp) == kMarkupNetMinor,
|
static_assert(kMarkupMinor * 10000 / (10000 + Money::kVatRateBp) == kMarkupNetMinor,
|
||||||
"the exact net of the markup must be the €50 the rule promises");
|
"the exact net of the markup must be the €50 the rule promises");
|
||||||
|
|
||||||
// The warranty both phones carry, verbatim. Shared rather than pasted per
|
// The catalogue. Goods come and go: the two handsets that opened this list
|
||||||
// product because it is a promise about how Catcrafts handles a fault, not a
|
// were withdrawn on 2026-09-05, and nothing here, in the renderers or on the
|
||||||
// statement about a particular board — and two copies of a legal text are two
|
// legal pages may assume what kind of thing is sold next. The one standing
|
||||||
// texts that can drift apart, while /legal/terms states exactly one policy.
|
// entry is the donation item.
|
||||||
inline constexpr std::string_view kPhoneWarranty =
|
//
|
||||||
"Two years from Catcrafts, worldwide, one counter: every claim goes to Catcrafts, whatever turns out to be broken. The software (postmarketOS, the patches, imsd) is Catcrafts' own work and is fixed by Catcrafts with updates, delivered over the air. Even a phone that no longer boots is normally recovered in place: as long as fastboot still comes up, Catcrafts walks you through reflashing it over a USB cable in minutes. Only a phone that shows nothing at all, not even fastboot, travels for a software fault. For a hardware fault Catcrafts takes the phone back and handles the manufacturer's process, including the temporary reflash to stock Android it requires, and returns it running postmarketOS. When a phone does have to travel, warranty shipping is paid by Catcrafts, both directions, worldwide. User inflicted damage, including software faults caused by relocking the bootloader do not fall under this policy. The full terms are on the terms page.";
|
// Adding a priced product is one Product below: slug, name, brand, variants
|
||||||
|
// (each with its VAT-inclusive price, supplier + kMarkupMinor), a boxed
|
||||||
|
// shipping weight in grams, a photo under images/ (and in project.cpp's
|
||||||
|
// cfg.files), a spec sheet and a warranty text. The shop grid, the product
|
||||||
|
// page, the checkout, the sitemap and the JSON-LD all read this vector and
|
||||||
|
// nothing else; the E2E suites find the first non-donation entry and skip
|
||||||
|
// their goods checks while there is none.
|
||||||
export const std::vector<Product>& Products() {
|
export const std::vector<Product>& Products() {
|
||||||
static const std::vector<Product> products = [] {
|
static const std::vector<Product> products = [] {
|
||||||
Product p;
|
// The donation item. The buyer names the amount (Form::ValidateDonation
|
||||||
p.slug = "fp6-pmos";
|
// bounds it); nothing ships and no VAT is charged, because a gift with
|
||||||
p.name = "The Fairphone (Gen. 6) with postmarketOS";
|
// nothing supplied in return is not a taxable supply — which is also
|
||||||
p.brand = "Fairphone";
|
// why a donation never gets an invoice.
|
||||||
p.tagline = "A repairable Android phone, reflashed to run mainline Linux with a working IMS/VoLTE stack.";
|
|
||||||
// Launch day is this one line: "coming-soon" -> "available". The page
|
|
||||||
// shows launch prices either way; only the order form is held back.
|
|
||||||
//
|
//
|
||||||
// ── BEFORE FLIPPING THIS, TWO THINGS ARE OWED ────────────────────
|
// The order of this vector is the order of the shop grid. Priced goods
|
||||||
//
|
// go BEFORE this entry: the donation is the odd one out, not what the
|
||||||
// 1. THE WITHDRAWAL BUTTON (herroepingsknop). Since 2026-06-19, art.
|
// shop is about, and stays last.
|
||||||
// 11a of the Consumer Rights Directive — art. 6:230oa BW — has
|
|
||||||
// required a clearly labelled button in the interface for a
|
|
||||||
// consumer to withdraw from a distance contract. Email alone is no
|
|
||||||
// longer enough. It must stay available for the whole withdrawal
|
|
||||||
// period, ask only for essentials, NOT require an account, and
|
|
||||||
// confirm immediately on a durable medium.
|
|
||||||
// **If it is missing, the 14-day withdrawal period becomes ONE
|
|
||||||
// YEAR** — every EU phone sale unwindable for twelve months.
|
|
||||||
// It does not bite today only because the sole purchasable item is
|
|
||||||
// the donation, and a gift carries no withdrawal right. Flipping
|
|
||||||
// this line is exactly the moment it starts to.
|
|
||||||
// The order page is the natural home: it already identifies one
|
|
||||||
// order by an unguessable link, so no account is needed.
|
|
||||||
//
|
|
||||||
// 2. THE SHIPPING RATE TABLE. Without SENDCLOUD_* credentials (or a
|
|
||||||
// hand-placed cache) there is no rate table, and checkout refuses
|
|
||||||
// every order that ships. Donations skip that branch, which is why
|
|
||||||
// the shop looks fine today. See deploy/README.md.
|
|
||||||
p.status = "coming-soon";
|
|
||||||
// Boxed shipping weight, grams: the FP6 retail box (~450 g) plus the
|
|
||||||
// outer carton and padding. Rounded UP on purpose — this number picks
|
|
||||||
// the carrier's weight bracket, and guessing light is how an order gets
|
|
||||||
// quoted a rate the parcel does not qualify for. Worth replacing with a
|
|
||||||
// scale reading of a real outgoing parcel before launch.
|
|
||||||
p.shipWeightGrams = 700;
|
|
||||||
p.image = "/fp6-pmos.jpg";
|
|
||||||
p.summary = "The Fairphone (Gen. 6), reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
|
|
||||||
p.warranty = kPhoneWarranty;
|
|
||||||
// No safety note. There was one — the emergency-calling caveat — and
|
|
||||||
// it came off on 2026-08-18, when imsd placed calls to 112 on a live
|
|
||||||
// network in a test session approved by TB112 (the Dutch 1-1-2
|
|
||||||
// operator). That verification is exactly what the caveat was waiting
|
|
||||||
// for, so the claim it hedged is now a plain fact and the warning box
|
|
||||||
// would only mislead. The field stays in the model for the next real
|
|
||||||
// safety claim; leaving it empty is the decision, not an oversight.
|
|
||||||
p.variants = {
|
|
||||||
// supplier €513.30 incl VAT
|
|
||||||
{ "green", "Forest Green", 51330 + kMarkupMinor },
|
|
||||||
// supplier €519.30 incl VAT
|
|
||||||
{ "black", "Black", 51930 + kMarkupMinor },
|
|
||||||
// supplier €604.88 incl VAT
|
|
||||||
{ "white", "White", 60488 + kMarkupMinor },
|
|
||||||
};
|
|
||||||
// The hardware, as Fairphone specifies it — this is a stock Fairphone
|
|
||||||
// (Gen. 6), so its spec sheet is this product's spec sheet.
|
|
||||||
p.specs = {
|
|
||||||
{ "Display", "6.31″ OLED, 2484 × 1116 (FHD+), up to 120 Hz, Gorilla Glass 7i" },
|
|
||||||
{ "Processor", "Qualcomm Snapdragon 7s Gen 3, Adreno 810 GPU" },
|
|
||||||
{ "Memory", "8 GB RAM" },
|
|
||||||
{ "Storage", "256 GB, microSD slot up to 2 TB" },
|
|
||||||
{ "Rear cameras", "50 MP main with OIS + 13 MP ultra-wide" },
|
|
||||||
{ "Front camera", "32 MP" },
|
|
||||||
{ "Battery", "4415 mAh, user-replaceable, 30 W fast charging" },
|
|
||||||
{ "Connectivity", "5G, Wi-Fi 6E, Bluetooth 5.4, NFC" },
|
|
||||||
{ "SIM", "Dual: nano-SIM + eSIM" },
|
|
||||||
{ "USB", "USB-C 2.0" },
|
|
||||||
{ "Fingerprint reader", "Side-mounted, in the power button" },
|
|
||||||
{ "Durability", "IP55, 12 user-replaceable modules" },
|
|
||||||
{ "Dimensions", "156.5 × 73.3 × 9.6 mm, 193 g" },
|
|
||||||
};
|
|
||||||
// Same rule the loader used to apply: the from-price is the
|
|
||||||
// cheapest variant, derived so the two can never disagree.
|
|
||||||
if (const Variant* cheapest = p.CheapestVariant()) {
|
|
||||||
p.priceInclMinor = cheapest->priceInclMinor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Fairphone (Gen. 6+): Fairphone's mid-generation refresh, announced
|
|
||||||
// 2026-08-18. A second listing rather than more colours on the entry
|
|
||||||
// above — different silicon, a different price and its own spec sheet,
|
|
||||||
// and the two are bought as different phones.
|
|
||||||
//
|
|
||||||
// It is sellable at all because the SOFTWARE is unchanged: the same
|
|
||||||
// postmarketOS image, the same kernel patches and the same imsd run on
|
|
||||||
// this board, confirmed on a device. The chassis, modem, cameras and
|
|
||||||
// battery are the Gen. 6's; the SoC and the RAM are the only parts the
|
|
||||||
// image had to be re-verified against, and it was.
|
|
||||||
Product q;
|
|
||||||
q.slug = "fp6plus-pmos";
|
|
||||||
q.name = "The Fairphone (Gen. 6+) with postmarketOS";
|
|
||||||
q.brand = "Fairphone";
|
|
||||||
q.tagline = "The mid-generation refresh with faster chip, 12 GB of RAM, running the same mainline Linux image.";
|
|
||||||
q.status = "coming-soon";
|
|
||||||
// Same device weight (193 g) in the same retail box, so the outgoing
|
|
||||||
// parcel is the Gen. 6's parcel and lands in the same carrier bracket.
|
|
||||||
// The rounding note on the entry above applies here too: this wants a
|
|
||||||
// scale reading of a real parcel before launch, not a second guess.
|
|
||||||
q.shipWeightGrams = 700;
|
|
||||||
// Fairphone's own Cobalt Blue render, cropped from the 2200 × 2200
|
|
||||||
// original on their Flickr — same rightsholder and same CC BY-SA 4.0
|
|
||||||
// licence as the Gen. 6 render above, credited with the adaptation
|
|
||||||
// notice on /legal/imprint. Deliberately NOT one of the copies the tech
|
|
||||||
// press hosts: an outlet's file carries no licence grant from the
|
|
||||||
// outlet, and the ones in circulation show the phone running stock
|
|
||||||
// Android with the Play Store on screen — which is the single thing
|
|
||||||
// this listing exists to sell the absence of.
|
|
||||||
q.image = "/fp6plus-pmos.jpg";
|
|
||||||
q.summary = "The Fairphone (Gen. 6+), reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
|
|
||||||
q.warranty = kPhoneWarranty;
|
|
||||||
// Supplier €649.00 incl VAT in every colour — Fairphone's own launch
|
|
||||||
// price, which is what the device costs anywhere a day after launch and
|
|
||||||
// the only real number that exists for it today. The Gen. 6 above is
|
|
||||||
// sourced well below list, so expect these to come DOWN once a sourcing
|
|
||||||
// price lands: change the three numbers here (and the pins in
|
|
||||||
// ShouldShipContent) and the €50 net margin holds by construction, as
|
|
||||||
// the static_asserts above guarantee for every supplier price.
|
|
||||||
//
|
|
||||||
// Cobalt Blue leads the list: with one price across the range,
|
|
||||||
// CheapestVariant() returns whichever colour comes first, and that is
|
|
||||||
// the selector's pre-selected default — so it has to be the colour the
|
|
||||||
// photo above actually shows.
|
|
||||||
q.variants = {
|
|
||||||
{ "blue", "Cobalt Blue", 64900 + kMarkupMinor },
|
|
||||||
{ "green", "Forest Green", 64900 + kMarkupMinor },
|
|
||||||
{ "black", "Horizon Black", 64900 + kMarkupMinor },
|
|
||||||
};
|
|
||||||
// The hardware, as Fairphone specifies it. Five rows differ from the
|
|
||||||
// Gen. 6 — the panel, the SoC, the RAM, the charge rate, and Fairphone's
|
|
||||||
// own rounding of the chassis length (156.57 mm here, 156.5 there, one
|
|
||||||
// shell). The other eight are restated rather than cross-referenced: a
|
|
||||||
// spec sheet that says "as above" is not a spec sheet.
|
|
||||||
q.specs = {
|
|
||||||
{ "Display", "6.31″ LTPO AMOLED, 2484 × 1116 (FHD+), 10–120 Hz adaptive, Gorilla Glass 7i" },
|
|
||||||
{ "Processor", "Qualcomm Snapdragon 7s Gen 4, Adreno 810 GPU" },
|
|
||||||
{ "Memory", "12 GB RAM" },
|
|
||||||
{ "Storage", "256 GB, microSD slot up to 2 TB" },
|
|
||||||
{ "Rear cameras", "50 MP main with OIS + 13 MP ultra-wide" },
|
|
||||||
{ "Front camera", "32 MP" },
|
|
||||||
{ "Battery", "4415 mAh, user-replaceable, 27 W fast charging" },
|
|
||||||
{ "Connectivity", "5G, Wi-Fi 6E, Bluetooth 5.4, NFC" },
|
|
||||||
{ "SIM", "Dual: nano-SIM + eSIM" },
|
|
||||||
{ "USB", "USB-C 2.0" },
|
|
||||||
{ "Fingerprint reader", "Side-mounted, in the power button" },
|
|
||||||
{ "Durability", "IP55, 12 user-replaceable modules" },
|
|
||||||
{ "Dimensions", "156.6 × 73.3 × 9.6 mm, 193 g" },
|
|
||||||
};
|
|
||||||
// Same derivation as above: the sticker is the cheapest colour, so the
|
|
||||||
// page cannot advertise a number the default choice doesn't honour.
|
|
||||||
if (const Variant* cheapest = q.CheapestVariant()) {
|
|
||||||
q.priceInclMinor = cheapest->priceInclMinor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The donation item: the shop's soft opening. It is "available" while
|
|
||||||
// the phones above stay "coming-soon" on purpose — the two rails, the
|
|
||||||
// ledger, the reconciler and the mailer all run for real money before
|
|
||||||
// the first phone order depends on them. The buyer names the amount
|
|
||||||
// (Form::ValidateDonation bounds it); nothing ships and no VAT is
|
|
||||||
// charged, because a gift with nothing supplied in return is not a
|
|
||||||
// taxable supply — which is also why a donation never gets an invoice.
|
|
||||||
// The order of this vector is the order of the shop grid, and it is
|
|
||||||
// deliberate: fp6-pmos FIRST (the shop's headline, and the tests
|
|
||||||
// address Products()[0] as the priced product), the Gen. 6+ second,
|
|
||||||
// and the donation LAST — it is the odd entry out, not what the shop
|
|
||||||
// is about.
|
|
||||||
Product d;
|
Product d;
|
||||||
d.slug = "donation";
|
d.slug = "donation";
|
||||||
d.name = "Donation";
|
d.name = "Donation";
|
||||||
d.tagline = "Fund the open-source work directly";
|
d.tagline = "Fund the open-source work directly";
|
||||||
d.status = "available";
|
d.status = "available";
|
||||||
d.donation = true;
|
d.donation = true;
|
||||||
d.summary = "Thank you very much for considering to donate! Your donation will contribute to the maintenance of the image and R&D for new devices.";
|
d.summary = "Thank you very much for considering to donate! Your donation contributes to the maintenance of the open-source software Catcrafts publishes and to research and development for new devices.";
|
||||||
return std::vector<Product>{ std::move(p), std::move(q), std::move(d) };
|
return std::vector<Product>{ std::move(d) };
|
||||||
}();
|
}();
|
||||||
return products;
|
return products;
|
||||||
}
|
}
|
||||||
|
|
@ -317,7 +168,7 @@ export const LegalPage& AboutPage() {
|
||||||
static const LegalPage page{
|
static const LegalPage page{
|
||||||
.slug = "about",
|
.slug = "about",
|
||||||
.title = "About",
|
.title = "About",
|
||||||
.updated = "2026-08-05",
|
.updated = "2026-09-05",
|
||||||
.lede = "Catcrafts is a one-person company. This page is about the person, the mission, and the name.",
|
.lede = "Catcrafts is a one-person company. This page is about the person, the mission, and the name.",
|
||||||
.sections = {
|
.sections = {
|
||||||
{ "Who",
|
{ "Who",
|
||||||
|
|
@ -327,7 +178,7 @@ export const LegalPage& AboutPage() {
|
||||||
{ "The mission",
|
{ "The mission",
|
||||||
{
|
{
|
||||||
"Accessible open-source software and hardware, for the benefit of everyone. Software you can read, hardware you can repair, and phones that answer to their owner rather than to an advertising department. A real alternative to big tech: not a manifesto about one, but things you can download, flash and buy today.",
|
"Accessible open-source software and hardware, for the benefit of everyone. Software you can read, hardware you can repair, and phones that answer to their owner rather than to an advertising department. A real alternative to big tech: not a manifesto about one, but things you can download, flash and buy today.",
|
||||||
"Everything Catcrafts makes is published as open source. The shop is how the development gets funded: buying a phone here pays for the stack it runs.",
|
"Everything Catcrafts makes is published as open source. The shop is how the development gets funded: every purchase and every donation there pays for the work.",
|
||||||
} },
|
} },
|
||||||
{ "The work",
|
{ "The work",
|
||||||
{
|
{
|
||||||
|
|
@ -337,7 +188,7 @@ export const LegalPage& AboutPage() {
|
||||||
} },
|
} },
|
||||||
{ "The name",
|
{ "The name",
|
||||||
{
|
{
|
||||||
"Catcrafts is not the Minecraft server. That is catcraft.net, singular, an unrelated community that happens to share almost the same name. This site sells and builds software and hardware for Linux phones, and has no cross-play, no ranks and no diamonds. The cat in the logo speaks shell, not creeper.",
|
"Catcrafts is not the Minecraft server. That is catcraft.net, singular, an unrelated community that happens to share almost the same name. This site is about open-source software and hardware for Linux phones, and has no cross-play, no ranks and no diamonds. The cat in the logo speaks shell, not creeper.",
|
||||||
} },
|
} },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -375,7 +226,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
||||||
{
|
{
|
||||||
.slug = "privacy",
|
.slug = "privacy",
|
||||||
.title = "Privacy",
|
.title = "Privacy",
|
||||||
.updated = "2026-08-20",
|
.updated = "2026-09-05",
|
||||||
.lede = "What this site collects, why, and how to get rid of it. Written to describe what the code actually does. If you find a discrepancy, the code is the bug and a report is very welcome.",
|
.lede = "What this site collects, why, and how to get rid of it. Written to describe what the code actually does. If you find a discrepancy, the code is the bug and a report is very welcome.",
|
||||||
.sections = {
|
.sections = {
|
||||||
{ "Who is responsible",
|
{ "Who is responsible",
|
||||||
|
|
@ -384,7 +235,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
||||||
} },
|
} },
|
||||||
{ "Orders",
|
{ "Orders",
|
||||||
{
|
{
|
||||||
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you a phone and issuing an invoice consist of.",
|
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you an order and issuing an invoice consist of.",
|
||||||
"Neither payment method involves a payment processor, so there is no third party that Catcrafts hands your data to in order to get paid. Bank payment is an ordinary transfer from your bank to the account named on the order page: your own bank and Catcrafts' bank handle it, as they would any transfer you make, and Catcrafts has engaged nobody in between. Cryptocurrency payment involves no provider either: the order page shows a receiving address that belongs to Catcrafts, and paying it is a transaction on a public blockchain. Only the order record described above connects a payment to you, and that record stays with Catcrafts.",
|
"Neither payment method involves a payment processor, so there is no third party that Catcrafts hands your data to in order to get paid. Bank payment is an ordinary transfer from your bank to the account named on the order page: your own bank and Catcrafts' bank handle it, as they would any transfer you make, and Catcrafts has engaged nobody in between. Cryptocurrency payment involves no provider either: the order page shows a receiving address that belongs to Catcrafts, and paying it is a transaction on a public blockchain. Only the order record described above connects a payment to you, and that record stays with Catcrafts.",
|
||||||
"What Catcrafts records about a bank transfer is deliberately narrow: the payment reference you quoted, the amount, the date and the method. Your name and IBAN are visible to Catcrafts on its own bank statement, as they are to anyone who receives a transfer, but they are not copied into the shop's records and are not needed to confirm an order. The reference is what matches your payment to your order.",
|
"What Catcrafts records about a bank transfer is deliberately narrow: the payment reference you quoted, the amount, the date and the method. Your name and IBAN are visible to Catcrafts on its own bank statement, as they are to anyone who receives a transfer, but they are not copied into the shop's records and are not needed to confirm an order. The reference is what matches your payment to your order.",
|
||||||
"The order status page lives at an unguessable link. Anyone holding the link can read that order's status and totals, so treat it like a receipt on your desk and don't post it anywhere public.",
|
"The order status page lives at an unguessable link. Anyone holding the link can read that order's status and totals, so treat it like a receipt on your desk and don't post it anywhere public.",
|
||||||
|
|
@ -418,7 +269,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
||||||
{
|
{
|
||||||
.slug = "imprint",
|
.slug = "imprint",
|
||||||
.title = "Imprint & contact",
|
.title = "Imprint & contact",
|
||||||
.updated = "2026-08-19",
|
.updated = "2026-09-05",
|
||||||
.lede = "Who is behind this site and how to reach them.",
|
.lede = "Who is behind this site and how to reach them.",
|
||||||
.sections = {
|
.sections = {
|
||||||
{ "Contact",
|
{ "Contact",
|
||||||
|
|
@ -434,21 +285,12 @@ export const std::vector<LegalPage>& LegalPages() {
|
||||||
{
|
{
|
||||||
"If you find a vulnerability, please email at security@catcrafts.net before disclosing it publicly and you will be credited. This site is source-available on Forgejo, so you can read exactly what it does rather than guessing.",
|
"If you find a vulnerability, please email at security@catcrafts.net before disclosing it publicly and you will be credited. This site is source-available on Forgejo, so you can read exactly what it does rather than guessing.",
|
||||||
} },
|
} },
|
||||||
// The CC BY-SA 4.0 licence on both product renders requires
|
|
||||||
// naming the author, linking the licence and flagging that the
|
|
||||||
// files were changed. project.cpp pointed here for it long
|
|
||||||
// before anything here said it, which is the gap this closes.
|
|
||||||
{ "Image credits",
|
|
||||||
{
|
|
||||||
"The product renders are the manufacturer's own: © Fairphone, used under the Creative Commons Attribution-ShareAlike 4.0 licence, https://creativecommons.org/licenses/by-sa/4.0 . The Fairphone (Gen. 6) in Forest Green came through Wikimedia Commons; the Fairphone (Gen. 6+) in Cobalt Blue comes from Fairphone's own photostream at https://www.flickr.com/photos/fairphone . Both were cropped and recompressed to fit this site, and those adaptations are offered back under the same licence.",
|
|
||||||
"They are renders of stock hardware, which is exactly what Catcrafts ships. The difference is the software on it. No manufacturer render shows postmarketOS, so treat the screens in those images as the manufacturer's artwork rather than a picture of what arrives.",
|
|
||||||
} },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
.slug = "terms",
|
.slug = "terms",
|
||||||
.title = "Terms",
|
.title = "Terms",
|
||||||
.updated = "2026-08-20",
|
.updated = "2026-09-05",
|
||||||
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
|
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
|
||||||
.sections = {
|
.sections = {
|
||||||
{ "Ordering and payment",
|
{ "Ordering and payment",
|
||||||
|
|
@ -466,32 +308,17 @@ export const std::vector<LegalPage>& LegalPages() {
|
||||||
"Because nothing ships, no name or address is asked for. An email address is optional and is used only to send the confirmation. Donations appear on the financials page as an aggregate running total, never individually.",
|
"Because nothing ships, no name or address is asked for. An email address is optional and is used only to send the confirmation. Donations appear on the financials page as an aggregate running total, never individually.",
|
||||||
"A donation is not a purchase, so the returns section below does not apply to it.",
|
"A donation is not a purchase, so the returns section below does not apply to it.",
|
||||||
} },
|
} },
|
||||||
{ "Fulfilment",
|
|
||||||
{
|
|
||||||
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",
|
|
||||||
"Shipping is tracked and insured. The tiers and prices are shown at checkout before you commit.",
|
|
||||||
} },
|
|
||||||
{ "Where Catcrafts sells",
|
|
||||||
{
|
|
||||||
"Catcrafts ships to a short list of countries, and the list is the whole policy: the Netherlands, Switzerland, Serbia, Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and Singapore. Checkout accepts a delivery address in those and refuses every other country. The current list is always the one checkout enforces, so if a country is not offered at checkout it is not offered at all. Outside the Netherlands you are the importer: the parcel is sent duty unpaid, and your own customs authority charges you import VAT and any duty when it arrives.",
|
|
||||||
"The reason it is a list of what is allowed rather than a list of what is not, is that the rules are national and there are nearly two hundred of them. Selling a phone into a country can enrol Catcrafts in that country's recycling and battery schemes, require its own radio approval, or require the handset to be registered in a national database before any local network will carry a call. Those obligations attach before the first parcel, not after it, and a shop this size cannot pretend to have read every country's version. So a country is added when it has actually been checked, and until then the answer is no. That is a deliberately cautious default and it will sometimes say no to a country that would have been fine.",
|
|
||||||
"Catcrafts does not ship to the United States or Canada, and these are worth naming because they are the two people ask about most. Canada is not a choice: the phone holds no Canadian radio certification, and Canadian law prohibits importing, distributing or selling uncertified radio equipment, so it cannot lawfully be sold there by anyone at present. The United States is a combination: a customs entry is now required for every individual parcel, the mobile networks certify which handsets may be activated, and none of the legal protection that shields telephone companies over emergency calls extends to whoever made the device. Insurance is part of the picture too, but it is no longer the interesting part, and buying a policy would not open either country.",
|
|
||||||
"Separately, Catcrafts cannot sell or ship to Russia, Belarus or North Korea. Unlike everything above this is not a judgement call: European Union sanctions prohibit exporting consumer electronics to those countries, and the prohibition covers indirect routes, a forwarding address, a reseller, or a purchase made on behalf of someone there just as much as a direct parcel. Checkout refuses a delivery address in any of them, and an order that turns out to be bound there by another route is refused and the payment returned.",
|
|
||||||
"The same anti-routing rule applies to every refused country, not only the sanctioned ones: if a parcel is to be forwarded onward, or the delivery address belongs to a forwarding service acting for a customer elsewhere, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around it. The point is that the sale does not happen, not that the address looks acceptable.",
|
|
||||||
"One more thing that can refuse an address even inside the list: shipping is priced entirely from the carrier's own rates, so a destination the carrier has no rate for cannot be quoted and cannot be ordered. That is a different and much softer no than the ones above, it is about a price not existing rather than a sale being refused, and it can change whenever the carrier's tariff does.",
|
|
||||||
"The software itself remains free for anyone anywhere, and none of this touches it: the sources and images are public, and flashing a device you already own is not a purchase and is not restricted by this section.",
|
|
||||||
} },
|
|
||||||
{ "Warranty",
|
{ "Warranty",
|
||||||
{
|
{
|
||||||
"Everything sold here is warranted by Catcrafts for two years from delivery, worldwide. One counter: every claim goes to Catcrafts, and Catcrafts deals with whoever needs dealing with. You never have to work out whether a fault is hardware or software, or talk to a manufacturer.",
|
"Everything sold here is warranted by Catcrafts for two years from delivery, worldwide.",
|
||||||
"Behind that counter the split is simple. Software Catcrafts wrote is fixed by Catcrafts with an update, delivered remotely like any other update. If a fault stops the product from starting, Catcrafts provides the tools and walks you through recovering it in place, so a software fault still does not mean sending anything anywhere. Hardware faults are handled through the manufacturer's or supplier's warranty: Catcrafts keeps the purchase paperwork those claims depend on and runs the process end to end. What that means for a specific product is described on its own page.",
|
"Software Catcrafts wrote is fixed by Catcrafts with an update, delivered remotely like any other update. If a fault stops the product from starting, Catcrafts provides the tools and walks you through recovering it in place, so a software fault still does not mean sending anything anywhere.",
|
||||||
"When a product does have to travel (a hardware fault, or a product so far gone it no longer responds to recovery tools at all), the shipping is paid by Catcrafts, both directions, worldwide. Every unit is tested before dispatch, so a genuine defect should be rare. When one happens anyway, it should not cost you anything. The one exception: if a returned product turns out to have no fault, or the damage is yours (a drop, water damage, a repair attempt gone wrong, software issue caused by a relocked bootloader), the repair and the shipping are billed at cost, and you are told the price before any work happens.",
|
"When a product does have to travel (a hardware fault, or a product so far gone it no longer responds to recovery tools at all), the shipping is paid by Catcrafts, both directions, worldwide. Every unit is tested before dispatch, so a genuine defect should be rare. When one happens anyway, it should not cost you anything. The one exception: if a returned product turns out to have no fault, or the damage is yours (a drop, water damage, a repair attempt gone wrong, or a software fault caused by your own changes), the repair and the shipping are billed at cost, and you are told the price before any work happens.",
|
||||||
"If you are an EU consumer you additionally hold the statutory conformity guarantee: under Dutch law it lasts as long as a product of this kind may reasonably be expected to last, a defect appearing in the first year is presumed to have existed at delivery, and remedies under it are free. Nothing in this section limits those rights.",
|
"If you are an EU consumer you additionally hold the statutory conformity guarantee: under Dutch law it lasts as long as a product of this kind may reasonably be expected to last, a defect appearing in the first year is presumed to have existed at delivery, and remedies under it are free. Nothing in this section limits those rights.",
|
||||||
"For warranty please contact warranty@catcrafts.net"
|
"For warranty please contact warranty@catcrafts.net"
|
||||||
} },
|
} },
|
||||||
{ "Returns",
|
{ "Returns",
|
||||||
{
|
{
|
||||||
"EU consumers can withdraw from the purchase within 14 days of delivery, no reason needed: send an email, send the product back, and the price plus the standard shipping you paid is refunded within 14 days of your notice, though the refund can wait until the product is back or you show it has been shipped. Return shipping is yours to arrange and pay. You may inspect the product as you would in a shop; value lost through use beyond that can be deducted from the refund.",
|
"EU consumers can withdraw from the purchase within 14 days of delivery, no reason needed: send an email, send the product back, and the price plus the standard shipping you paid is refunded when the product has beeb retuned and inspected. Return shipping is yours to arrange and pay. You may inspect the product as you would in a shop. value lost through use beyond that can be deducted from the refund.",
|
||||||
"Outside the EU, sales are final except for defects: the warranty above applies in full, but there is no change-of-mind window. Import duties and fees paid to your own authorities are between you and them and are never refunded by Catcrafts in any case.",
|
"Outside the EU, sales are final except for defects: the warranty above applies in full, but there is no change-of-mind window. Import duties and fees paid to your own authorities are between you and them and are never refunded by Catcrafts in any case.",
|
||||||
"A product that arrives broken is a warranty case, not a return: the warranty section applies, and the shipping is on Catcrafts.",
|
"A product that arrives broken is a warranty case, not a return: the warranty section applies, and the shipping is on Catcrafts.",
|
||||||
} },
|
} },
|
||||||
|
|
|
||||||
|
|
@ -238,8 +238,8 @@ export inline constexpr std::int64_t kMaxQuantity = 99;
|
||||||
//
|
//
|
||||||
// Names the reason as regulatory rather than commercial, because a Dutch shop
|
// Names the reason as regulatory rather than commercial, because a Dutch shop
|
||||||
// that plainly posts worldwide would otherwise read as arbitrary, or worse as
|
// that plainly posts worldwide would otherwise read as arbitrary, or worse as
|
||||||
// quietly declining someone. The truth is duller and better: selling a phone into
|
// quietly declining someone. The truth is duller and better: selling into a
|
||||||
// a country means meeting that country's rules first, and doing it in the wrong
|
// country means meeting that country's rules first, and doing it in the wrong
|
||||||
// order is the illegal part.
|
// order is the illegal part.
|
||||||
//
|
//
|
||||||
// No date is promised. Which country comes next depends on which one people ask
|
// No date is promised. Which country comes next depends on which one people ask
|
||||||
|
|
@ -250,9 +250,9 @@ export inline constexpr std::int64_t kMaxQuantity = 99;
|
||||||
// on-page total preview, so the page can never encourage an order the server
|
// on-page total preview, so the page can never encourage an order the server
|
||||||
// will refuse.
|
// will refuse.
|
||||||
export inline constexpr std::string_view kRegulatoryMessage =
|
export inline constexpr std::string_view kRegulatoryMessage =
|
||||||
"Catcrafts can't ship there. Selling a phone into a country means meeting "
|
"Catcrafts can't ship there. Selling into a country means meeting that "
|
||||||
"that country's own rules first — recycling schemes, radio approval, import "
|
"country's own rules first: recycling schemes, radio approval, import "
|
||||||
"registration — and only a few are cleared so far. Email "
|
"registration. Only a few are cleared so far. Email "
|
||||||
"orders@catcrafts.net if you would like yours looked at next.";
|
"orders@catcrafts.net if you would like yours looked at next.";
|
||||||
|
|
||||||
// The standing note above the buy form, and the one place the policy is stated
|
// The standing note above the buy form, and the one place the policy is stated
|
||||||
|
|
@ -432,7 +432,7 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
|
||||||
|
|
||||||
// Quantity: a small positive integer, nothing else. Absent means 1 (the
|
// Quantity: a small positive integer, nothing else. Absent means 1 (the
|
||||||
// no-JS form default); anything unparseable or out of range is rejected
|
// no-JS form default); anything unparseable or out of range is rejected
|
||||||
// rather than clamped — silently changing how many phones someone buys is
|
// rather than clamped — silently changing how many units someone buys is
|
||||||
// worse than asking again.
|
// worse than asking again.
|
||||||
const std::string_view qty = Trim(f.Get("quantity"));
|
const std::string_view qty = Trim(f.Get("quantity"));
|
||||||
if (qty.empty()) {
|
if (qty.empty()) {
|
||||||
|
|
|
||||||
|
|
@ -192,8 +192,9 @@ export struct Product {
|
||||||
// Deliberately NOT accompanied by a GTIN: the device is materially
|
// Deliberately NOT accompanied by a GTIN: the device is materially
|
||||||
// modified (different OS), and Google's product-data rules say a
|
// modified (different OS), and Google's product-data rules say a
|
||||||
// customized product must not carry the original manufacturer's GTIN —
|
// customized product must not carry the original manufacturer's GTIN —
|
||||||
// which also matches the legal framing that this is a Fairphone with
|
// which also matches the legal framing that this is the manufacturer's
|
||||||
// different software, not a Catcrafts-manufactured phone. Empty = omit.
|
// device with different software, not a Catcrafts-manufactured one.
|
||||||
|
// Empty = omit.
|
||||||
std::string brand;
|
std::string brand;
|
||||||
// "available" — buyable now. "coming-soon" — listed with launch prices,
|
// "available" — buyable now. "coming-soon" — listed with launch prices,
|
||||||
// orders not open yet. "unavailable" — listed but not sellable (sourcing
|
// orders not open yet. "unavailable" — listed but not sellable (sourcing
|
||||||
|
|
@ -220,7 +221,7 @@ export struct Product {
|
||||||
// decides how many units fit one parcel. A destination the carrier has no
|
// decides how many units fit one parcel. A destination the carrier has no
|
||||||
// bracket for is refused at checkout rather than priced from a guess.
|
// bracket for is refused at checkout rather than priced from a guess.
|
||||||
std::int64_t shipWeightGrams = 0;
|
std::int64_t shipWeightGrams = 0;
|
||||||
// Root-relative path of the product photo, e.g. "/fp6-pmos.jpg". Served
|
// Root-relative path of the product photo, e.g. "/widget.jpg". Served
|
||||||
// from our own origin like every other asset — the privacy notice's
|
// from our own origin like every other asset — the privacy notice's
|
||||||
// "everything comes from catcrafts.net" applies to product images too.
|
// "everything comes from catcrafts.net" applies to product images too.
|
||||||
std::string image;
|
std::string image;
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,8 @@ export bool IsSanctioned(std::string_view cc) {
|
||||||
// Where this shop ships. An allow-list, and that inversion IS the design.
|
// Where this shop ships. An allow-list, and that inversion IS the design.
|
||||||
//
|
//
|
||||||
// Everything not named here is refused. That is not timidity, it is the only
|
// Everything not named here is refused. That is not timidity, it is the only
|
||||||
// honest posture available: the rules deciding whether a phone may lawfully be
|
// honest posture available: the rules deciding whether a product may lawfully
|
||||||
// sold into a country are national, they differ in kind rather than degree, and
|
// be sold into a country are national, they differ in kind rather than degree, and
|
||||||
// nobody has read all of them. A deny-list ships to every country nobody thought
|
// nobody has read all of them. A deny-list ships to every country nobody thought
|
||||||
// about; an allow-list refuses them. Only one of those failure modes is
|
// about; an allow-list refuses them. Only one of those failure modes is
|
||||||
// survivable, so the default is no, and every code below is a country somebody
|
// survivable, so the default is no, and every code below is a country somebody
|
||||||
|
|
@ -139,9 +139,10 @@ export bool IsSanctioned(std::string_view cc) {
|
||||||
// JP — using a handset without Japan's giteki (技適) mark on a Japanese network
|
// JP — using a handset without Japan's giteki (技適) mark on a Japanese network
|
||||||
// is a Radio Act offence carrying up to a year's detention or ¥1,000,000.
|
// is a Radio Act offence carrying up to a year's detention or ¥1,000,000.
|
||||||
// The only exemption is for a short-stay visitor carrying a device in; it
|
// The only exemption is for a short-stay visitor carrying a device in; it
|
||||||
// does not reach a resident receiving a parcel. Fairphone holds no Japanese
|
// does not reach a resident receiving a parcel. A handset certified for
|
||||||
// certification and says outright that its devices cannot be used in
|
// the EU market holds no Japanese certification, and its maker will say
|
||||||
// countries requiring local homologation. Note where the liability lands:
|
// outright that it cannot be used in countries requiring local
|
||||||
|
// homologation. Note where the liability lands:
|
||||||
// the offence is USE, so the person committing it is the customer. Selling
|
// the offence is USE, so the person committing it is the customer. Selling
|
||||||
// someone a phone they break the law by switching on is worse than any
|
// someone a phone they break the law by switching on is worse than any
|
||||||
// paperwork gap on this page.
|
// paperwork gap on this page.
|
||||||
|
|
@ -155,7 +156,7 @@ export bool IsSanctioned(std::string_view cc) {
|
||||||
//
|
//
|
||||||
// Not here, and deliberately: the United States and Canada. The insurance
|
// Not here, and deliberately: the United States and Canada. The insurance
|
||||||
// exclusion was only ever half the reason and is no longer the interesting half.
|
// exclusion was only ever half the reason and is no longer the interesting half.
|
||||||
// Canada is legally shut — the Fairphone (Gen. 6) holds no ISED certification,
|
// Canada is legally shut — an EU-certified handset holds no ISED certification,
|
||||||
// and Canadian law bars importing, distributing or selling uncertified radio
|
// and Canadian law bars importing, distributing or selling uncertified radio
|
||||||
// apparatus, so no structure or policy opens it. The US adds per-parcel customs
|
// apparatus, so no structure or policy opens it. The US adds per-parcel customs
|
||||||
// entry since de minimis ended, carrier certification that gates activation, and
|
// entry since de minimis ended, carrier certification that gates activation, and
|
||||||
|
|
@ -170,8 +171,8 @@ export bool IsSanctioned(std::string_view cc) {
|
||||||
// Appear as importer of record and two of these countries close.
|
// Appear as importer of record and two of these countries close.
|
||||||
export std::span<const std::string_view> ShippableCountries() {
|
export std::span<const std::string_view> ShippableCountries() {
|
||||||
static constexpr std::array<std::string_view, 10> open{
|
static constexpr std::array<std::string_view, 10> open{
|
||||||
// Home. Verpact wants nothing under 50,000 kg of packaging, and the
|
// Home. Verpact wants nothing under 50,000 kg of packaging, and goods
|
||||||
// phones are already on the Dutch market when Catcrafts buys them, so no
|
// bought from Dutch retail are already on the Dutch market when Catcrafts buys them, so no
|
||||||
// producer duty attaches. That second clause is load-bearing: source
|
// producer duty attaches. That second clause is load-bearing: source
|
||||||
// stock from another member state and Catcrafts becomes the Dutch
|
// stock from another member state and Catcrafts becomes the Dutch
|
||||||
// importer, owing Stichting OPEN before this line is honest again.
|
// importer, owing Stichting OPEN before this line is honest again.
|
||||||
|
|
|
||||||
|
|
@ -289,8 +289,8 @@ export RenderedPage RenderHome(std::span<const Project> projects,
|
||||||
R"("founder":{"@type":"Person","@id":"https://catcrafts.net/about#person",)"
|
R"("founder":{"@type":"Person","@id":"https://catcrafts.net/about#person",)"
|
||||||
R"("name":"Jorijn van der Graaf","url":"https://catcrafts.net/about"},)"
|
R"("name":"Jorijn van der Graaf","url":"https://catcrafts.net/about"},)"
|
||||||
R"("description":"Catcrafts makes accessible, open-source software and hardware: )"
|
R"("description":"Catcrafts makes accessible, open-source software and hardware: )"
|
||||||
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and Fairphone )"
|
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and mainline )"
|
||||||
R"((Gen. 6) handsets sold with postmarketOS preinstalled.",)"
|
R"(Linux support for mobile hardware.",)"
|
||||||
R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]},)"
|
R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]},)"
|
||||||
R"({"@type":"WebSite","@id":"https://catcrafts.net/#website",)"
|
R"({"@type":"WebSite","@id":"https://catcrafts.net/#website",)"
|
||||||
R"("name":"Catcrafts","alternateName":["Catcrafts.net","CatCrafts"],)"
|
R"("name":"Catcrafts","alternateName":["Catcrafts.net","CatCrafts"],)"
|
||||||
|
|
@ -676,8 +676,8 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
|
||||||
RenderedPage page;
|
RenderedPage page;
|
||||||
page.meta.title = "Shop — Catcrafts";
|
page.meta.title = "Shop — Catcrafts";
|
||||||
page.meta.description =
|
page.meta.description =
|
||||||
"Fairphone (Gen. 6) and (Gen. 6+) handsets reflashed to run postmarketOS, "
|
"Hardware that runs the open-source software Catcrafts maintains, and a "
|
||||||
"with the IMS/VoLTE stack Catcrafts maintains.";
|
"donation item that funds the work directly.";
|
||||||
page.meta.canonical = "/shop";
|
page.meta.canonical = "/shop";
|
||||||
page.meta.geoPriceHint = true;
|
page.meta.geoPriceHint = true;
|
||||||
// The category page as typed data: an ItemList naming each product page.
|
// The category page as typed data: an ItemList naming each product page.
|
||||||
|
|
@ -704,10 +704,9 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
|
||||||
page.main = Format(
|
page.main = Format(
|
||||||
R"(<header class="page-header">)"
|
R"(<header class="page-header">)"
|
||||||
R"(<h1 class="page-header__title">Shop</h1>)"
|
R"(<h1 class="page-header__title">Shop</h1>)"
|
||||||
R"(<p class="page-header__lede">Hardware that runs the software from the )"
|
R"(<p class="page-header__lede">Everything sold here funds the open-source )"
|
||||||
R"(projects page. Assembled to order and flashed. Please allow up to a )"
|
R"(work on the projects page. Or fund the work directly: the donation )"
|
||||||
R"(week before dispatch. Or fund the work directly: the donation item )"
|
R"(item takes any amount.)"
|
||||||
R"(takes any amount.)"
|
|
||||||
R"(</header>)"
|
R"(</header>)"
|
||||||
R"(<div class="product-grid">{}</div>)",
|
R"(<div class="product-grid">{}</div>)",
|
||||||
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards));
|
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards));
|
||||||
|
|
@ -1362,18 +1361,19 @@ export RenderedPage RenderProduct(const Product& product,
|
||||||
// donation has neither a spec sheet nor a warranty, and an empty table
|
// donation has neither a spec sheet nor a warranty, and an empty table
|
||||||
// under a manufacturer-specific lede would be nonsense on its page.
|
// under a manufacturer-specific lede would be nonsense on its page.
|
||||||
//
|
//
|
||||||
// The lede names the BRAND, never a model. It used to say "Fairphone
|
// The lede names the BRAND, never a model. It used to hardcode one model
|
||||||
// (Gen. 6)" in the markup, which read true while the catalogue held one
|
// name, which read true while the catalogue held one device and went
|
||||||
// phone and went silently false the moment it held two — a spec sheet
|
// silently false the moment it held two — a spec sheet introduced as some
|
||||||
// introduced as some other device's sheet is worse than no lede at all.
|
// other device's sheet is worse than no lede at all. It also claims
|
||||||
|
// nothing about the software on the device: that is the product's own
|
||||||
|
// summary's job, and this renderer serves whatever the catalogue lists.
|
||||||
// A spec sheet with no brand to name is a content bug, asserted against in
|
// A spec sheet with no brand to name is a content bug, asserted against in
|
||||||
// ShouldShipContent rather than papered over with a second sentence here.
|
// ShouldShipContent rather than papered over with a second sentence here.
|
||||||
const SafeHtml specsSection = product.specs.empty() ? SafeHtml{} : Format(
|
const SafeHtml specsSection = product.specs.empty() ? SafeHtml{} : Format(
|
||||||
R"(<section class="section">)"
|
R"(<section class="section">)"
|
||||||
R"(<h2 class="section__title">Specifications</h2>)"
|
R"(<h2 class="section__title">Specifications</h2>)"
|
||||||
R"(<p class="section__lede">The hardware is a stock {}, unmodified. )"
|
R"(<p class="section__lede">The hardware is a stock {}, unmodified. )"
|
||||||
R"({}'s spec sheet is this product's spec sheet, )"
|
R"({}'s spec sheet is this product's spec sheet.</p>)"
|
||||||
R"(and all of it works under postmarketOS.</p>)"
|
|
||||||
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
||||||
R"(</section>)",
|
R"(</section>)",
|
||||||
Escape(product.brand), Escape(product.brand), Join(specRows));
|
Escape(product.brand), Escape(product.brand), Join(specRows));
|
||||||
|
|
@ -1671,7 +1671,7 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
||||||
R"(</section>)")
|
R"(</section>)")
|
||||||
: Format(
|
: Format(
|
||||||
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
||||||
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )"
|
R"(<p>Your order is sourced, prepared and tested, then shipped. Allow up )"
|
||||||
R"(to a week before dispatch. Updates land in your email.</p>)"
|
R"(to a week before dispatch. Updates land in your email.</p>)"
|
||||||
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
|
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
|
||||||
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
|
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
// The JSON-LD blocks are the machine-readable identity and offer records,
|
||||||
// born from a Google AI overview flatly asserting that nobody named Catcrafts
|
// born from a Google AI overview flatly asserting that nobody named Catcrafts
|
||||||
// sells Fairphone hardware. Parsed with our own JSON reader — the same one
|
// sells the hardware it then sold. Parsed with our own JSON reader — the same
|
||||||
// that must accept them — and checked for the facts.
|
// one that must accept them — and checked for the facts.
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
import Catcrafts.Shared;
|
import Catcrafts.Shared;
|
||||||
|
|
@ -64,28 +64,7 @@ int main(int argc, char** argv) {
|
||||||
Check(ok, "home Organization schema parses and carries the VAT identity");
|
Check(ok, "home Organization schema parses and carries the VAT identity");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the product record ────────────────────────────────────────────
|
// ── the shop index ────────────────────────────────────────────────
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
bool listOk = false;
|
bool listOk = false;
|
||||||
for (const Json::Value& doc : ExtractLd(srv, "/shop")) {
|
for (const Json::Value& doc : ExtractLd(srv, "/shop")) {
|
||||||
|
|
@ -96,33 +75,69 @@ int main(int argc, char** argv) {
|
||||||
}
|
}
|
||||||
Check(listOk, "shop index carries an ItemList of the product pages");
|
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");
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
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
|
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
|
||||||
// reads. Shipping is published from the live carrier table at one unit's
|
// reads. Shipping is published from the live carrier table at one unit's
|
||||||
// weight — the same integers checkout charges, so the listing cannot
|
// weight — the same integers checkout charges, so the listing cannot
|
||||||
// quote a rate the till won't honour; returns mirror the terms page.
|
// 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, "OfferShippingDetails", "offers carry shipping details");
|
||||||
srv.BodyHas("/shop/fp6-pmos", "MerchantReturnPolicy", "offers carry a return policy");
|
srv.BodyHas(shop, "MerchantReturnPolicy", "offers carry a return policy");
|
||||||
srv.BodyHas("/shop/fp6-pmos", "\"sku\":\"fp6-pmos-green\"", "offers carry per-variant skus");
|
const std::string sku = pr->variants.empty()
|
||||||
srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}",
|
? 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");
|
"product carries the hardware brand");
|
||||||
|
}
|
||||||
|
|
||||||
// One entry per (transit tier, price) the carrier table produces, for the
|
// One entry per (transit tier, price) the carrier table produces, for
|
||||||
// destinations the shop actually sells to — two, since the fixture prices
|
// the destinations the shop actually sells to — two, since the fixture
|
||||||
// NL/DE/GB/CH and checkout refuses DE and GB pending their producer
|
// prices NL/DE/GB/CH and checkout refuses DE and GB pending their
|
||||||
// registrations. Not a fixed property of the code: it is whatever the
|
// producer registrations. Not a fixed property of the code: it is
|
||||||
// carrier prices INTERSECTED with where the shop sells, and publishing a
|
// whatever the carrier prices INTERSECTED with where the shop sells,
|
||||||
// rate to a country checkout would decline is an offer that cannot be
|
// and publishing a rate to a country checkout would decline is an
|
||||||
// accepted.
|
// offer that cannot be accepted.
|
||||||
const Json::Value* shipping = nullptr;
|
const Json::Value* shipping = firstOffer ? firstOffer->Find("shippingDetails") : 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
|
Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
|
||||||
"shipping details group the carrier's rates");
|
"shipping details group the carrier's rates");
|
||||||
// The advertised rate IS the carrier's single-unit price. Neither a
|
// The advertised rate IS the carrier's single-unit price. Neither a
|
||||||
|
|
@ -152,24 +167,25 @@ int main(int argc, char** argv) {
|
||||||
Check(!refused, "a destination the policy refuses is not advertised");
|
Check(!refused, "a destination the policy refuses is not advertised");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (srv.ShopOpen()) {
|
if (srv.ShopOpen(pr->slug)) {
|
||||||
srv.BodyHas("/shop/fp6-pmos", "schema.org/InStock",
|
srv.BodyHas(shop, "schema.org/InStock", "open shop maps to InStock availability");
|
||||||
"open shop maps to InStock availability");
|
|
||||||
} else {
|
} else {
|
||||||
srv.BodyHas("/shop/fp6-pmos", "schema.org/PreOrder",
|
srv.BodyHas(shop, "schema.org/PreOrder", "coming-soon maps to PreOrder availability");
|
||||||
"coming-soon maps to PreOrder availability");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// og: tags are the link-preview card on Mastodon and Lemmy — where the
|
// og: tags are the link-preview card on Mastodon and Lemmy — where the
|
||||||
// traffic actually comes from.
|
// traffic actually comes from. Per product, not per site: two listings
|
||||||
srv.BodyHas("/", "property=\"og:title\"", "home page has og:title");
|
// that share a link-preview image are two posts that look like the
|
||||||
srv.BodyHas("/shop/fp6-pmos", "og:image\" content=\"https://catcrafts.net/fp6-pmos.jpg",
|
// 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");
|
"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.
|
} else {
|
||||||
srv.BodyHas("/shop/fp6plus-pmos",
|
std::println("note: the catalogue lists no priced product; the product-record "
|
||||||
"og:image\" content=\"https://catcrafts.net/fp6plus-pmos.jpg",
|
"checks did not run and re-arm when one is listed");
|
||||||
"the refresh previews with its own photo, not the Gen. 6's");
|
}
|
||||||
|
srv.BodyHas("/", "property=\"og:title\"", "home page has og:title");
|
||||||
|
|
||||||
// ── the person-company weld ───────────────────────────────────────
|
// ── the person-company weld ───────────────────────────────────────
|
||||||
// The about page: the founder must be named in the HTML, the Person
|
// 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
|
// payment links and reports "paid" once the marker file exists — which is how
|
||||||
// this suite simulates the customer paying.
|
// this suite simulates the customer paying.
|
||||||
//
|
//
|
||||||
// The lifecycle half runs only while the shop is OPEN; while coming-soon it
|
// The goods half addresses whatever priced product the compiled catalogue
|
||||||
// asserts the closed behaviour instead. Launch day (status flip to
|
// lists first — slug, default colour, boxed weight — and derives every expected
|
||||||
// "available" in Catcrafts.Shared-Content.cppm) re-arms the full suite with
|
// figure from that record and the harness's carrier table with the till's own
|
||||||
// no edit here. The validation half runs in both states on purpose:
|
// Money arithmetic. It runs only while that product is OPEN; while coming-soon
|
||||||
// validation happens before the coming-soon check, and that ordering is
|
// it asserts the closed behaviour instead, and while the catalogue lists no
|
||||||
// exactly what it pins.
|
// 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 std;
|
||||||
import Catcrafts.Shared;
|
import Catcrafts.Shared;
|
||||||
|
|
@ -81,21 +84,46 @@ bool GpgVerifies(const std::filesystem::path& file) {
|
||||||
file.string()).c_str()) == 0;
|
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 ──────────────────────────────────────────────────────
|
// ── checkout ──────────────────────────────────────────────────────
|
||||||
// A valid submission answers 303 straight to the PAYMENT page — no
|
// 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
|
// 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
|
// token is still extractable from the Location and the browser flow works
|
||||||
// in dev.
|
// in dev.
|
||||||
const auto checkout = srv.Post("/shop/fp6-pmos", Good());
|
const auto checkout = srv.Post(shop, Good());
|
||||||
const std::string token = TokenOf(checkout);
|
const std::string token = TokenOf(checkout);
|
||||||
Check(checkout.status == "303" && !token.empty(),
|
Check(checkout.status == "303" && !token.empty(),
|
||||||
"POST checkout -> 303 straight to payment", checkout.status);
|
"POST checkout -> 303 straight to payment", checkout.status);
|
||||||
{
|
{
|
||||||
const std::string ledger = srv.OrdersText();
|
const std::string ledger = srv.OrdersText();
|
||||||
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
|
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
|
||||||
&& ledger.find("\"total_minor\":58880") != std::string::npos,
|
&& ledger.find(std::format("\"total_minor\":{}", nlTotal)) != std::string::npos,
|
||||||
"order stored: NL total is €588.80 (green €573.80 + €15 shipping)");
|
"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
|
// 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
|
// one rail configured posts: it must land on the bank rail rather
|
||||||
// than nothing.
|
// than nothing.
|
||||||
|
|
@ -108,8 +136,9 @@ void OpenShopLifecycle(TestServer& srv) {
|
||||||
const std::string orderPath = std::format("/order/{}", token);
|
const std::string orderPath = std::format("/order/{}", token);
|
||||||
{
|
{
|
||||||
const std::string page = srv.Body(orderPath);
|
const std::string page = srv.Body(orderPath);
|
||||||
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
|
for (std::string_view probe : std::initializer_list<std::string_view>{
|
||||||
"http-equiv=\"refresh\"", "€588.80" }) {
|
"awaiting payment", "Resume payment", "CC-",
|
||||||
|
"http-equiv=\"refresh\"", nlEuro }) {
|
||||||
Check(page.find(probe) != std::string::npos,
|
Check(page.find(probe) != std::string::npos,
|
||||||
std::format("order page has {}", probe));
|
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
|
// 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
|
// 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.
|
// 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, "name=\"pay\"", "the form offers a payment choice");
|
||||||
srv.BodyHas("/shop/fp6-pmos", "value=\"crypto\"", "crypto is one of the choices");
|
srv.BodyHas(shop, "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, "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");
|
Check(!tokenCrypto.empty(), "a crypto order goes through");
|
||||||
if (!tokenCrypto.empty()) {
|
if (!tokenCrypto.empty()) {
|
||||||
bool recorded = false;
|
bool recorded = false;
|
||||||
|
|
@ -161,7 +190,7 @@ void OpenShopLifecycle(TestServer& srv) {
|
||||||
// so the form comes back with the choice highlighted rather than a bare
|
// so the form comes back with the choice highlighted rather than a bare
|
||||||
// 400.
|
// 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.status == "422", "an unknown payment method is refused", bogus.status);
|
||||||
Check(bogus.body.find("Pick one of the payment methods") != std::string::npos,
|
Check(bogus.body.find("Pick one of the payment methods") != std::string::npos,
|
||||||
"the refusal names the payment field");
|
"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
|
// national currency line sourced from the build-time ECB rates. CH because
|
||||||
// it is the export destination that actually sells — North America is
|
// it is the export destination that actually sells — North America is
|
||||||
// refused on insurance, and GB waits on its e-waste registrations.
|
// 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"));
|
"email=ch%40example.org&name=Heidi&street=1%20Bahnhofstrasse&postal=8001&city=Zurich&country=CH"));
|
||||||
Check(!tokenCh.empty(), "CH checkout issues an order");
|
Check(!tokenCh.empty(), "CH checkout issues an order");
|
||||||
if (!tokenCh.empty()) {
|
if (!tokenCh.empty()) {
|
||||||
const std::string page = srv.Body(std::format("/order/{}", tokenCh));
|
const std::string page = srv.Body(std::format("/order/{}", tokenCh));
|
||||||
// €474.21 goods (green net) + €55 world shipping = €529.21
|
// Net of the default colour + €55 world shipping.
|
||||||
Check(page.find("€529.21") != std::string::npos,
|
Check(page.find(Money::FormatEuro(Total(pr, unit, 1, kCh, "CH"))) != std::string::npos,
|
||||||
"export order total is ex-VAT + world shipping");
|
"export order total is ex-VAT + world shipping");
|
||||||
Check(page.find("Zero-rated export") != std::string::npos,
|
Check(page.find("Zero-rated export") != std::string::npos,
|
||||||
"export order states the VAT treatment");
|
"export order states the VAT treatment");
|
||||||
|
|
@ -187,42 +216,55 @@ void OpenShopLifecycle(TestServer& srv) {
|
||||||
"conversion is labelled indicative");
|
"conversion is labelled indicative");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A two-unit white export order: unit €665.38, line €1330.76, net from the
|
// A two-unit export order in the dearest colour (or the only price, for a
|
||||||
// LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80.
|
// colourless listing): the export net comes from the LINE total (unit × 2),
|
||||||
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
|
// not per unit, plus one parcel's world shipping at twice the weight.
|
||||||
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&color=white&quantity=2"));
|
const Variant* dear = nullptr;
|
||||||
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
|
for (const Variant& v : pr.variants) {
|
||||||
if (!tokenWhite.empty()) {
|
if (!dear || v.priceInclMinor > dear->priceInclMinor) dear = &v;
|
||||||
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
|
}
|
||||||
Check(page.find("€1154.80") != std::string::npos,
|
const std::int64_t dearUnit = dear ? dear->priceInclMinor : unit;
|
||||||
"white ×2 export total nets the line, not the 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("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.
|
// A colour we never listed must not buy anything, whatever the form claims.
|
||||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&color=mauve"));
|
if (!pr.variants.empty()) {
|
||||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=100"));
|
srv.CheckStatus(shop, "422", "POST", Good("&color=no-such-colour"));
|
||||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=0"));
|
}
|
||||||
|
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
|
// Quantity is a free input with a technical ceiling, not a dropdown — a
|
||||||
// nine-unit order is business, not fraud.
|
// multi-unit order within the parcel is business, not fraud.
|
||||||
Check(!TokenOf(srv.Post("/shop/fp6-pmos", Good("&quantity=9"))).empty(),
|
const std::int64_t several = std::min<std::int64_t>(9, maxUnits);
|
||||||
"a nine-unit order goes through");
|
Check(several > 1 && !TokenOf(srv.Post(shop, Good(std::format("&quantity={}", several)))).empty(),
|
||||||
srv.BodyHas("/shop/fp6-pmos", "type=\"number\"", "quantity is a number input, not a dropdown");
|
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
|
// The ceiling is physical: the heaviest band any destination offers
|
||||||
// (10 kg in the fixture) divided by the boxed unit weight (700 g) = 14.
|
// (10 kg in the fixture) divided by the boxed unit weight. The input
|
||||||
// The input advertises the BEST case across destinations; the per-country
|
// advertises the BEST case across destinations; the per-country limit is
|
||||||
// limit is enforced on submit, below.
|
// enforced on submit, below.
|
||||||
srv.BodyHas("/shop/fp6-pmos", "max=\"14\"", "quantity input carries the one-parcel ceiling");
|
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
|
// One order is one parcel. One unit past the ceiling is past every band
|
||||||
// fixture has, so it must be refused rather than quoted a rate the
|
// 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,
|
// carrier would not accept — and the refusal has to say what WOULD fit,
|
||||||
// or the buyer is left guessing.
|
// 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.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");
|
"the too-heavy refusal says what fits");
|
||||||
Check(heavy.body.find("orders@catcrafts.net") != std::string::npos,
|
Check(heavy.body.find("orders@catcrafts.net") != std::string::npos,
|
||||||
"the too-heavy refusal offers a way to order anyway");
|
"the too-heavy refusal offers a way to order anyway");
|
||||||
|
|
@ -235,17 +277,17 @@ void OpenShopLifecycle(TestServer& srv) {
|
||||||
constexpr std::string_view kAu =
|
constexpr std::string_view kAu =
|
||||||
"email=au%40example.org&name=Alex&street=1%20George%20St&postal=2000&city=Sydney&country=AU";
|
"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.status == "422", "an uncovered destination is refused", au.status);
|
||||||
Check(au.body.find("No carrier rate for AU") != std::string::npos,
|
Check(au.body.find("No carrier rate for AU") != std::string::npos,
|
||||||
"an uncovered destination is refused, naming the country");
|
"an uncovered destination is refused, naming the country");
|
||||||
// The country FIELD ERROR must be the carrier message, not the
|
// The country FIELD ERROR must be the carrier message, not the
|
||||||
// no-sale one. Matched on the error markup rather than the bare
|
// no-sale one. Matched on the error markup rather than the bare
|
||||||
// sentence: the no-sale line is standing copy above every buy form.
|
// 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");
|
"an uncovered destination is not confused with a refused one");
|
||||||
const std::size_t before = LedgerLines(srv).size();
|
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");
|
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.
|
// registered identity, amounts — and a signature that verifies offline.
|
||||||
{
|
{
|
||||||
const auto invoice = srv.Get(std::format("/order/{}/invoice.md", token));
|
const auto invoice = srv.Get(std::format("/order/{}/invoice.md", token));
|
||||||
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
|
for (std::string_view probe : std::initializer_list<std::string_view>{
|
||||||
|
"BEGIN PGP SIGNED MESSAGE", "# Invoice ",
|
||||||
"Customer number: ", "Chico Mendesring 256",
|
"Customer number: ", "Chico Mendesring 256",
|
||||||
"KVK 78437059", "NL003329281B38", "CC-",
|
"KVK 78437059", "NL003329281B38", "CC-",
|
||||||
"VAT 21% (NL)", "€588.80" }) {
|
"VAT 21% (NL)", nlEuro }) {
|
||||||
Check(invoice.body.find(probe) != std::string::npos,
|
Check(invoice.body.find(probe) != std::string::npos,
|
||||||
std::format("invoice has {}", probe));
|
std::format("invoice has {}", probe));
|
||||||
}
|
}
|
||||||
|
|
@ -373,9 +416,10 @@ void OpenShopLifecycle(TestServer& srv) {
|
||||||
}
|
}
|
||||||
Check(!nlMail.empty(), "a confirmation email links the NL order");
|
Check(!nlMail.empty(), "a confirmation email links the NL order");
|
||||||
if (!nlMail.empty()) {
|
if (!nlMail.empty()) {
|
||||||
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
|
for (std::string_view probe : std::initializer_list<std::string_view>{
|
||||||
|
"To: e2e@example.org", "Subject: Catcrafts order CC-",
|
||||||
"From: Catcrafts <info@catcrafts.net>",
|
"From: Catcrafts <info@catcrafts.net>",
|
||||||
"MIME-Version: 1.0", "€588.80", "incl. 21% NL VAT",
|
"MIME-Version: 1.0", nlEuro, "incl. 21% NL VAT",
|
||||||
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
|
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
|
||||||
"filename=\"catcrafts-invoice-" }) {
|
"filename=\"catcrafts-invoice-" }) {
|
||||||
Check(nlMail.find(probe) != std::string::npos,
|
Check(nlMail.find(probe) != std::string::npos,
|
||||||
|
|
@ -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
|
// A perfectly valid order must be refused while the shop is closed: after
|
||||||
// validation (so the field checks below still exercise the parser) and
|
// validation (so the field checks below still exercise the parser) and
|
||||||
// before any rail or ledger is touched.
|
// 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");
|
Check(srv.OrdersText().empty(), "refused order writes nothing to the ledger");
|
||||||
std::println("shop is coming-soon; the checkout, order-lifecycle and invoice "
|
std::println("shop is coming-soon; the checkout, order-lifecycle and invoice "
|
||||||
"checks re-arm when the status flips to available");
|
"checks re-arm when the status flips to available");
|
||||||
}
|
}
|
||||||
|
|
||||||
void AlwaysOnValidation(TestServer& srv) {
|
void AlwaysOnValidation(TestServer& srv, const Product& pr) {
|
||||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
const std::string shop = "/shop/" + pr.slug;
|
||||||
|
srv.CheckStatus(shop, "422", "POST",
|
||||||
"name=Ada&street=x&postal=1&city=y&country=NL"); // no email
|
"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)
|
"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
|
"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
|
// Destinations the shop refuses (Money::SellsTo). Well-formed, real
|
||||||
// addresses: the refusal is policy, not a shape check, so it has to hold for
|
// 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
|
// whether the shop is open or not, and it is the assertion that would catch
|
||||||
// the gate being lost in a refactor.
|
// the gate being lost in a refactor.
|
||||||
const std::size_t before = LedgerLines(srv).size();
|
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");
|
"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");
|
"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");
|
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us");
|
||||||
// Sanctioned destinations (Money::SanctionedCountries) refuse through the
|
// Sanctioned destinations (Money::SanctionedCountries) refuse through the
|
||||||
// same always-on gate — this refusal is the law, so of all the checks in
|
// 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.
|
// 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");
|
"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");
|
"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");
|
"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
|
// 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
|
// 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
|
// 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
|
// HTTP is that the refusal holds at all, in both states — which is what 422
|
||||||
// says here.
|
// 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");
|
"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");
|
"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");
|
"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");
|
"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");
|
"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
|
// And the default that makes an allow-list worth having: a well-formed code
|
||||||
// nobody ever considered is refused without appearing on any list.
|
// 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");
|
"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
|
// Refused in validation means nothing reached the ledger and no payment
|
||||||
// link was ever created.
|
// 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
|
// 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,
|
// 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
|
// confirm there is no invoice and no VAT, and watch /financials book it under
|
||||||
// donations rather than sales.
|
// donations rather than sales.
|
||||||
|
|
@ -554,7 +600,16 @@ void DonationLifecycle(TestServer& srv) {
|
||||||
// live EURC payment proved that a buyer staring at "awaiting payment"
|
// live EURC payment proved that a buyer staring at "awaiting payment"
|
||||||
// after their wallet said done is a support email in the making. The
|
// 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.
|
// 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 =
|
const std::string tokenSeen =
|
||||||
TokenOf(srv.Post("/shop/donation", "amount=2&pay=crypto"));
|
TokenOf(srv.Post("/shop/donation", "amount=2&pay=crypto"));
|
||||||
Check(!tokenSeen.empty(), "a crypto donation goes through");
|
Check(!tokenSeen.empty(), "a crypto donation goes through");
|
||||||
|
|
@ -641,7 +696,7 @@ void DonationLifecycle(TestServer& srv) {
|
||||||
"a donation is not worded as an export");
|
"a donation is not worded as an export");
|
||||||
Check(page.find("invoice.md") == std::string::npos,
|
Check(page.find("invoice.md") == std::string::npos,
|
||||||
"a paid donation offers no invoice download");
|
"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");
|
"a paid donation promises no dispatch");
|
||||||
}
|
}
|
||||||
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
|
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
|
// The re-rendered form only exists when the shop is open; while coming-soon a
|
||||||
// rejection answers with the coming-soon page instead.
|
// 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 —
|
// A rejected submission must come back with the values still in it —
|
||||||
// losing a filled-in form is how a sale gets abandoned.
|
// 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");
|
"email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD");
|
||||||
for (std::string_view probe : { "value=\"bad\"", "value=\"NLD\"", "value=\"Ada\"",
|
for (std::string_view probe : { "value=\"bad\"", "value=\"NLD\"", "value=\"Ada\"",
|
||||||
"value=\"Main 1\"", "value=\"Delft\"" }) {
|
"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
|
// 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
|
// 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");
|
"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,
|
!= std::string::npos,
|
||||||
"refusal explains itself on the form");
|
"refusal explains itself on the form");
|
||||||
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
|
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
|
// one — the buyer should learn the law forbids the sale, not wonder what
|
||||||
// insurance has to do with Moscow.
|
// 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");
|
"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,
|
Check(refused.body.find("EU sanctions prohibit") != std::string::npos,
|
||||||
"sanctions refusal explains itself on the form");
|
"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");
|
"sanctions refusal is not worded as the policy one");
|
||||||
}
|
}
|
||||||
// The buy panel warns before anyone fills it in, and the preview script
|
// The buy panel warns before anyone fills it in — positively, as the
|
||||||
// carries the same lists so it cannot quote a total the server would
|
// allow-list is the whole policy — and the preview script carries the
|
||||||
// refuse.
|
// same lists so it cannot quote a total the server would refuse: `w` is
|
||||||
srv.BodyHas("/shop/fp6-pmos", "does not sell or ship to the United States or Canada",
|
// the shipping allow-list (a country is refused by its ABSENCE), `s` the
|
||||||
"buy panel states where the shop does not sell");
|
// sanctioned countries. No deny-list survives in the payload
|
||||||
srv.BodyHas("/shop/fp6-pmos", "cannot sell or ship to Russia, Belarus or North Korea",
|
// (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");
|
"buy panel states where the law forbids selling");
|
||||||
srv.BodyHas("/shop/fp6-pmos", ""x":["US","CA"]",
|
srv.BodyHas(shop, ""w":["NL",",
|
||||||
"total preview knows the refused destinations");
|
"total preview carries the shipping allow-list");
|
||||||
srv.BodyHas("/shop/fp6-pmos", ""s":["RU","BY","KP"]",
|
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");
|
"total preview knows the sanctioned destinations");
|
||||||
// The honeypot message must not name the trap, or it teaches the next
|
// The honeypot message must not name the trap, or it teaches the next
|
||||||
// bot. Only the ERROR NOTICE is inspected: the re-rendered form
|
// bot. Only the ERROR NOTICE is inspected: the re-rendered form
|
||||||
// legitimately contains the name="website" field itself — that IS the
|
// legitimately contains the name="website" field itself — that IS the
|
||||||
// trap, re-armed.
|
// 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;
|
std::smatch m;
|
||||||
Check(std::regex_search(pot.body, m, std::regex(R"lit(notice--error">([^<]*))lit")),
|
Check(std::regex_search(pot.body, m, std::regex(R"lit(notice--error">([^<]*))lit")),
|
||||||
"honeypot rejection renders an error notice");
|
"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
|
// 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
|
// 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) {
|
void OneRailDown(const char* binary) {
|
||||||
ServerOptions options;
|
ServerOptions options;
|
||||||
options.extraArgs = { "--rail=off", "--crypto-rail=fake-crypto" };
|
options.extraArgs = { "--rail=off", "--crypto-rail=fake-crypto" };
|
||||||
|
|
@ -856,19 +919,24 @@ int main(int argc, char** argv) {
|
||||||
options.mailer = true;
|
options.mailer = true;
|
||||||
TestServer srv(argv[1], 8217, options);
|
TestServer srv(argv[1], 8217, options);
|
||||||
|
|
||||||
if (srv.ShopOpen()) {
|
const Product* pr = FirstPricedProduct();
|
||||||
OpenShopLifecycle(srv);
|
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 {
|
} 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.
|
// The donation item is open in both shop states — that is the point of it.
|
||||||
DonationLifecycle(srv);
|
DonationLifecycle(srv);
|
||||||
|
|
||||||
if (srv.ShopOpen()) {
|
if (pr && srv.ShopOpen(pr->slug)) {
|
||||||
RejectedFormEcho(srv);
|
RejectedFormEcho(srv, *pr);
|
||||||
}
|
}
|
||||||
|
|
||||||
OneRailDown(argv[1]);
|
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
|
// charges, the currency conversions the shop card carries, and the copy
|
||||||
// around them. Runs in both shop states — coming-soon asserts the closed
|
// around them. Runs in both shop states — coming-soon asserts the closed
|
||||||
// shape, launch re-arms the open one with no edit here.
|
// 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 std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
import Catcrafts.E2eHarness;
|
import Catcrafts.E2eHarness;
|
||||||
|
|
||||||
|
using namespace Catcrafts;
|
||||||
using namespace Catcrafts::E2e;
|
using namespace Catcrafts::E2e;
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
namespace {
|
||||||
TestServer srv(argv[1], 8216);
|
|
||||||
|
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 price is rendered from the same integers the checkout charges, with
|
||||||
// the derived ex-VAT twin alongside — asserting both pins the arithmetic.
|
// the derived ex-VAT twin alongside — asserting both pins the arithmetic.
|
||||||
srv.BodyHas("/shop/fp6-pmos", "€573.80",
|
srv.BodyHas(shop, incl, "product page shows the (from-)price");
|
||||||
"product page shows the from-price (green supplier + €60.50 gross markup)");
|
srv.BodyHas(shop, net, "product page shows the derived ex-VAT price");
|
||||||
srv.BodyHas("/shop/fp6-pmos", "€474.21", "product page shows the derived ex-VAT price");
|
srv.BodyHas("/shop", incl, "shop card shows the (from-)price");
|
||||||
srv.BodyHas("/shop/fp6-pmos", ">from<", "product page marks the price as a from-price");
|
// "from" is a claim that other colours cost more, so it appears exactly
|
||||||
srv.BodyHas("/shop", "€573.80", "shop card shows the from-price");
|
// when there is more than one colour to choose from.
|
||||||
// Every colour is priced in the selector, and the form carries the exact
|
if (pr.variants.size() > 1) {
|
||||||
// data blob the preview computes from.
|
srv.BodyHas(shop, ">from<", "product page marks the price as a from-price");
|
||||||
srv.BodyHas("/shop/fp6-pmos", "Black €579.80", "colour selector prices black");
|
Check(pr.CheapestVariant() && pr.CheapestVariant()->priceInclMinor == pr.priceInclMinor,
|
||||||
srv.BodyHas("/shop/fp6-pmos", "White €665.38", "colour selector prices white");
|
"the advertised from-price is the cheapest colour's");
|
||||||
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");
|
|
||||||
} else {
|
} else {
|
||||||
srv.BodyHas("/shop/fp6-pmos", "Coming soon", "coming-soon notice on the buy panel");
|
srv.BodyLacks(shop, ">from<", "a single-price product is not a from-price");
|
||||||
srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge");
|
}
|
||||||
srv.BodyLacks("/shop/fp6-pmos", "<form", "no order form while coming soon");
|
// 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
|
// The photo: on the page, on the card, in the link preview, and actually
|
||||||
// soft opening. Its card quotes no price (there is none), its page is a
|
// present in the repo — the file itself is Caddy's to serve (static
|
||||||
// form asking an amount and, optionally, an email — never an address.
|
// 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", "/shop/donation", "shop grid lists the donation item");
|
||||||
srv.BodyHas("/shop", "any amount", "donation card quotes no price");
|
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", "name=\"amount\"", "donation form asks for an amount");
|
||||||
srv.BodyHas("/shop/donation", ">Donate</button>",
|
srv.BodyHas("/shop/donation", ">Donate</button>", "donation form submits to payment");
|
||||||
"donation form submits to payment");
|
|
||||||
srv.BodyLacks("/shop/donation", "name=\"street\"",
|
srv.BodyLacks("/shop/donation", "name=\"street\"",
|
||||||
"donation form asks no address — nothing ships");
|
"donation form asks no address — nothing ships");
|
||||||
srv.BodyLacks("/shop/donation", "name=\"quantity\"",
|
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
|
// No price, no conversions, no preview: the donation page ships NO
|
||||||
// executable script at all — stricter than the shop pages' one-script rule.
|
// executable script at all — stricter than the shop pages' one-script rule.
|
||||||
srv.BodyLacks("/shop/donation", "<script>", "donation page ships no executable script");
|
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", "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
|
} // namespace
|
||||||
// supported currency pre-formatted server-side as a data attribute for
|
|
||||||
// the script to pick from. Converted amounts carry "~". GBP converts the
|
int main(int argc, char** argv) {
|
||||||
// ex-VAT price; SEK (an EU member's currency) converts the VAT-inclusive
|
TestServer srv(argv[1], 8216);
|
||||||
// price. USD and CAD are absent on purpose — the shop refuses those
|
|
||||||
// destinations, so it does not quote a local price to them either.
|
if (const Product* pr = FirstPricedProduct()) {
|
||||||
srv.BodyHas("/shop", "class=\"price__single\"", "shop card renders the single-number price");
|
PricedProduct(srv, *pr);
|
||||||
srv.BodyHas("/shop", "data-gbp=\"~£", "shop card carries a GBP conversion");
|
} else {
|
||||||
srv.BodyHas("/shop", "data-sek=\"~kr ", "shop card carries an SEK conversion");
|
std::println("note: the catalogue lists no priced product; the goods checks "
|
||||||
srv.BodyHas("/shop", "data-world=\"€474.21\"", "shop card carries the euro export fallback");
|
"did not run and re-arm when one is listed");
|
||||||
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");
|
Donation(srv);
|
||||||
// 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");
|
|
||||||
|
|
||||||
return Finish();
|
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.
|
// backend exists: a client-side router cannot produce one.
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
import Catcrafts.E2eHarness;
|
import Catcrafts.E2eHarness;
|
||||||
|
|
||||||
|
using namespace Catcrafts;
|
||||||
using namespace Catcrafts::E2e;
|
using namespace Catcrafts::E2e;
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
TestServer srv(argv[1], 8210);
|
TestServer srv(argv[1], 8210);
|
||||||
|
|
||||||
// ── status codes ──────────────────────────────────────────────────
|
// ── status codes ──────────────────────────────────────────────────
|
||||||
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos",
|
for (std::string_view p : { "/", "/about", "/shop", "/shop/donation", "/projects",
|
||||||
"/shop/fp6plus-pmos", "/shop/donation", "/projects",
|
|
||||||
"/posts", "/demos", "/demos/raytracer", "/financials",
|
"/posts", "/demos", "/demos/raytracer", "/financials",
|
||||||
"/legal/privacy", "/legal/terms", "/legal/imprint",
|
"/legal/privacy", "/legal/terms", "/legal/imprint",
|
||||||
"/feed.xml", "/sitemap.xml", "/api/healthz" }) {
|
"/feed.xml", "/sitemap.xml", "/api/healthz" }) {
|
||||||
srv.CheckStatus(std::string(p), "200");
|
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.
|
// Trailing slashes must normalise, not 404 or duplicate the canonical URL.
|
||||||
srv.CheckStatus("/projects/", "200");
|
srv.CheckStatus("/projects/", "200");
|
||||||
srv.CheckStatus("/shop/", "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");
|
srv.HeaderHas("/sitemap.xml", "content-type", "application/xml", "sitemap content-type");
|
||||||
|
|
||||||
// ── sitemap and feed content ──────────────────────────────────────
|
// ── sitemap and feed content ──────────────────────────────────────
|
||||||
srv.BodyHas("/sitemap.xml", "/shop/fp6-pmos", "sitemap lists the product");
|
// Product URLs come off the catalogue, not a second list, so every entry
|
||||||
srv.BodyHas("/sitemap.xml", "/shop/fp6plus-pmos",
|
// is advertised and nothing that is not listed can be.
|
||||||
"sitemap lists the refresh — it comes off the catalogue, not a second list");
|
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", "/about", "sitemap lists the about page");
|
||||||
srv.BodyHas("/sitemap.xml", "/legal/privacy", "sitemap lists the privacy page");
|
srv.BodyHas("/sitemap.xml", "/legal/privacy", "sitemap lists the privacy page");
|
||||||
srv.BodyHas("/sitemap.xml", "/demos", "sitemap lists the demos 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");
|
srv.BodyHas("/feed.xml", "<feed xmlns=\"http://www.w3.org/2005/Atom\">", "feed is Atom");
|
||||||
|
|
||||||
// ── abuse ─────────────────────────────────────────────────────────
|
// ── 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')
|
"email=a%40b.example&name=" + std::string(20000, 'x')
|
||||||
+ "&street=x&postal=1&city=y&country=NL");
|
+ "&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);
|
Check(json.status == "415", "POST with a JSON content-type -> 415", json.status);
|
||||||
}
|
}
|
||||||
srv.CheckStatus("/shop/nope", "404", "POST",
|
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
|
// 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
|
// 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.
|
// address or rate table.
|
||||||
struct Order {
|
struct Order {
|
||||||
std::string token;
|
std::string token;
|
||||||
|
|
|
||||||
|
|
@ -26,91 +26,197 @@ void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||||||
got.empty() ? "" : " got: ", 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 ─────────────────────────────────────────
|
// ── 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() {
|
void CatalogueContract() {
|
||||||
using namespace Catcrafts::Money;
|
using namespace Catcrafts::Money;
|
||||||
|
|
||||||
const auto& products = Content::Products();
|
const auto& products = Content::Products();
|
||||||
// Three entries: the two phones, and the donation item that soft-opens
|
Check(!products.empty(), "content: the catalogue is not empty");
|
||||||
// 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
|
// The donation item: exactly one, LAST (it is the odd entry out, not what
|
||||||
// stays LAST.
|
// the shop is about), and available — it is what the shop is open FOR
|
||||||
Check(products.size() == 3, "content: three products");
|
// whatever the goods are doing. Buyer names the amount, so no price, no
|
||||||
if (products.size() == 3) {
|
// variants, no weight — and the Buyable() price check is waived for
|
||||||
const Product& pr = products[0];
|
// exactly this shape.
|
||||||
Check(pr.slug == "fp6-pmos", "content: product slug");
|
std::size_t donations = 0;
|
||||||
// Coming-soon is the pre-launch state; launch flips it to
|
for (const Product& p : products) donations += p.donation ? 1 : 0;
|
||||||
// "available" and this check keeps passing either way.
|
Check(donations == 1, "content: exactly one donation item");
|
||||||
Check(pr.Buyable() || pr.ComingSoon(),
|
const Product& don = products.back();
|
||||||
"content: product is buyable or deliberately coming soon");
|
Check(don.donation && don.slug == "donation", "content: the donation item comes last");
|
||||||
Check(pr.variants.size() == 3, "content: three colours");
|
Check(don.Buyable() && !don.ComingSoon(), "content: the donation item is on sale");
|
||||||
// Cost-plus pricing, derived in code: supplier + €60.50 gross markup,
|
Check(don.priceInclMinor == 0 && don.variants.empty() && don.shipWeightGrams == 0,
|
||||||
// exactly — the gross-up of the €50 Catcrafts keeps after VAT. The
|
"content: a donation has no price, no colours and no parcel");
|
||||||
// margin identity itself (net of retail = net of supplier + 5000) is
|
Check(don.warranty.empty() && don.specs.empty(),
|
||||||
// asserted below; these pin the resulting stickers.
|
"content: a donation carries no spec sheet and no warranty");
|
||||||
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 57380,
|
// Its page: a donation form (amount + optional email), no price line, no
|
||||||
"content: green = 513.30 supplier + 60.50 gross markup");
|
// product JSON-LD — an offer with no amount is a claim shopping crawlers
|
||||||
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 57980,
|
// can only misread — and no goods sections.
|
||||||
"content: black = 519.30 supplier + 60.50 gross markup");
|
{
|
||||||
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 66538,
|
const auto dp = Views::RenderProduct(don, Rates{});
|
||||||
"content: white = 604.88 supplier + 60.50 gross markup");
|
const std::string_view html = dp.main.View();
|
||||||
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
|
Check(html.find("name=\"amount\"") != std::string_view::npos,
|
||||||
Check(pr.priceInclMinor == 57380, "content: from-price is the cheapest variant");
|
"donation page: the form asks for an amount");
|
||||||
// The pricing rule as the user states it: after shipping (a pass-
|
Check(html.find("name=\"street\"") == std::string_view::npos
|
||||||
// through) and VAT, every unit sold walks away with €50.00 — however
|
&& html.find("name=\"country\"") == std::string_view::npos,
|
||||||
// the supplier moves. Checked against the COMPILED catalogue, per
|
"donation page: no address is asked for — nothing ships");
|
||||||
// variant, in the same arithmetic the invoice and checkout use:
|
Check(html.find("field__req") == std::string_view::npos
|
||||||
// net(retail) - net(supplier) must be exactly 5000 minor. Shipping
|
|| html.find("Email <span") == std::string_view::npos,
|
||||||
// has its own round-trip guarantee in ShouldComputeMoney, and
|
"donation page: email is not marked required");
|
||||||
// Payment costs are the one accepted deviation, and on a bank
|
Check(html.find("price--product") == std::string_view::npos,
|
||||||
// transfer they are zero.
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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", 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
|
||||||
|
// branch around in the renderer.
|
||||||
|
for (const Product& any : products) {
|
||||||
|
Check(any.specs.empty() || !any.brand.empty(),
|
||||||
|
"content: a product with a spec sheet names its manufacturer", any.slug);
|
||||||
|
}
|
||||||
|
Check(!Content::Projects().empty(), "content: projects present");
|
||||||
|
Check(Content::LegalPages().size() == 3, "content: three legal pages");
|
||||||
|
Check(Content::AboutPage().sections.size() >= 3
|
||||||
|
&& !Content::AboutPage().sections[0].body.empty()
|
||||||
|
&& Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf")
|
||||||
|
!= std::string::npos,
|
||||||
|
"content: about page names the founder");
|
||||||
|
Check(!Content::Demos().empty(), "content: demos present");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the 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] :
|
for (const auto& [slug, supplier] :
|
||||||
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
|
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
|
||||||
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
|
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
|
||||||
const Variant* v = pr.FindVariant(slug);
|
const Variant* v = pr.FindVariant(slug);
|
||||||
Check(v && Money::NetFromGross(v->priceInclMinor)
|
Check(v && NetFromGross(v->priceInclMinor) - NetFromGross(supplier) == 5000,
|
||||||
- Money::NetFromGross(supplier) == 5000,
|
"fixture: variant nets the supplier price plus exactly €50", slug);
|
||||||
"content: variant nets the supplier price plus exactly €50", slug);
|
|
||||||
}
|
}
|
||||||
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
|
|
||||||
"content: cheapest is green");
|
|
||||||
// The boxed weight of one outgoing parcel. Not decoration: this
|
|
||||||
// single integer picks the carrier's weight bracket, so it decides
|
|
||||||
// the shipping cents added to every order AND the per-country
|
|
||||||
// quantity ceiling. Regressed to 0 or off by an order of magnitude,
|
|
||||||
// the shop quotes a rate the carrier does not honour on real parcels.
|
|
||||||
Check(pr.shipWeightGrams == 700, "content: one boxed unit weighs 700 g");
|
|
||||||
{
|
{
|
||||||
// A ladder straddling that weight. 700 g fits both bands, and
|
// A ladder straddling the boxed weight. 700 g fits both bands, and
|
||||||
// RateFor takes the CHEAPEST band that can carry it — 895, not
|
// RateFor takes the CHEAPEST band that can carry it — 895, not the
|
||||||
// the tighter-looking 5500 — while the ceiling comes off the
|
// tighter-looking 5500 — while the ceiling comes off the heaviest band:
|
||||||
// heaviest band: 10000 / 700 = 14 units to a parcel.
|
// 10000 / 700 = 14 units to a parcel.
|
||||||
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 5500 } };
|
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 5500 } };
|
||||||
Check(RateFor(ladder, pr.shipWeightGrams) == 895,
|
Check(RateFor(ladder, pr.shipWeightGrams) == 895,
|
||||||
"content: the shipped weight lands in the cheap carrier bracket");
|
"fixture: the shipped weight lands in the cheap carrier bracket");
|
||||||
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
|
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
|
||||||
"content: and caps one parcel at fourteen units");
|
"fixture: 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
|
// The product page's schema.org record must parse with our own JSON parser
|
||||||
// an approved test session, so the assertion inverts: the page must
|
// and carry one variant Product per colour, each with its ONE offer — built
|
||||||
// not go back to telling buyers that path is unverified. A future
|
// from the same integers the checkout charges.
|
||||||
// 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");
|
|
||||||
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
|
// The listing's shipping block is carrier data, so the render needs a
|
||||||
// render needs a table. Three of these four are priced on purpose
|
// table. Three of these four are priced on purpose and must still not
|
||||||
// and must still not be advertised: US is refused on insurance, DE
|
// be advertised: US is refused by policy, DE and GB on their missing
|
||||||
// and GB on their missing producer registrations. The carrier will
|
// producer registrations. The carrier will happily quote all three,
|
||||||
// happily quote all three, which is exactly why the filter is worth
|
// which is exactly why the filter is worth asserting — CH is the only
|
||||||
// asserting — CH is the only one here besides home that sells.
|
// one here besides home that sells.
|
||||||
const std::vector<ShipRates> feedTable{
|
const std::vector<ShipRates> feedTable{
|
||||||
{ "NL", { { 2000, 895 } } },
|
{ "NL", { { 2000, 895 } } },
|
||||||
{ "CH", { { 2000, 2450 } } },
|
{ "CH", { { 2000, 2450 } } },
|
||||||
|
|
@ -134,12 +240,15 @@ void CatalogueContract() {
|
||||||
}
|
}
|
||||||
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
|
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
|
||||||
"schema: product JSON-LD parses, one variant per colour");
|
"schema: product JSON-LD parses, one variant per colour");
|
||||||
// Merchant-grade fields: shipping, returns, sku, group id —
|
Check(pp.meta.jsonLd.find("\"brand\":{\"@type\":\"Brand\",\"name\":\"Acme\"}")
|
||||||
// what Merchant Center's website-crawl feed reads at launch
|
!= std::string::npos,
|
||||||
// (productGroupID is its item_group_id).
|
"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
|
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
|
||||||
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
|
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
|
||||||
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
|
&& pp.meta.jsonLd.find("\"sku\":\"fixture-handheld-green\"") != std::string::npos
|
||||||
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||||
"schema: variants carry shipping, returns, sku and group id");
|
"schema: variants carry shipping, returns, sku and group id");
|
||||||
// The published rates ARE the carrier's, at one unit's weight.
|
// The published rates ARE the carrier's, at one unit's weight.
|
||||||
|
|
@ -153,13 +262,30 @@ void CatalogueContract() {
|
||||||
&& pp.meta.jsonLd.find("\"33.00\"") == std::string::npos
|
&& pp.meta.jsonLd.find("\"33.00\"") == std::string::npos
|
||||||
&& pp.meta.jsonLd.find("\"17.94\"") == std::string::npos,
|
&& pp.meta.jsonLd.find("\"17.94\"") == std::string::npos,
|
||||||
"schema: refused destinations are never advertised");
|
"schema: refused destinations are never advertised");
|
||||||
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
|
Check(pp.meta.jsonLd.find("\"US\"") == std::string::npos,
|
||||||
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
|
|
||||||
"schema: a refused destination is never advertised, priced or not");
|
"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
|
// No table: no shipping claim. The listing loses the merchant block
|
||||||
// block rather than inventing a rate — the whole point of
|
// rather than inventing a rate — the whole point of dropping the zone
|
||||||
// dropping the zone fallback.
|
// fallback.
|
||||||
auto bare = Views::RenderProduct(pr, Rates{});
|
auto bare = Views::RenderProduct(pr, Rates{});
|
||||||
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
|
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
|
||||||
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
|
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
|
||||||
|
|
@ -167,138 +293,13 @@ void CatalogueContract() {
|
||||||
Check(Json::Parse(bare.meta.jsonLd).has_value()
|
Check(Json::Parse(bare.meta.jsonLd).has_value()
|
||||||
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||||
"schema: and the rest of the record still parses");
|
"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 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
// branch around in the renderer.
|
|
||||||
for (const Product& any : products) {
|
|
||||||
Check(any.specs.empty() || !any.brand.empty(),
|
|
||||||
"content: a product with a spec sheet names its manufacturer", any.slug);
|
|
||||||
}
|
|
||||||
Check(!Content::Projects().empty(), "content: projects present");
|
|
||||||
Check(Content::LegalPages().size() == 3, "content: three legal pages");
|
|
||||||
Check(Content::AboutPage().sections.size() >= 3
|
|
||||||
&& !Content::AboutPage().sections[0].body.empty()
|
|
||||||
&& Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf")
|
|
||||||
!= std::string::npos,
|
|
||||||
"content: about page names the founder");
|
|
||||||
Check(!Content::Demos().empty(), "content: demos present");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the identity graph ────────────────────────────────────────────────
|
// ── the identity graph ────────────────────────────────────────────────
|
||||||
|
|
@ -366,8 +367,11 @@ void IdentityGraph() {
|
||||||
auto about = Views::RenderAbout(Content::AboutPage());
|
auto about = Views::RenderAbout(Content::AboutPage());
|
||||||
Check(about.meta.jsonLd.find(kOrgId) != std::string::npos,
|
Check(about.meta.jsonLd.find(kOrgId) != std::string::npos,
|
||||||
"schema: about joins the founder to the organization node");
|
"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,
|
Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos,
|
||||||
"schema: offers are sold by the organization node");
|
"schema: offers are sold by the organization node");
|
||||||
}
|
}
|
||||||
|
|
@ -382,9 +386,9 @@ void IdentityGraph() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the sale gates, built by hand ─────────────────────────────────────
|
// ── the sale gates, built by hand ─────────────────────────────────────
|
||||||
// The shipped catalogue is one product in one status with three colours, so
|
// The shipped catalogue holds each status at most once (today: the donation
|
||||||
// asserting against it can only ever exercise one arm of each guard. These
|
// alone), so asserting against it can only ever exercise one arm of each
|
||||||
// products exist to reach the others.
|
// guard. These products exist to reach the others.
|
||||||
//
|
//
|
||||||
// Buyable() is the ONLY server-side gate on POST /checkout, and it is a
|
// Buyable() is the ONLY server-side gate on POST /checkout, and it is a
|
||||||
// conjunction: status AND a price. ComingSoon() is separately what decides
|
// conjunction: status AND a price. ComingSoon() is separately what decides
|
||||||
|
|
@ -454,16 +458,15 @@ void SaleGates() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the product page's live-total blob ────────────────────────────────
|
// ── the product page's live-total blob ────────────────────────────────
|
||||||
// RenderCheckoutForm renders only for a Buyable product, and the shipped
|
// RenderCheckoutForm renders only for a Buyable priced product, and the
|
||||||
// catalogue is coming-soon, so today nothing renders it: the two suites that
|
// shipped catalogue lists none, so nothing in it renders the form: the suites
|
||||||
// read this attribute both sit behind a ShopOpen() gate. Flip a copy to
|
// that read this attribute over HTTP sit behind a ShopOpen() gate. Flip the
|
||||||
// "available" and read the markup here instead — the refusal lists in that
|
// fixture to "available" and read the markup here instead — the refusal lists
|
||||||
// blob are what make the on-page total decline in exactly the places
|
// 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
|
// checkout declines, and a page that quotes a total for a sanctioned or
|
||||||
// no-sale destination invites an order that must then be refused.
|
// no-sale destination invites an order that must then be refused.
|
||||||
void CheckoutPreviewData() {
|
void CheckoutPreviewData() {
|
||||||
if (Content::Products().empty()) return;
|
Product pr = Fixture();
|
||||||
Product pr = Content::Products()[0];
|
|
||||||
pr.status = "available";
|
pr.status = "available";
|
||||||
Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders");
|
Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders");
|
||||||
|
|
||||||
|
|
@ -583,6 +586,7 @@ void AdvertisedUrls() {
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
CatalogueContract();
|
CatalogueContract();
|
||||||
|
RendererContract();
|
||||||
IdentityGraph();
|
IdentityGraph();
|
||||||
SaleGates();
|
SaleGates();
|
||||||
CheckoutPreviewData();
|
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.
|
// (the timezone price hint); everything else ships none at all.
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
|
import Catcrafts.Shared;
|
||||||
import Catcrafts.E2eHarness;
|
import Catcrafts.E2eHarness;
|
||||||
|
|
||||||
|
using namespace Catcrafts;
|
||||||
using namespace Catcrafts::E2e;
|
using namespace Catcrafts::E2e;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
@ -53,10 +55,14 @@ int main(int argc, char** argv) {
|
||||||
// Placeholders are dev-only markers; one reaching production is a content
|
// Placeholders are dev-only markers; one reaching production is a content
|
||||||
// bug (an imprint that says PLACEHOLDER once shipped exactly that way).
|
// bug (an imprint that says PLACEHOLDER once shipped exactly that way).
|
||||||
for (std::string_view pg : { "/legal/privacy", "/legal/terms", "/legal/imprint",
|
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",
|
srv.BodyLacks(std::string(pg), "PLACEHOLDER",
|
||||||
std::format("{} ships no placeholder markers", pg));
|
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
|
// Shop pages are the one exception to script-free: they carry exactly ONE
|
||||||
// EXECUTABLE inline script — the timezone price hint, whose tag is the
|
// 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
|
// 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
|
// ever ride in under this exception), no network APIs, and the page must
|
||||||
// remain complete without it — both prices in the markup regardless.
|
// remain complete without it — both prices in the markup regardless.
|
||||||
for (std::string_view pg : { "/shop", "/shop/fp6-pmos", "/shop/fp6plus-pmos" }) {
|
// The pages under the rule: the shop index and every PRICED product page.
|
||||||
const std::string body = srv.Body(std::string(pg));
|
// 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>");
|
const std::size_t n = CountOccurrences(body, "<script>");
|
||||||
Check(n == 1,
|
Check(n == 1,
|
||||||
std::format("{} carries exactly one executable script (the price hint)", pg),
|
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));
|
Check(!network, std::format("{} script makes no network calls", pg));
|
||||||
}
|
}
|
||||||
srv.BodyHas("/shop/fp6-pmos", "cc-noneu", "price hint tags the non-EU outcome");
|
if (const Product* pr = FirstPricedProduct()) {
|
||||||
srv.BodyHas("/shop/fp6-pmos", "cc-eu", "price hint tags the confirmed-EU outcome too");
|
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
|
// The renderer loads only where a demo entry declares needsWasm — the
|
||||||
// demo LIST is a content page and must stay free of it.
|
// 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
|
// it is dead weight and one more thing that could retarget a future
|
||||||
// relative link.
|
// relative link.
|
||||||
srv.BodyLacks("/posts", "<base", "/posts has no base tag");
|
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 ─────────────────────────────────────────────
|
// ── home page actions ─────────────────────────────────────────────
|
||||||
srv.BodyHas("/", "Browse projects", "home links to projects");
|
srv.BodyHas("/", "Browse projects", "home links to projects");
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ module;
|
||||||
export module Catcrafts.E2eHarness;
|
export module Catcrafts.E2eHarness;
|
||||||
import std;
|
import std;
|
||||||
import Crafter.Network;
|
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;
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
|
@ -62,6 +66,18 @@ inline std::string ReadFile(const fs::path& p) {
|
||||||
return buf.str();
|
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) {
|
inline std::size_t CountOccurrences(std::string_view haystack, std::string_view needle) {
|
||||||
if (needle.empty()) return 0;
|
if (needle.empty()) return 0;
|
||||||
std::size_t n = 0;
|
std::size_t n = 0;
|
||||||
|
|
@ -302,9 +318,12 @@ public:
|
||||||
return Body(path);
|
return Body(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open shop or coming-soon? The pricing blob (data-cc) exists only on the
|
// Open shop or coming-soon, for one priced product? The pricing blob
|
||||||
// real order form, so its presence is the probe.
|
// (data-cc) exists only on the real order form, so its presence is the
|
||||||
bool ShopOpen() { return Body("/shop/fp6-pmos").find("data-cc=") != std::string::npos; }
|
// probe.
|
||||||
|
bool ShopOpen(std::string_view slug) {
|
||||||
|
return Body("/shop/" + std::string(slug)).find("data-cc=") != std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void Spawn(const std::vector<std::string>& argv) {
|
void Spawn(const std::vector<std::string>& argv) {
|
||||||
|
|
|
||||||
13
tools/dev.sh
13
tools/dev.sh
|
|
@ -203,13 +203,14 @@ elif [ "${DEV_FINANCIALS:-0}" = 1 ]; then
|
||||||
{"label":"SAMPLE inventory","total_minor":300000},
|
{"label":"SAMPLE inventory","total_minor":300000},
|
||||||
{"label":"SAMPLE payment fees","total_minor":5000}]}
|
{"label":"SAMPLE payment fees","total_minor":5000}]}
|
||||||
JSON
|
JSON
|
||||||
# Two paid orders, written straight to the ledger: the product is
|
# Two paid orders, written straight to the ledger: the catalogue lists no
|
||||||
# coming-soon, so checkout refuses and there is no other way to make the
|
# priced goods, so checkout cannot make the sales row non-zero and this is
|
||||||
# sales row non-zero. Same event shapes Orders.cpp appends.
|
# the only way to fill it. Same event shapes Orders.cpp appends; the slug
|
||||||
|
# names nothing in the catalogue, so the order page shows it verbatim.
|
||||||
cat > "$WORK/orders.jsonl" <<'JSON'
|
cat > "$WORK/orders.jsonl" <<'JSON'
|
||||||
{"type":"order","at":"2026-08-10T10:00:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","ref":"CC-111111","product":"fp6-pmos","color":"green","quantity":1,"unit_minor":56330,"email":"sample@example.org","name":"Sample Buyer","street":"1 Example St","postal":"1000AA","city":"Amsterdam","country":"NL","goods_minor":56330,"shipping_minor":713,"total_minor":57043,"vat_included":true,"status":"awaiting_payment","pay_choice":"bank","pay_url":"","pay_id":"dev-1"}
|
{"type":"order","at":"2026-08-10T10:00:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","ref":"CC-111111","product":"sample-goods","color":"green","quantity":1,"unit_minor":56330,"email":"sample@example.org","name":"Sample Buyer","street":"1 Example St","postal":"1000AA","city":"Amsterdam","country":"NL","goods_minor":56330,"shipping_minor":713,"total_minor":57043,"vat_included":true,"status":"awaiting_payment","pay_choice":"bank","pay_url":"","pay_id":"dev-1"}
|
||||||
{"type":"status","at":"2026-08-10T10:04:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","status":"paid","via":"ideal"}
|
{"type":"status","at":"2026-08-10T10:04:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","status":"paid","via":"ideal"}
|
||||||
{"type":"order","at":"2026-08-12T14:30:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","ref":"CC-222222","product":"fp6-pmos","color":"white","quantity":1,"unit_minor":65488,"email":"other@example.org","name":"Other Buyer","street":"2 Example Rd","postal":"3000BB","city":"Rotterdam","country":"DE","goods_minor":65488,"shipping_minor":2500,"total_minor":67988,"vat_included":true,"status":"awaiting_payment","pay_choice":"crypto","pay_url":"","pay_id":"dev-2"}
|
{"type":"order","at":"2026-08-12T14:30:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","ref":"CC-222222","product":"sample-goods","color":"white","quantity":1,"unit_minor":65488,"email":"other@example.org","name":"Other Buyer","street":"2 Example Rd","postal":"3000BB","city":"Rotterdam","country":"DE","goods_minor":65488,"shipping_minor":2500,"total_minor":67988,"vat_included":true,"status":"awaiting_payment","pay_choice":"crypto","pay_url":"","pay_id":"dev-2"}
|
||||||
{"type":"status","at":"2026-08-12T14:33:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","status":"paid","via":"bitcoin"}
|
{"type":"status","at":"2026-08-12T14:33:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","status":"paid","via":"bitcoin"}
|
||||||
JSON
|
JSON
|
||||||
echo "dev: seeded SAMPLE financials (DEV_FINANCIALS=1) — figures are invented"
|
echo "dev: seeded SAMPLE financials (DEV_FINANCIALS=1) — figures are invented"
|
||||||
|
|
@ -241,7 +242,7 @@ cat <<EOF
|
||||||
|
|
||||||
/ home
|
/ home
|
||||||
/shop product list
|
/shop product list
|
||||||
/shop/<slug> fp6-pmos, fp6plus-pmos, donation (fake payment rail)
|
/shop/<slug> donation (fake payment rail)
|
||||||
/projects the Crafter suite
|
/projects the Crafter suite
|
||||||
/posts fediverse posts
|
/posts fediverse posts
|
||||||
/demos demo list; /demos/raytracer loads the wasm
|
/demos demo list; /demos/raytracer loads the wasm
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue