This commit is contained in:
parent
320af54b3d
commit
4666c1995f
14 changed files with 876 additions and 346 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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><b></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);
|
||||
|
|
|
|||
|
|
@ -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(""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");
|
||||
// `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(""w":["NL","CH","AU","
|
||||
""HK","SG","RS","ME","
|
||||
""AL","XK","GE"]") != std::string_view::npos,
|
||||
"checkout: the preview carries the shipping allow-list verbatim");
|
||||
Check(html.find(""US"") == 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(""sm"") != std::string_view::npos
|
||||
&& html.find(""rm"") != 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(""g":700") != std::string_view::npos,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue