donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b0666841f6
commit
abbd616b40
23 changed files with 2898 additions and 209 deletions
|
|
@ -31,8 +31,11 @@ void CatalogueContract() {
|
|||
using namespace Catcrafts::Money;
|
||||
|
||||
const auto& products = Content::Products();
|
||||
Check(products.size() == 1, "content: one product");
|
||||
if (products.size() == 1) {
|
||||
// Two entries: the phone, and the donation item that soft-opens the shop.
|
||||
// The phone stays FIRST — it is the headline, and the suites below
|
||||
// address Products()[0] as the priced product.
|
||||
Check(products.size() == 2, "content: two products");
|
||||
if (products.size() == 2) {
|
||||
const Product& pr = products[0];
|
||||
Check(pr.slug == "fp6-pmos", "content: product slug");
|
||||
// Coming-soon is the pre-launch state; launch flips it to
|
||||
|
|
@ -40,17 +43,52 @@ void CatalogueContract() {
|
|||
Check(pr.Buyable() || pr.ComingSoon(),
|
||||
"content: product is buyable or deliberately coming soon");
|
||||
Check(pr.variants.size() == 3, "content: three colours");
|
||||
// Cost-plus pricing, derived in code: supplier + €50, exactly.
|
||||
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330,
|
||||
"content: green = 513.30 supplier + 50 markup");
|
||||
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930,
|
||||
"content: black = 519.30 supplier + 50 markup");
|
||||
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488,
|
||||
"content: white = 604.88 supplier + 50 markup");
|
||||
// Cost-plus pricing, derived in code: supplier + €60.50 gross markup,
|
||||
// exactly — the gross-up of the €50 Catcrafts keeps after VAT. The
|
||||
// margin identity itself (net of retail = net of supplier + 5000) is
|
||||
// asserted below; these pin the resulting stickers.
|
||||
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 57380,
|
||||
"content: green = 513.30 supplier + 60.50 gross markup");
|
||||
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 57980,
|
||||
"content: black = 519.30 supplier + 60.50 gross markup");
|
||||
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 66538,
|
||||
"content: white = 604.88 supplier + 60.50 gross markup");
|
||||
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
|
||||
Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant");
|
||||
Check(pr.priceInclMinor == 57380, "content: from-price is the cheapest variant");
|
||||
// The pricing rule as the user states it: after shipping (a pass-
|
||||
// through) and VAT, every unit sold walks away with €50.00 — however
|
||||
// the supplier moves. Checked against the COMPILED catalogue, per
|
||||
// variant, in the same arithmetic the invoice and checkout use:
|
||||
// net(retail) - net(supplier) must be exactly 5000 minor. Shipping
|
||||
// has its own round-trip guarantee in ShouldComputeMoney, and
|
||||
// Mollie's per-transaction fee is the one accepted deviation.
|
||||
for (const auto& [slug, supplier] :
|
||||
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
|
||||
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
|
||||
const Variant* v = pr.FindVariant(slug);
|
||||
Check(v && Money::NetFromGross(v->priceInclMinor)
|
||||
- Money::NetFromGross(supplier) == 5000,
|
||||
"content: variant nets the supplier price plus exactly €50", slug);
|
||||
}
|
||||
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
|
||||
"content: cheapest is green");
|
||||
// The boxed weight of one outgoing parcel. Not decoration: this
|
||||
// single integer picks the carrier's weight bracket, so it decides
|
||||
// the shipping cents added to every order AND the per-country
|
||||
// quantity ceiling. Regressed to 0 or off by an order of magnitude,
|
||||
// the shop quotes a rate the carrier does not honour on real parcels.
|
||||
Check(pr.shipWeightGrams == 700, "content: one boxed unit weighs 700 g");
|
||||
{
|
||||
// A ladder straddling that weight. 700 g fits both bands, and
|
||||
// RateFor takes the CHEAPEST band that can carry it — 895, not
|
||||
// the tighter-looking 5500 — while the ceiling comes off the
|
||||
// heaviest band: 10000 / 700 = 14 units to a parcel.
|
||||
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 5500 } };
|
||||
Check(RateFor(ladder, pr.shipWeightGrams) == 895,
|
||||
"content: the shipped weight lands in the cheap carrier bracket");
|
||||
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
|
||||
"content: and caps one parcel at fourteen units");
|
||||
}
|
||||
Check(pr.safetyNote.find("112") != std::string::npos
|
||||
&& pr.safetyNote.find("not yet verified") != std::string::npos,
|
||||
"content: emergency-calling safety warning present and honest");
|
||||
|
|
@ -114,6 +152,45 @@ void CatalogueContract() {
|
|||
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||
"schema: and the rest of the record still parses");
|
||||
}
|
||||
|
||||
// The donation item: the shop's soft opening. Available (it is what
|
||||
// the shop is open FOR) while the phone stays coming-soon; buyer
|
||||
// names the amount, so no price, no variants, no weight — and the
|
||||
// Buyable() price check is waived for exactly this shape.
|
||||
const Product& don = products[1];
|
||||
Check(don.slug == "donation" && don.donation,
|
||||
"content: the second product is the donation item");
|
||||
Check(don.Buyable() && !don.ComingSoon(),
|
||||
"content: the donation item is on sale while the phone is not");
|
||||
Check(don.priceInclMinor == 0 && don.variants.empty()
|
||||
&& don.shipWeightGrams == 0,
|
||||
"content: a donation has no price, no colours and no parcel");
|
||||
Check(don.warranty.empty() && don.specs.empty(),
|
||||
"content: a donation carries no spec sheet and no warranty");
|
||||
// Its page: a donation form (amount + optional email), no price line,
|
||||
// no product JSON-LD — an offer with no amount is a claim shopping
|
||||
// crawlers can only misread — and no Fairphone sections.
|
||||
{
|
||||
const auto dp = Views::RenderProduct(don, Rates{});
|
||||
const std::string_view html = dp.main.View();
|
||||
Check(html.find("name=\"amount\"") != std::string_view::npos,
|
||||
"donation page: the form asks for an amount");
|
||||
Check(html.find("name=\"street\"") == std::string_view::npos
|
||||
&& html.find("name=\"country\"") == std::string_view::npos,
|
||||
"donation page: no address is asked for — nothing ships");
|
||||
Check(html.find("field__req") == std::string_view::npos
|
||||
|| html.find("Email <span") == std::string_view::npos,
|
||||
"donation page: email is not marked required");
|
||||
Check(html.find("price--product") == std::string_view::npos,
|
||||
"donation page: no price line for an unpriced item");
|
||||
Check(html.find("Specifications") == std::string_view::npos
|
||||
&& html.find("Warranty") == std::string_view::npos,
|
||||
"donation page: no spec or warranty section renders");
|
||||
Check(dp.meta.jsonLd.empty(),
|
||||
"donation page: no product JSON-LD is published");
|
||||
Check(!dp.meta.geoPriceHint,
|
||||
"donation page: no price-hint script — nothing to convert");
|
||||
}
|
||||
}
|
||||
Check(!Content::Projects().empty(), "content: projects present");
|
||||
Check(Content::LegalPages().size() == 3, "content: three legal pages");
|
||||
|
|
@ -205,11 +282,201 @@ void IdentityGraph() {
|
|||
"schema: founder and about name one Person node");
|
||||
}
|
||||
|
||||
// ── the sale gates, built by hand ─────────────────────────────────────
|
||||
// The shipped catalogue is one product in one status with three colours, so
|
||||
// asserting against it can only ever exercise one arm of each guard. These
|
||||
// products exist to reach the others.
|
||||
//
|
||||
// Buyable() is the ONLY server-side gate on POST /checkout, and it is a
|
||||
// conjunction: status AND a price. ComingSoon() is separately what decides
|
||||
// whether a visitor is told "the shop has not opened yet" or "temporarily
|
||||
// unavailable", so the two are pinned apart rather than as one either/or.
|
||||
void SaleGates() {
|
||||
Product priced;
|
||||
priced.status = "available";
|
||||
priced.priceInclMinor = 56330;
|
||||
Check(priced.Buyable() && !priced.ComingSoon(),
|
||||
"status: available with a price is the one buyable state");
|
||||
|
||||
// The half the catalogue can never exercise. An "available" product with
|
||||
// no price is what a from-price sync that failed to run leaves behind
|
||||
// (Content::Products derives priceInclMinor from CheapestVariant); if
|
||||
// this arm of the conjunction regressed, checkout would mint a real order
|
||||
// record and a live payment link for €0.
|
||||
Product unpriced;
|
||||
unpriced.status = "available";
|
||||
unpriced.priceInclMinor = 0;
|
||||
Check(!unpriced.Buyable(), "status: an unpriced product cannot be bought");
|
||||
|
||||
// The donation arm of the same conjunction: no price by definition, yet
|
||||
// buyable — and ONLY because the flag says the buyer names the amount.
|
||||
// The status half still gates it like anything else.
|
||||
Product gift;
|
||||
gift.status = "available";
|
||||
gift.donation = true;
|
||||
Check(gift.Buyable(), "status: an available donation needs no price");
|
||||
gift.status = "unavailable";
|
||||
Check(!gift.Buyable(), "status: a withdrawn donation is not buyable either");
|
||||
|
||||
Product withdrawn;
|
||||
withdrawn.status = "unavailable";
|
||||
withdrawn.priceInclMinor = 56330;
|
||||
Check(!withdrawn.Buyable() && !withdrawn.ComingSoon(),
|
||||
"status: unavailable is neither for sale nor coming soon");
|
||||
|
||||
Product soon;
|
||||
soon.status = "coming-soon";
|
||||
soon.priceInclMinor = 56330;
|
||||
Check(soon.ComingSoon() && !soon.Buyable(),
|
||||
"status: coming-soon publishes a price without opening orders");
|
||||
|
||||
// A product with no colours is a supported catalogue shape — priceInclMinor
|
||||
// is then simply the price. The null return is the only thing standing
|
||||
// between that shape and the two call sites that dereference the result
|
||||
// (the checkout form's default selection, and the checkout handler).
|
||||
Product plain;
|
||||
plain.priceInclMinor = 56330;
|
||||
Check(plain.CheapestVariant() == nullptr,
|
||||
"variants: no colours means no cheapest colour");
|
||||
Check(plain.FindVariant("green") == nullptr && plain.FindVariant("") == nullptr,
|
||||
"variants: nothing is ever found in an empty colour list");
|
||||
|
||||
// The loop compares with a strict <, so a tie keeps the listed order.
|
||||
// That is what stops the advertised "from" price and the form's default
|
||||
// selection from naming different colours when two cost the same.
|
||||
Product tied;
|
||||
tied.priceInclMinor = 56330;
|
||||
tied.variants = {
|
||||
{ "green", "Forest Green", 56330 },
|
||||
{ "black", "Black", 56330 },
|
||||
};
|
||||
Check(tied.CheapestVariant() && tied.CheapestVariant()->slug == "green",
|
||||
"variants: equally priced colours keep the listed order");
|
||||
}
|
||||
|
||||
// ── the product page's live-total blob ────────────────────────────────
|
||||
// RenderCheckoutForm renders only for a Buyable product, and the shipped
|
||||
// catalogue is coming-soon, so today nothing renders it: the two suites that
|
||||
// read this attribute both sit behind a ShopOpen() gate. Flip a copy to
|
||||
// "available" and read the markup here instead — the refusal lists in that
|
||||
// blob are what make the on-page total decline in exactly the places
|
||||
// checkout declines, and a page that quotes a total for a sanctioned or
|
||||
// no-sale destination invites an order that must then be refused.
|
||||
void CheckoutPreviewData() {
|
||||
if (Content::Products().empty()) return;
|
||||
Product pr = Content::Products()[0];
|
||||
pr.status = "available";
|
||||
Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders");
|
||||
|
||||
const std::vector<Money::ShipRates> feedTable{
|
||||
{ "NL", { { 2000, 895 } } },
|
||||
{ "DE", { { 2000, 995 } } },
|
||||
};
|
||||
const auto page = Views::RenderProduct(pr, Rates{}, feedTable, {}, {}, true);
|
||||
const std::string_view html = page.main.View();
|
||||
|
||||
// The blob is a JSON document inside an HTML attribute, so every quote
|
||||
// arrives escaped — matching the escaped form is matching what the
|
||||
// browser actually parses back out.
|
||||
Check(html.find(""x":["US","CA"]") != std::string_view::npos,
|
||||
"checkout: the preview carries the no-sale list verbatim");
|
||||
Check(html.find(""s":["RU","BY","KP"]")
|
||||
!= std::string_view::npos,
|
||||
"checkout: the preview carries the sanctions list verbatim");
|
||||
// The unit weight, which is what selects a bracket out of the carrier
|
||||
// table the same blob carries.
|
||||
Check(html.find(""g":700") != std::string_view::npos,
|
||||
"checkout: the preview knows one unit's shipping weight");
|
||||
// Per-colour unit prices: the preview multiplies these, so they are the
|
||||
// same integers the checkout charges or the two disagree on screen.
|
||||
Check(html.find(""green":57380") != std::string_view::npos
|
||||
&& html.find(""black":57980") != std::string_view::npos
|
||||
&& html.find(""white":66538") != std::string_view::npos,
|
||||
"checkout: every colour is priced in the preview blob");
|
||||
}
|
||||
|
||||
// ── which euro amount a localised price converts ──────────────────────
|
||||
// The headline price rides along as one pre-formatted attribute per
|
||||
// currency, and the basis differs by EU membership: a member's currency
|
||||
// converts the VAT-inclusive price the buyer pays, everyone else's converts
|
||||
// the ex-VAT export price. Invert that test and a Swedish visitor sees a
|
||||
// figure 21% below what their card is charged, or a British one 21% above
|
||||
// the export price — both while every existing assertion (which only checks
|
||||
// that the attributes exist) still passes.
|
||||
void LocalisedPriceBasis() {
|
||||
// €121.00 inclusive: net = 12100 * 10000 / 12100 = 10000 exactly, so the
|
||||
// two bases are a clean €121 and €100 with no rounding to reason about.
|
||||
Product pr;
|
||||
pr.slug = "basis-probe";
|
||||
pr.name = "Basis probe";
|
||||
pr.status = "coming-soon";
|
||||
pr.priceInclMinor = 12100;
|
||||
|
||||
// 1 EUR = 1 unit in both currencies, so the printed number can only
|
||||
// report which euro amount the conversion started from.
|
||||
Rates rates;
|
||||
rates.date = "2026-01-01";
|
||||
rates.microPerEur.emplace_back("SEK", 1'000'000);
|
||||
rates.microPerEur.emplace_back("GBP", 1'000'000);
|
||||
|
||||
const auto page = Views::RenderProduct(pr, rates);
|
||||
const std::string_view html = page.main.View();
|
||||
|
||||
// SE is an EU member: base 12100, converted whole and half-up ->
|
||||
// (12100 * 1e6 + 5e7) / 1e8 = 121.
|
||||
Check(html.find(R"(data-sek="~kr 121")") != std::string_view::npos,
|
||||
"price: an EU member's currency converts the VAT-inclusive price");
|
||||
// GB is not: base 10000 -> 100.
|
||||
Check(html.find(R"(data-gbp="~£100")") != std::string_view::npos,
|
||||
"price: a non-EU currency converts the ex-VAT export price");
|
||||
// The euro fallback that no-JS visitors and crawlers read is the export
|
||||
// price, in the same units the two above were derived from.
|
||||
Check(html.find(R"(data-world="€100")") != std::string_view::npos,
|
||||
"price: the export euro price is the world default");
|
||||
}
|
||||
|
||||
// ── the URLs this site advertises as its own ──────────────────────────
|
||||
// The sitemap is the list of URLs the site asks crawlers to index; the nav
|
||||
// is what a visitor clicks. Both are hand-maintained lists, so a renamed
|
||||
// legal slug or a retired path left behind advertises a 404 — or a 301 — as
|
||||
// canonical, silently and forever. Nothing else cross-checks them: the route
|
||||
// suite walks a literal list of its own rather than SitemapPaths().
|
||||
void AdvertisedUrls() {
|
||||
Views::SiteContent site;
|
||||
site.legal = Content::LegalPages();
|
||||
|
||||
for (const std::string_view path : SitemapPaths()) {
|
||||
const Route r = ParseRoute(path);
|
||||
Check(r.kind != RouteKind::NotFound,
|
||||
"sitemap: every advertised path resolves to a page", path);
|
||||
// A route carrying a canonical target is a redirect whatever it
|
||||
// renders, and asking a crawler to index a redirect is asking it to
|
||||
// index a non-canonical URL.
|
||||
Check(r.canonicalRedirect.empty(),
|
||||
"sitemap: no advertised path is itself a redirect", path);
|
||||
// Parsing only proves the URL is SHAPED like a legal page; the
|
||||
// dispatcher's lookup is what decides whether it renders one.
|
||||
if (r.kind == RouteKind::Legal) {
|
||||
Check(site.FindLegal(r.slug) != nullptr,
|
||||
"sitemap: every advertised legal slug names a real page", path);
|
||||
}
|
||||
}
|
||||
|
||||
for (const NavItem& item : NavItems()) {
|
||||
Check(ParseRoute(item.href).kind == item.kind,
|
||||
"nav: every nav entry parses to the route it claims", item.href);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
CatalogueContract();
|
||||
IdentityGraph();
|
||||
SaleGates();
|
||||
CheckoutPreviewData();
|
||||
LocalisedPriceBasis();
|
||||
AdvertisedUrls();
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
|
|
|
|||
Loading…
Reference in a new issue