markdown fix
All checks were successful
Deploy / build-deploy (push) Successful in 2m23s

This commit is contained in:
Jorijn van der Graaf 2026-08-18 11:12:24 +02:00
commit 4666c1995f
14 changed files with 876 additions and 346 deletions

View file

@ -117,21 +117,66 @@ int main() {
// Zones still classify US and CA (the arithmetic is destination-blind, and
// keeping it that way means one policy switch, not two); the sale is what
// stops, in SellsTo.
// Two hard gates, and the shipping one is an ALLOW-list. The assertion that
// matters most is therefore the DEFAULT: a country nobody has cleared
// refuses. This is the test that fails if the gate is ever "simplified" back
// into a deny-list, which would silently reopen most of the world.
Check(SellsTo("NL") && SellsTo("CH") && SellsTo("AU")
&& SellsTo("HK") && SellsTo("SG")
&& SellsTo("RS") && SellsTo("ME") && SellsTo("AL") && SellsTo("XK")
&& SellsTo("GE"),
"policy: every cleared destination sells");
// Both were cleared once and refuted on verification. JP because using a
// non-giteki handset is a Radio Act offence for the BUYER; NZ because its
// radio regulator's supplier duties expressly reach a website seller and an
// overseas company cannot register to comply. Asserted by name so a future
// "these look fine, add them back" cannot pass silently.
Check(!SellsTo("JP") && !SellsTo("NZ"),
"policy: refuted destinations stay refuted");
Check(!SellsTo("DE") && !SellsTo("FR") && !SellsTo("BE"),
"policy: uncleared member states refuse by default");
Check(!SellsTo("KR") && !SellsTo("MX") && !SellsTo("ZA") && !SellsTo("XX"),
"policy: uncleared and unknown codes refuse by default");
Check(!SellsTo("NO") && !SellsTo("IS"),
"policy: the EEA inherits the EU's distance-seller duties, so it waits");
Check(!SellsTo("GB"), "policy: GB waits on its EA small-producer entries");
Check(!SellsTo("TR") && !SellsTo("IN") && !SellsTo("BR"),
"policy: IMEI and type-approval destinations refuse");
// North America is refused for regulatory reasons now, not insurance ones —
// it simply is not on the list, and there is no separate category for it.
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells");
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"),
"policy: the rest of the world still sells");
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
// Matching is on the normalised code, as everywhere else here — but note the
// direction the allow-list fails in. Under the old deny-list, "us" missed the
// blocked entry and SOLD; now an unnormalised code is simply absent from the
// list and refuses. Callers still uppercase first (ValidateCheckout does),
// but the consequence of forgetting is a lost sale rather than a shipment to
// a country the shop cannot serve.
Check(!SellsTo("us") && !SellsTo("nl"),
"policy: an unnormalised code fails closed, not open");
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
// Sanctioned destinations refuse through the same gate, but the two lists
// stay distinguishable — the checkout error names the law for one and the
// shop's own choice for the other.
Check(ShipsTo("NL") && !ShipsTo("US") && !ShipsTo("DE"),
"policy: ShipsTo is the allow-list on its own");
// Form::kShipsToMessage names these countries in prose for the buy page, and
// prose cannot be generated from ISO codes. If this fails because a country
// was opened, update that sentence too — the two must not drift.
Check(ShippableCountries().size() == 10,
"policy: opening a country means updating Form::kShipsToMessage as well");
// Sanctioned destinations refuse through the same gate, but stay
// distinguishable — the checkout error names the law for one and pending
// paperwork for the other, and conflating them would tell a Russian buyer
// to email and ask.
Check(!SellsTo("RU") && !SellsTo("BY") && !SellsTo("KP"),
"sanctions: RU, BY and KP refused");
Check(IsSanctioned("RU") && IsSanctioned("BY") && IsSanctioned("KP"),
"sanctions: the list knows its members");
Check(!IsSanctioned("US") && !IsSanctioned("NL"),
"sanctions: the insurance refusal is not a sanctions refusal");
Check(!IsSanctioned("US") && !IsSanctioned("DE") && !IsSanctioned("NL"),
"sanctions: an uncleared country is not a sanctioned one");
// Belt and braces: a sanctioned code must never reach the allow-list, or the
// wrong sentence would be shown for a criminal-law refusal.
for (const std::string_view cc : SanctionedCountries()) {
Check(!ShipsTo(cc), "sanctions: never on the shipping list", cc);
}
// ── carrier weight brackets ───────────────────────────────────────
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
@ -264,14 +309,20 @@ int main() {
// A country the shop refuses gets no localised price either — the two
// tables are kept consistent on purpose, so this is a real invariant and
// not a coincidence of the current list.
for (const std::string_view cc : NoSaleCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: refused destinations have no display currency", cc);
}
for (const std::string_view cc : SanctionedCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: sanctioned destinations have no display currency", cc);
}
Check(!CurrencyFor("US").has_value() && !CurrencyFor("CA").has_value(),
"fx: north america has no display currency either");
// The invariant covers only refusals nothing will lift. The table otherwise
// runs AHEAD of the shipping list on purpose — GB keeps its GBP row while its
// e-waste registrations are pending, because "what would this cost me" stays
// a fair question for a country one small registration from opening, and
// deleting the row to restore it weeks later would be churn. The refusal that
// must not be quoted around is enforced in SellsTo, not here.
Check(CurrencyFor("GB").has_value(),
"fx: a temporarily-closed destination keeps its display currency");
// ── rates loader ──────────────────────────────────────────────────
const Rates r = LoadRates(

View file

@ -107,9 +107,13 @@ int main(int argc, char** argv) {
srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}",
"product carries the hardware brand");
// One entry per (transit tier, price) the carrier table produces — three,
// for the fixture's NL/DE/GB. Not a fixed property of the code any more:
// it is whatever the carrier prices, which is the point.
// One entry per (transit tier, price) the carrier table produces, for the
// destinations the shop actually sells to — two, since the fixture prices
// NL/DE/GB/CH and checkout refuses DE and GB pending their producer
// registrations. Not a fixed property of the code: it is whatever the
// carrier prices INTERSECTED with where the shop sells, and publishing a
// rate to a country checkout would decline is an offer that cannot be
// accepted.
const Json::Value* shipping = nullptr;
if (group) {
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
@ -119,27 +123,33 @@ int main(int argc, char** argv) {
}
}
}
Check(shipping && shipping->IsArray() && shipping->array.size() == 3,
Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
"shipping details group the carrier's rates");
// The advertised rate IS the carrier's single-unit price, and a
// destination the table does not cover is never advertised.
// The advertised rate IS the carrier's single-unit price. Neither a
// destination the table does not cover (AU) nor one the policy refuses
// (DE, priced at €25 in the fixture) is ever advertised.
if (shipping && shipping->IsArray()) {
std::vector<std::string> rates;
bool au = false;
bool refused = false;
for (const Json::Value& detail : shipping->array) {
if (const Json::Value* rate = detail.Find("shippingRate")) {
rates.emplace_back(rate->Str("value"));
}
if (const Json::Value* dest = detail.Find("shippingDestination")) {
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
for (const Json::Value& c : cc->array) au = au || c.string == "AU";
for (const Json::Value& c : cc->array) {
au = au || c.string == "AU";
refused = refused || c.string == "DE" || c.string == "GB";
}
}
}
}
std::ranges::sort(rates);
Check(rates == std::vector<std::string>{ "15.00", "25.00", "55.00" },
Check(rates == std::vector<std::string>{ "15.00", "55.00" },
"published shipping rates come from the carrier table");
Check(!au, "an uncovered destination is not advertised");
Check(!refused, "a destination the policy refuses is not advertised");
}
if (srv.ShopOpen()) {

View file

@ -166,20 +166,21 @@ void OpenShopLifecycle(TestServer& srv) {
}
// A non-EU order: ex-VAT goods, world shipping, and the indicative
// national currency line sourced from the build-time ECB rates. GB rather
// than a North American destination because those are refused outright.
const std::string tokenGb = TokenOf(srv.Post("/shop/fp6-pmos",
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB"));
Check(!tokenGb.empty(), "GB checkout issues an order");
if (!tokenGb.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenGb));
// national currency line sourced from the build-time ECB rates. CH because
// it is the export destination that actually sells — North America is
// refused on insurance, and GB waits on its e-waste registrations.
const std::string tokenCh = TokenOf(srv.Post("/shop/fp6-pmos",
"email=ch%40example.org&name=Heidi&street=1%20Bahnhofstrasse&postal=8001&city=Zurich&country=CH"));
Check(!tokenCh.empty(), "CH checkout issues an order");
if (!tokenCh.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenCh));
// €474.21 goods (green net) + €55 world shipping = €529.21
Check(page.find("€529.21") != std::string::npos,
"export order total is ex-VAT + world shipping");
Check(page.find("Zero-rated export") != std::string::npos,
"export order states the VAT treatment");
Check(std::regex_search(page, std::regex("£[0-9]+")),
"export order shows the indicative GBP amount");
Check(std::regex_search(page, std::regex("CHF [0-9]+")),
"export order shows the indicative CHF amount");
Check(page.find("indicative") != std::string::npos,
"conversion is labelled indicative");
}
@ -187,7 +188,7 @@ void OpenShopLifecycle(TestServer& srv) {
// A two-unit white export order: unit €665.38, line €1330.76, net from the
// LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80.
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2"));
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&color=white&quantity=2"));
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
if (!tokenWhite.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
@ -357,15 +358,15 @@ void OpenShopLifecycle(TestServer& srv) {
// The NL order's message, found by its own order link (the same email
// address placed two orders, so the address alone would be ambiguous).
std::string nlMail;
std::string gbMail;
std::string chMail;
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
const std::string name = entry.path().filename().string();
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
const std::string mail = ReadFile(entry.path());
if (mail.find(std::format("/order/{}", token)) != std::string::npos) nlMail = mail;
if (!tokenGb.empty()
&& mail.find(std::format("/order/{}", tokenGb)) != std::string::npos) {
gbMail = mail;
if (!tokenCh.empty()
&& mail.find(std::format("/order/{}", tokenCh)) != std::string::npos) {
chMail = mail;
}
}
Check(!nlMail.empty(), "a confirmation email links the NL order");
@ -392,7 +393,7 @@ void OpenShopLifecycle(TestServer& srv) {
}
}
// The export order's message states the VAT treatment its invoice carries.
Check(!gbMail.empty() && gbMail.find("zero-rated export") != std::string::npos,
Check(!chMail.empty() && chMail.find("zero-rated export") != std::string::npos,
"export confirmation states the zero-rated treatment");
// Idempotency comes from the ledger's notified event, not from luck in
@ -467,12 +468,12 @@ void AlwaysOnValidation(TestServer& srv) {
"email=a%40b.example&country=NL"); // missing address
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot
// Destinations the shop refuses (Money::NoSaleCountries). Well-formed,
// real addresses: the refusal is policy, not a shape check, so it has to
// hold for every spelling the form accepts. Deliberately outside the
// shop-open gate — validation runs before the coming-soon check, so this
// must answer 422 whether the shop is open or not, and it is the
// assertion that would catch the block being lost in a refactor.
// Destinations the shop refuses (Money::SellsTo). Well-formed, real
// addresses: the refusal is policy, not a shape check, so it has to hold for
// every spelling the form accepts. Deliberately outside the shop-open gate —
// validation runs before the coming-soon check, so this must answer 422
// whether the shop is open or not, and it is the assertion that would catch
// the gate being lost in a refactor.
const std::size_t before = LedgerLines(srv).size();
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
@ -489,6 +490,31 @@ void AlwaysOnValidation(TestServer& srv) {
"email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru");
// Destinations off the shipping allow-list (Money::ShipsTo). DE and GB are
// the pointed cases: the fixture PRICES both, so a rate exists and the parcel
// is postable — the refusal is entirely the policy's, which is the whole
// reason to assert these rather than a country the carrier never covered.
//
// Status only, like every other refusal in this function. The exact sentence
// is asserted in ShouldValidateForms, where the validator is called directly:
// this function runs in the coming-soon state too, and a closed shop renders
// no order form for a field error to land in. What is worth proving over real
// HTTP is that the refusal holds at all, in both states — which is what 422
// says here.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=de%40example.org&name=Klaus&street=1%20Hauptstr&postal=10115&city=Berlin&country=DE");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=no%40example.org&name=Kari&street=1%20Karl%20Johans&postal=0154&city=Oslo&country=NO");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=tr%40example.org&name=Emre&street=1%20Istiklal&postal=34430&city=Istanbul&country=TR");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=br%40example.org&name=Ana&street=1%20Paulista&postal=01310&city=Sao%20Paulo&country=BR");
// And the default that makes an allow-list worth having: a well-formed code
// nobody ever considered is refused without appearing on any list.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=zz%40example.org&name=Sam&street=1%20Main&postal=0000&city=Nowhere&country=ZZ");
// Refused in validation means nothing reached the ledger and no payment
// link was ever created.
Check(LedgerLines(srv).size() == before, "a refused destination creates no order record");

View file

@ -95,10 +95,94 @@ int main() {
Check(md("```\nx").View().find("<code>x") != std::string_view::npos,
"md: unterminated fence still renders its content");
// ── tables ────────────────────────────────────────────────────────
CheckEq(md("| a | b |\n|---|---|\n| 1 | 2 |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>a</th><th>b</th></tr></thead>)"
R"(<tbody><tr><td>1</td><td>2</td></tr></tbody>)"
R"(</table></div>)",
"md: pipe table");
// The shape the carrier table in the camera post is actually written in:
// leading pipe, no trailing one. Requiring both would leave it as prose.
CheckEq(md("| Device | OS\n|---|---|\n| FP6 | pmOS |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>Device</th><th>OS</th></tr></thead>)"
R"(<tbody><tr><td>FP6</td><td>pmOS</td></tr></tbody>)"
R"(</table></div>)",
"md: outer pipes are optional independently");
// The whole reason recognition needs two lines. These posts paste pipelines
// and or-lists into prose constantly; none of them is a table.
CheckEq(md("dmesg | grep ufs"), "<p>dmesg | grep ufs</p>",
"md: a pipe in prose is not a table");
// A mismatched column count is far more likely to be prose with pipes in it
// than a table whose author miscounted — so it stays prose, visibly odd.
Check(md("| a | b |\n|---|").View().starts_with("<p>"),
"md: delimiter row must agree about the column count",
md("| a | b |\n|---|").View());
// `---` is a rule, as it is everywhere else in this file. A one-column
// table has to write the pipe.
CheckEq(md("| Device\n---"), "<p>| Device</p><hr>",
"md: a bare dash row is still a thematic break");
CheckEq(md("| a | b | c |\n|:---|:---:|---:|\n| 1 | 2 | 3 |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>a</th>)"
R"(<th class="post-body__cell--center">b</th>)"
R"(<th class="post-body__cell--right">c</th></tr></thead>)"
R"(<tbody><tr><td>1</td>)"
R"(<td class="post-body__cell--center">2</td>)"
R"(<td class="post-body__cell--right">3</td></tr></tbody>)"
R"(</table></div>)",
"md: alignment row moves the column, left needs no class");
// A short row is padded so the grid stays rectangular.
Check(md("| a | b |\n|---|---|\n| 1 |").View().find("<td>1</td><td></td>")
!= std::string_view::npos,
"md: a short row is padded to the header width",
md("| a | b |\n|---|---|\n| 1 |").View());
// Cells are prose, so everything inline works in them — and everything
// inline is still escaped in them.
Check(md("| [x](https://e.example) | `<b>` |\n|---|---|").View().find(
R"(<th><a href="https://e.example" rel="noopener">x</a></th>)"
R"(<th><code>&lt;b&gt;</code></th>)") != std::string_view::npos,
"md: cells render inline markup and stay escaped",
md("| [x](https://e.example) | `<b>` |\n|---|---|").View());
// `\|` is how a cell contains a pipe.
Check(md("| a \\| b | c |\n|---|---|").View().find("<th>a | b</th><th>c</th>")
!= std::string_view::npos,
"md: an escaped pipe is cell content, not a cell boundary",
md("| a \\| b | c |\n|---|---|").View());
// The GPU comparison in one of these posts is a table inside a quote.
Check(md("> | a | b |\n> |---|---|\n> | 1 | 2 |").View().starts_with(
R"(<blockquote class="post-body__quote"><div class="post-body__table">)"),
"md: a table inside a blockquote is still a table",
md("> | a | b |\n> |---|---|\n> | 1 | 2 |").View());
// A table under a prose line with no blank line between is still a table:
// absorbing it into the paragraph is the exact failure this all fixes.
Check(md("text\n| a | b |\n|---|---|").View().starts_with("<p>text</p><div"),
"md: a table interrupts a paragraph",
md("text\n| a | b |\n|---|---|").View());
// Prose resumes after the table rather than being eaten as a one-cell row.
Check(md("| a | b |\n|---|---|\n| 1 | 2 |\nafter").View().ends_with("<p>after</p>"),
"md: a line with no pipe ends the table",
md("| a | b |\n|---|---|\n| 1 | 2 |\nafter").View());
// A list marker wins over a table: a line that opens one is a list item.
Check(md("- a | b\n|---|---|").View().starts_with("<ul"),
"md: a list item is not a table header",
md("- a | b\n|---|---|").View());
// ── inline ────────────────────────────────────────────────────────
CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong");
CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis");
CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal");
// <s>, not GFM's <del>: nothing was removed from this document, and the one
// body that uses this is striking a joke through for effect.
CheckEq(md("~~struck~~"), "<p><s>struck</s></p>", "md: strikethrough");
CheckEq(md("~~**both**~~"), "<p><s><strong>both</strong></s></p>",
"md: strikethrough nests");
// A lone tilde is a home directory or an approximation, never a delimiter.
CheckEq(md("~/.local and ~5 minutes"), "<p>~/.local and ~5 minutes</p>",
"md: a single tilde strikes nothing");
CheckEq(md("a ~~ b ~~ c"), "<p>a ~~ b ~~ c</p>",
"md: spaced tildes stay literal");
// Underscores are deliberately inert: these posts paste kernel symbol
// names into prose, and italicising half of one is worse than not
// italicising a word that used the underscore form.
@ -172,7 +256,11 @@ int main() {
Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates");
Check(!md("![](").View().empty(), "md: unclosed image terminates");
Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates");
Check(!md("~~unclosed").View().empty(), "md: unclosed strikethrough terminates");
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
Check(!md("|||\n|||").View().empty(), "md: degenerate table terminates");
Check(!md("|---|---|").View().empty(), "md: a lone delimiter row terminates");
Check(!md("| a |\n| - |\n|").View().empty(), "md: a ragged table terminates");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);

View file

@ -100,13 +100,16 @@ void CatalogueContract() {
// checkout charges.
{
// The listing's shipping block is now carrier data, so the
// render needs a table. US is priced here on purpose: the
// carrier will happily quote it and the shop still must not
// advertise it.
// render needs a table. Three of these four are priced on purpose
// and must still not be advertised: US is refused on insurance, DE
// and GB on their missing producer registrations. The carrier will
// happily quote all three, which is exactly why the filter is worth
// asserting — CH is the only one here besides home that sells.
const std::vector<ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "CH", { { 2000, 2450 } } },
{ "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 2450 } } },
{ "GB", { { 2000, 3300 } } },
{ "US", { { 2000, 1794 } } },
};
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
@ -137,6 +140,13 @@ void CatalogueContract() {
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
"schema: shipping rates come from the carrier table");
// And nothing the shop refuses is advertised, whatever the carrier
// quotes for it — by rate, so a filter that dropped the country code
// but kept the price would still be caught.
Check(pp.meta.jsonLd.find("\"9.95\"") == std::string::npos
&& pp.meta.jsonLd.find("\"33.00\"") == std::string::npos
&& pp.meta.jsonLd.find("\"17.94\"") == std::string::npos,
"schema: refused destinations are never advertised");
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
"schema: a refused destination is never advertised, priced or not");
@ -378,11 +388,23 @@ void CheckoutPreviewData() {
// 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("&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]") != std::string_view::npos,
"checkout: the preview carries the no-sale list verbatim");
Check(html.find("&quot;s&quot;:[&quot;RU&quot;,&quot;BY&quot;,&quot;KP&quot;]")
!= std::string_view::npos,
"checkout: the preview carries the sanctions list verbatim");
// `w` is the shipping ALLOW-list: the preview refuses a country by its
// ABSENCE here, which is why the payload stays five codes long instead of
// enumerating the two hundred that are closed.
Check(html.find("&quot;w&quot;:[&quot;NL&quot;,&quot;CH&quot;,&quot;AU&quot;,"
"&quot;HK&quot;,&quot;SG&quot;,&quot;RS&quot;,&quot;ME&quot;,"
"&quot;AL&quot;,&quot;XK&quot;,&quot;GE&quot;]") != std::string_view::npos,
"checkout: the preview carries the shipping allow-list verbatim");
Check(html.find("&quot;US&quot;") == std::string_view::npos,
"checkout: no deny-list survives in the preview payload");
// Both refusals ship their own sentence, or the preview would word a decline
// differently from the submit that follows it.
Check(html.find("&quot;sm&quot;") != std::string_view::npos
&& html.find("&quot;rm&quot;") != std::string_view::npos,
"checkout: the preview carries a message for each refusal");
// The unit weight, which is what selects a bracket out of the carrier
// table the same blob carries.
Check(html.find("&quot;g&quot;:700") != std::string_view::npos,

View file

@ -253,19 +253,43 @@ int main() {
return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country="
+ std::string(cc));
};
Check(withCountry("NL").Ok(), "checkout: the home market sells");
Check(withCountry("CH").Ok() && withCountry("AU").Ok()
&& withCountry("HK").Ok() && withCountry("SG").Ok()
&& withCountry("RS").Ok() && withCountry("ME").Ok()
&& withCountry("AL").Ok() && withCountry("XK").Ok()
&& withCountry("GE").Ok(),
"checkout: every cleared destination sells");
Check(!withCountry("JP").Ok() && !withCountry("NZ").Ok(),
"checkout: destinations refuted on verification stay refused");
// The default is the point of an allow-list: uncleared, unknown and
// never-considered codes all refuse without anyone listing them.
Check(!withCountry("US").Ok(), "checkout: US refused");
Check(!withCountry("CA").Ok(), "checkout: CA refused");
Check(!withCountry("us").Ok(), "checkout: lowercase US refused too");
Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell");
Check(withCountry("NL").Ok(), "checkout: EU unaffected");
Check(!withCountry("DE").Ok() && !withCountry("GB").Ok()
&& !withCountry("NO").Ok() && !withCountry("TR").Ok(),
"checkout: uncleared destinations refuse");
Check(!withCountry("XX").Ok(),
"checkout: a syntactically valid code nobody has cleared refuses");
{
auto us = withCountry("US");
Check(us.errors.size() == 1 && us.errors[0].field == "country",
"checkout: refusal is a country error, nothing else");
Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage,
"checkout: refusal says where the shop does not sell");
Check(us.errors[0].message == Catcrafts::Form::kRegulatoryMessage,
"checkout: refusal gives the regulatory reason");
Check(us.value.country == "US", "checkout: refused country echoed back");
}
{
// One sentence for every non-sanctions refusal, whatever the underlying
// reason was — the buyer cannot act on the difference.
auto de = withCountry("DE");
auto tr = withCountry("TR");
Check(de.errors.size() == 1 && tr.errors.size() == 1
&& de.errors[0].message == Catcrafts::Form::kRegulatoryMessage
&& tr.errors[0].message == Catcrafts::Form::kRegulatoryMessage,
"checkout: every uncleared destination gets the same sentence");
}
// Sanctioned destinations: same gate, different sentence. The message has
// to name the law rather than shop policy — a buyer told "Catcrafts does
@ -284,6 +308,11 @@ int main() {
Check(ru.value.country == "RU", "checkout: sanctioned country echoed back");
}
// Sanctions keep their own sentence: a buyer told the generic "email us and
// ask" would waste their time on something no amount of paperwork can lift.
Check(withCountry("RU").errors[0].message != Catcrafts::Form::kRegulatoryMessage,
"checkout: sanctions do not collapse into the regulatory sentence");
// Parameter pollution against the order gate. Every refusal above reads its
// field through Fields::Get, which takes the FIRST of a repeated pair, so
// appending a second value cannot reopen a destination the first one closed

View file

@ -84,6 +84,12 @@ inline std::size_t CountOccurrences(std::string_view haystack, std::string_view
// The single-unit rates are the €15 / €25 / €55 the totals assert. The second
// band exists so the too-heavy refusal has a real ceiling to hit:
// 10 kg / 700 g per unit = 14 units per parcel.
//
// CH carries the €55 world rate because it is the export destination the shop
// actually sells to; DE and GB keep their rows even though checkout now refuses
// both (unregistered producer schemes) precisely BECAUSE it refuses them — a
// destination the carrier prices and the policy declines is the case worth
// having a fixture for, and the suites assert that the policy wins.
inline constexpr std::string_view kShippingFixture =
R"({"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{)"
"\n"
@ -91,7 +97,9 @@ inline constexpr std::string_view kShippingFixture =
"\n"
R"("DE":[[2000,2500],[10000,4200]],)"
"\n"
R"("GB":[[2000,5500],[10000,7900]]}})"
R"("GB":[[2000,5500],[10000,7900]],)"
"\n"
R"("CH":[[2000,5500],[10000,7900]]}})"
"\n";
struct ServerOptions {