This commit is contained in:
parent
eb655b24b6
commit
25805a093a
20 changed files with 788 additions and 805 deletions
|
|
@ -13,12 +13,15 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// payment links and reports "paid" once the marker file exists — which is how
|
||||
// this suite simulates the customer paying.
|
||||
//
|
||||
// The lifecycle half runs only while the shop is OPEN; while coming-soon it
|
||||
// asserts the closed behaviour instead. Launch day (status flip to
|
||||
// "available" in Catcrafts.Shared-Content.cppm) re-arms the full suite with
|
||||
// no edit here. The validation half runs in both states on purpose:
|
||||
// validation happens before the coming-soon check, and that ordering is
|
||||
// exactly what it pins.
|
||||
// The goods half addresses whatever priced product the compiled catalogue
|
||||
// lists first — slug, default colour, boxed weight — and derives every expected
|
||||
// figure from that record and the harness's carrier table with the till's own
|
||||
// Money arithmetic. It runs only while that product is OPEN; while coming-soon
|
||||
// it asserts the closed behaviour instead, and while the catalogue lists no
|
||||
// goods at all (as since 2026-09-05) it prints a note and is skipped — listing
|
||||
// one re-arms it with no edit here. The validation half runs in both shop
|
||||
// states on purpose: validation happens before the coming-soon check, and that
|
||||
// ordering is exactly what it pins. The donation lifecycle runs regardless.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
|
|
@ -81,21 +84,46 @@ bool GpgVerifies(const std::filesystem::path& file) {
|
|||
file.string()).c_str()) == 0;
|
||||
}
|
||||
|
||||
void OpenShopLifecycle(TestServer& srv) {
|
||||
// The carrier table the harness hands the server (kShippingFixture), as the
|
||||
// ladders the totals below are derived from: one parcel under 2 kg ships for
|
||||
// €15 at home and €55 to Switzerland, and the 10 kg band is what caps a parcel.
|
||||
const std::vector<Money::ShipBracket> kNl{ { 2000, 1500 }, { 10000, 2900 } };
|
||||
const std::vector<Money::ShipBracket> kCh{ { 2000, 5500 }, { 10000, 7900 } };
|
||||
|
||||
// What the till charges for `quantity` units at `unit` cents to `cc`: the
|
||||
// parcel weighs quantity × boxed weight, the carrier prices that parcel, and
|
||||
// ComputeTotals nets the LINE for an export. The same three calls the
|
||||
// checkout handler makes, so a figure asserted here is one the shop derives,
|
||||
// not one anybody typed.
|
||||
std::int64_t Total(const Product& pr, std::int64_t unit, std::int64_t quantity,
|
||||
std::span<const Money::ShipBracket> ladder, std::string_view cc) {
|
||||
const std::int64_t shipping = Money::RateFor(ladder, pr.shipWeightGrams * quantity);
|
||||
return Money::ComputeTotals(unit, quantity, shipping, cc).total;
|
||||
}
|
||||
|
||||
void OpenShopLifecycle(TestServer& srv, const Product& pr) {
|
||||
const std::string shop = "/shop/" + pr.slug;
|
||||
const std::int64_t unit = pr.priceInclMinor; // the default (cheapest) colour
|
||||
const std::int64_t nlTotal = Total(pr, unit, 1, kNl, "NL");
|
||||
const std::string nlEuro = Money::FormatEuro(nlTotal);
|
||||
// The one-parcel ceiling: the heaviest band any destination offers (10 kg
|
||||
// in the fixture) divided by the boxed unit weight.
|
||||
const std::int64_t maxUnits = Money::MaxUnitsFor(kNl, pr.shipWeightGrams);
|
||||
|
||||
// ── checkout ──────────────────────────────────────────────────────
|
||||
// A valid submission answers 303 straight to the PAYMENT page — no
|
||||
// interim stop. The fake rail's payUrl is the order page itself, so the
|
||||
// token is still extractable from the Location and the browser flow works
|
||||
// in dev.
|
||||
const auto checkout = srv.Post("/shop/fp6-pmos", Good());
|
||||
const auto checkout = srv.Post(shop, Good());
|
||||
const std::string token = TokenOf(checkout);
|
||||
Check(checkout.status == "303" && !token.empty(),
|
||||
"POST checkout -> 303 straight to payment", checkout.status);
|
||||
{
|
||||
const std::string ledger = srv.OrdersText();
|
||||
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
|
||||
&& ledger.find("\"total_minor\":58880") != std::string::npos,
|
||||
"order stored: NL total is €588.80 (green €573.80 + €15 shipping)");
|
||||
&& ledger.find(std::format("\"total_minor\":{}", nlTotal)) != std::string::npos,
|
||||
"order stored: NL total is the default colour plus €15 home shipping");
|
||||
// No `pay` field in that submission, which is what a form with only
|
||||
// one rail configured posts: it must land on the bank rail rather
|
||||
// than nothing.
|
||||
|
|
@ -108,8 +136,9 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
const std::string orderPath = std::format("/order/{}", token);
|
||||
{
|
||||
const std::string page = srv.Body(orderPath);
|
||||
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
|
||||
"http-equiv=\"refresh\"", "€588.80" }) {
|
||||
for (std::string_view probe : std::initializer_list<std::string_view>{
|
||||
"awaiting payment", "Resume payment", "CC-",
|
||||
"http-equiv=\"refresh\"", nlEuro }) {
|
||||
Check(page.find(probe) != std::string::npos,
|
||||
std::format("order page has {}", probe));
|
||||
}
|
||||
|
|
@ -128,11 +157,11 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
// assertion that matters: it is what the reconciler later reads to decide
|
||||
// WHICH provider may confirm the order, so a choice that renders but is
|
||||
// not stored would mean crypto orders being asked about at the bank.
|
||||
srv.BodyHas("/shop/fp6-pmos", "name=\"pay\"", "the form offers a payment choice");
|
||||
srv.BodyHas("/shop/fp6-pmos", "value=\"crypto\"", "crypto is one of the choices");
|
||||
srv.BodyHas("/shop/fp6-pmos", "value=\"bank\" checked", "bank is the pre-selected choice");
|
||||
srv.BodyHas(shop, "name=\"pay\"", "the form offers a payment choice");
|
||||
srv.BodyHas(shop, "value=\"crypto\"", "crypto is one of the choices");
|
||||
srv.BodyHas(shop, "value=\"bank\" checked", "bank is the pre-selected choice");
|
||||
|
||||
const std::string tokenCrypto = TokenOf(srv.Post("/shop/fp6-pmos", Good("&pay=crypto")));
|
||||
const std::string tokenCrypto = TokenOf(srv.Post(shop, Good("&pay=crypto")));
|
||||
Check(!tokenCrypto.empty(), "a crypto order goes through");
|
||||
if (!tokenCrypto.empty()) {
|
||||
bool recorded = false;
|
||||
|
|
@ -161,7 +190,7 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
// so the form comes back with the choice highlighted rather than a bare
|
||||
// 400.
|
||||
{
|
||||
const auto bogus = srv.Post("/shop/fp6-pmos", Good("&pay=invoice-me-later"));
|
||||
const auto bogus = srv.Post(shop, Good("&pay=invoice-me-later"));
|
||||
Check(bogus.status == "422", "an unknown payment method is refused", bogus.status);
|
||||
Check(bogus.body.find("Pick one of the payment methods") != std::string::npos,
|
||||
"the refusal names the payment field");
|
||||
|
|
@ -171,13 +200,13 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
// national currency line sourced from the build-time ECB rates. CH because
|
||||
// it is the export destination that actually sells — North America is
|
||||
// refused on insurance, and GB waits on its e-waste registrations.
|
||||
const std::string tokenCh = TokenOf(srv.Post("/shop/fp6-pmos",
|
||||
const std::string tokenCh = TokenOf(srv.Post(shop,
|
||||
"email=ch%40example.org&name=Heidi&street=1%20Bahnhofstrasse&postal=8001&city=Zurich&country=CH"));
|
||||
Check(!tokenCh.empty(), "CH checkout issues an order");
|
||||
if (!tokenCh.empty()) {
|
||||
const std::string page = srv.Body(std::format("/order/{}", tokenCh));
|
||||
// €474.21 goods (green net) + €55 world shipping = €529.21
|
||||
Check(page.find("€529.21") != std::string::npos,
|
||||
// Net of the default colour + €55 world shipping.
|
||||
Check(page.find(Money::FormatEuro(Total(pr, unit, 1, kCh, "CH"))) != std::string::npos,
|
||||
"export order total is ex-VAT + world shipping");
|
||||
Check(page.find("Zero-rated export") != std::string::npos,
|
||||
"export order states the VAT treatment");
|
||||
|
|
@ -187,42 +216,55 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
"conversion is labelled indicative");
|
||||
}
|
||||
|
||||
// A two-unit white export order: unit €665.38, line €1330.76, net from the
|
||||
// LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80.
|
||||
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
|
||||
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&color=white&quantity=2"));
|
||||
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
|
||||
if (!tokenWhite.empty()) {
|
||||
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
|
||||
Check(page.find("€1154.80") != std::string::npos,
|
||||
"white ×2 export total nets the line, not the unit");
|
||||
// A two-unit export order in the dearest colour (or the only price, for a
|
||||
// colourless listing): the export net comes from the LINE total (unit × 2),
|
||||
// not per unit, plus one parcel's world shipping at twice the weight.
|
||||
const Variant* dear = nullptr;
|
||||
for (const Variant& v : pr.variants) {
|
||||
if (!dear || v.priceInclMinor > dear->priceInclMinor) dear = &v;
|
||||
}
|
||||
const std::int64_t dearUnit = dear ? dear->priceInclMinor : unit;
|
||||
const std::string tokenTwo = TokenOf(srv.Post(shop,
|
||||
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&quantity=2"
|
||||
+ (dear ? "&color=" + dear->slug : std::string{})));
|
||||
Check(!tokenTwo.empty(), "a two-unit checkout issues an order");
|
||||
if (!tokenTwo.empty()) {
|
||||
const std::string page = srv.Body(std::format("/order/{}", tokenTwo));
|
||||
Check(page.find(Money::FormatEuro(Total(pr, dearUnit, 2, kCh, "CH"))) != std::string::npos,
|
||||
"two-unit export total nets the line, not the unit");
|
||||
Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity");
|
||||
Check(page.find("White") != std::string::npos, "order page names the colour");
|
||||
if (dear) {
|
||||
Check(page.find(dear->label) != std::string::npos, "order page names the colour");
|
||||
}
|
||||
}
|
||||
|
||||
// A colour we never listed must not buy anything, whatever the form claims.
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&color=mauve"));
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=100"));
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=0"));
|
||||
if (!pr.variants.empty()) {
|
||||
srv.CheckStatus(shop, "422", "POST", Good("&color=no-such-colour"));
|
||||
}
|
||||
srv.CheckStatus(shop, "422", "POST", Good("&quantity=100"));
|
||||
srv.CheckStatus(shop, "422", "POST", Good("&quantity=0"));
|
||||
// Quantity is a free input with a technical ceiling, not a dropdown — a
|
||||
// nine-unit order is business, not fraud.
|
||||
Check(!TokenOf(srv.Post("/shop/fp6-pmos", Good("&quantity=9"))).empty(),
|
||||
"a nine-unit order goes through");
|
||||
srv.BodyHas("/shop/fp6-pmos", "type=\"number\"", "quantity is a number input, not a dropdown");
|
||||
// multi-unit order within the parcel is business, not fraud.
|
||||
const std::int64_t several = std::min<std::int64_t>(9, maxUnits);
|
||||
Check(several > 1 && !TokenOf(srv.Post(shop, Good(std::format("&quantity={}", several)))).empty(),
|
||||
std::format("a {}-unit order goes through", several));
|
||||
srv.BodyHas(shop, "type=\"number\"", "quantity is a number input, not a dropdown");
|
||||
// The ceiling is physical: the heaviest band any destination offers
|
||||
// (10 kg in the fixture) divided by the boxed unit weight (700 g) = 14.
|
||||
// The input advertises the BEST case across destinations; the per-country
|
||||
// limit is enforced on submit, below.
|
||||
srv.BodyHas("/shop/fp6-pmos", "max=\"14\"", "quantity input carries the one-parcel ceiling");
|
||||
// (10 kg in the fixture) divided by the boxed unit weight. The input
|
||||
// advertises the BEST case across destinations; the per-country limit is
|
||||
// enforced on submit, below.
|
||||
srv.BodyHas(shop, std::format("max=\"{}\"", maxUnits),
|
||||
"quantity input carries the one-parcel ceiling");
|
||||
|
||||
// One order is one parcel. Fifteen units is 10.5 kg, past every band the
|
||||
// fixture has, so it must be refused rather than quoted a rate the
|
||||
// One order is one parcel. One unit past the ceiling is past every band
|
||||
// the fixture has, so it must be refused rather than quoted a rate the
|
||||
// carrier would not accept — and the refusal has to say what WOULD fit,
|
||||
// or the buyer is left guessing.
|
||||
{
|
||||
const auto heavy = srv.Post("/shop/fp6-pmos", Good("&quantity=15"));
|
||||
const auto heavy = srv.Post(shop, Good(std::format("&quantity={}", maxUnits + 1)));
|
||||
Check(heavy.status == "422", "an over-weight order is refused", heavy.status);
|
||||
Check(heavy.body.find("up to 14 per order") != std::string::npos,
|
||||
Check(heavy.body.find(std::format("up to {} per order", maxUnits)) != std::string::npos,
|
||||
"the too-heavy refusal says what fits");
|
||||
Check(heavy.body.find("orders@catcrafts.net") != std::string::npos,
|
||||
"the too-heavy refusal offers a way to order anyway");
|
||||
|
|
@ -235,17 +277,17 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
constexpr std::string_view kAu =
|
||||
"email=au%40example.org&name=Alex&street=1%20George%20St&postal=2000&city=Sydney&country=AU";
|
||||
{
|
||||
const auto au = srv.Post("/shop/fp6-pmos", std::string(kAu));
|
||||
const auto au = srv.Post(shop, std::string(kAu));
|
||||
Check(au.status == "422", "an uncovered destination is refused", au.status);
|
||||
Check(au.body.find("No carrier rate for AU") != std::string::npos,
|
||||
"an uncovered destination is refused, naming the country");
|
||||
// The country FIELD ERROR must be the carrier message, not the
|
||||
// no-sale one. Matched on the error markup rather than the bare
|
||||
// sentence: the no-sale line is standing copy above every buy form.
|
||||
Check(au.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
|
||||
Check(au.body.find("field__error\">Catcrafts can't ship there") == std::string::npos,
|
||||
"an uncovered destination is not confused with a refused one");
|
||||
const std::size_t before = LedgerLines(srv).size();
|
||||
srv.Post("/shop/fp6-pmos", std::string(kAu));
|
||||
srv.Post(shop, std::string(kAu));
|
||||
Check(LedgerLines(srv).size() == before, "a refused destination writes no order");
|
||||
}
|
||||
|
||||
|
|
@ -286,10 +328,11 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
// registered identity, amounts — and a signature that verifies offline.
|
||||
{
|
||||
const auto invoice = srv.Get(std::format("/order/{}/invoice.md", token));
|
||||
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
|
||||
"Customer number: ", "Chico Mendesring 256",
|
||||
"KVK 78437059", "NL003329281B38", "CC-",
|
||||
"VAT 21% (NL)", "€588.80" }) {
|
||||
for (std::string_view probe : std::initializer_list<std::string_view>{
|
||||
"BEGIN PGP SIGNED MESSAGE", "# Invoice ",
|
||||
"Customer number: ", "Chico Mendesring 256",
|
||||
"KVK 78437059", "NL003329281B38", "CC-",
|
||||
"VAT 21% (NL)", nlEuro }) {
|
||||
Check(invoice.body.find(probe) != std::string::npos,
|
||||
std::format("invoice has {}", probe));
|
||||
}
|
||||
|
|
@ -373,11 +416,12 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
}
|
||||
Check(!nlMail.empty(), "a confirmation email links the NL order");
|
||||
if (!nlMail.empty()) {
|
||||
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
|
||||
"From: Catcrafts <info@catcrafts.net>",
|
||||
"MIME-Version: 1.0", "€588.80", "incl. 21% NL VAT",
|
||||
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
|
||||
"filename=\"catcrafts-invoice-" }) {
|
||||
for (std::string_view probe : std::initializer_list<std::string_view>{
|
||||
"To: e2e@example.org", "Subject: Catcrafts order CC-",
|
||||
"From: Catcrafts <info@catcrafts.net>",
|
||||
"MIME-Version: 1.0", nlEuro, "incl. 21% NL VAT",
|
||||
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
|
||||
"filename=\"catcrafts-invoice-" }) {
|
||||
Check(nlMail.find(probe) != std::string::npos,
|
||||
std::format("email has {}", probe));
|
||||
}
|
||||
|
|
@ -451,24 +495,26 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
}
|
||||
}
|
||||
|
||||
void ComingSoon(TestServer& srv) {
|
||||
void ComingSoon(TestServer& srv, const Product& pr) {
|
||||
const std::string shop = "/shop/" + pr.slug;
|
||||
// A perfectly valid order must be refused while the shop is closed: after
|
||||
// validation (so the field checks below still exercise the parser) and
|
||||
// before any rail or ledger is touched.
|
||||
srv.CheckStatus("/shop/fp6-pmos", "409", "POST", Good());
|
||||
srv.CheckStatus(shop, "409", "POST", Good());
|
||||
Check(srv.OrdersText().empty(), "refused order writes nothing to the ledger");
|
||||
std::println("shop is coming-soon; the checkout, order-lifecycle and invoice "
|
||||
"checks re-arm when the status flips to available");
|
||||
}
|
||||
|
||||
void AlwaysOnValidation(TestServer& srv) {
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
void AlwaysOnValidation(TestServer& srv, const Product& pr) {
|
||||
const std::string shop = "/shop/" + pr.slug;
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"name=Ada&street=x&postal=1&city=y&country=NL"); // no email
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=nonsense&" + Good()); // bad email (dup keeps first)
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=a%40b.example&country=NL"); // missing address
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot
|
||||
srv.CheckStatus(shop, "422", "POST", Good("&website=spam")); // honeypot
|
||||
|
||||
// Destinations the shop refuses (Money::SellsTo). Well-formed, real
|
||||
// addresses: the refusal is policy, not a shape check, so it has to hold for
|
||||
|
|
@ -477,20 +523,20 @@ void AlwaysOnValidation(TestServer& srv) {
|
|||
// whether the shop is open or not, and it is the assertion that would catch
|
||||
// the gate being lost in a refactor.
|
||||
const std::size_t before = LedgerLines(srv).size();
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us");
|
||||
// Sanctioned destinations (Money::SanctionedCountries) refuse through the
|
||||
// same always-on gate — this refusal is the law, so of all the checks in
|
||||
// this file it is the one that must survive every refactor.
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru");
|
||||
// Destinations off the shipping allow-list (Money::ShipsTo). DE and GB are
|
||||
// the pointed cases: the fixture PRICES both, so a rate exists and the parcel
|
||||
|
|
@ -503,19 +549,19 @@ void AlwaysOnValidation(TestServer& srv) {
|
|||
// no order form for a field error to land in. What is worth proving over real
|
||||
// HTTP is that the refusal holds at all, in both states — which is what 422
|
||||
// says here.
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=de%40example.org&name=Klaus&street=1%20Hauptstr&postal=10115&city=Berlin&country=DE");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=no%40example.org&name=Kari&street=1%20Karl%20Johans&postal=0154&city=Oslo&country=NO");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=tr%40example.org&name=Emre&street=1%20Istiklal&postal=34430&city=Istanbul&country=TR");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=br%40example.org&name=Ana&street=1%20Paulista&postal=01310&city=Sao%20Paulo&country=BR");
|
||||
// And the default that makes an allow-list worth having: a well-formed code
|
||||
// nobody ever considered is refused without appearing on any list.
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
srv.CheckStatus(shop, "422", "POST",
|
||||
"email=zz%40example.org&name=Sam&street=1%20Main&postal=0000&city=Nowhere&country=ZZ");
|
||||
// Refused in validation means nothing reached the ledger and no payment
|
||||
// link was ever created.
|
||||
|
|
@ -524,7 +570,7 @@ void AlwaysOnValidation(TestServer& srv) {
|
|||
}
|
||||
|
||||
// The donation item is open in BOTH shop states — it is the soft opening the
|
||||
// coming-soon phone waits behind — so this whole lifecycle runs
|
||||
// goods wait behind, listed or not — so this whole lifecycle runs
|
||||
// unconditionally: create with a buyer-named amount, settle on the fake rail,
|
||||
// confirm there is no invoice and no VAT, and watch /financials book it under
|
||||
// donations rather than sales.
|
||||
|
|
@ -554,7 +600,16 @@ void DonationLifecycle(TestServer& srv) {
|
|||
// live EURC payment proved that a buyer staring at "awaiting payment"
|
||||
// after their wallet said done is a support email in the making. The
|
||||
// fake rail's "<marker>.seen" file is that state's test handle.
|
||||
//
|
||||
// The rail must be UNPAID first. The open-shop half (when it runs) leaves
|
||||
// the paid marker behind, and a marker already present pays this donation
|
||||
// the moment it arrives — the seen state could then never show. Every
|
||||
// order placed before this point has already settled, so nothing else
|
||||
// notices the marker going.
|
||||
{
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(std::filesystem::path(
|
||||
srv.Orders().string() + ".fake-paid"), ec);
|
||||
const std::string tokenSeen =
|
||||
TokenOf(srv.Post("/shop/donation", "amount=2&pay=crypto"));
|
||||
Check(!tokenSeen.empty(), "a crypto donation goes through");
|
||||
|
|
@ -641,7 +696,7 @@ void DonationLifecycle(TestServer& srv) {
|
|||
"a donation is not worded as an export");
|
||||
Check(page.find("invoice.md") == std::string::npos,
|
||||
"a paid donation offers no invoice download");
|
||||
Check(page.find("flashed and tested") == std::string::npos,
|
||||
Check(page.find("prepared and tested") == std::string::npos,
|
||||
"a paid donation promises no dispatch");
|
||||
}
|
||||
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
|
||||
|
|
@ -746,11 +801,12 @@ void DonationLifecycle(TestServer& srv) {
|
|||
|
||||
// The re-rendered form only exists when the shop is open; while coming-soon a
|
||||
// rejection answers with the coming-soon page instead.
|
||||
void RejectedFormEcho(TestServer& srv) {
|
||||
void RejectedFormEcho(TestServer& srv, const Product& pr) {
|
||||
const std::string shop = "/shop/" + pr.slug;
|
||||
// A rejected submission must come back with the values still in it —
|
||||
// losing a filled-in form is how a sale gets abandoned.
|
||||
{
|
||||
const auto rejected = srv.Post("/shop/fp6-pmos",
|
||||
const auto rejected = srv.Post(shop,
|
||||
"email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD");
|
||||
for (std::string_view probe : { "value=\"bad\"", "value=\"NLD\"", "value=\"Ada\"",
|
||||
"value=\"Main 1\"", "value=\"Delft\"" }) {
|
||||
|
|
@ -762,11 +818,14 @@ void RejectedFormEcho(TestServer& srv) {
|
|||
}
|
||||
// A refused destination says why, in the form, with the address still in
|
||||
// it — the visitor should learn the shop does not sell there, not that
|
||||
// something went wrong.
|
||||
// something went wrong. The wording is kRegulatoryMessage's, as a FIELD
|
||||
// error (the same sentence also rides in the preview blob, so the error
|
||||
// markup is what is matched; the apostrophe is escaped as the renderer
|
||||
// escapes it).
|
||||
{
|
||||
const auto refused = srv.Post("/shop/fp6-pmos",
|
||||
const auto refused = srv.Post(shop,
|
||||
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
|
||||
Check(refused.body.find("does not sell or ship to the United States or Canada")
|
||||
Check(refused.body.find("field__error\">Catcrafts can't ship there")
|
||||
!= std::string::npos,
|
||||
"refusal explains itself on the form");
|
||||
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
|
||||
|
|
@ -776,30 +835,34 @@ void RejectedFormEcho(TestServer& srv) {
|
|||
// one — the buyer should learn the law forbids the sale, not wonder what
|
||||
// insurance has to do with Moscow.
|
||||
{
|
||||
const auto refused = srv.Post("/shop/fp6-pmos",
|
||||
const auto refused = srv.Post(shop,
|
||||
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
|
||||
Check(refused.body.find("EU sanctions prohibit") != std::string::npos,
|
||||
"sanctions refusal explains itself on the form");
|
||||
Check(refused.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
|
||||
Check(refused.body.find("field__error\">Catcrafts can't ship there") == std::string::npos,
|
||||
"sanctions refusal is not worded as the policy one");
|
||||
}
|
||||
// The buy panel warns before anyone fills it in, and the preview script
|
||||
// carries the same lists so it cannot quote a total the server would
|
||||
// refuse.
|
||||
srv.BodyHas("/shop/fp6-pmos", "does not sell or ship to the United States or Canada",
|
||||
"buy panel states where the shop does not sell");
|
||||
srv.BodyHas("/shop/fp6-pmos", "cannot sell or ship to Russia, Belarus or North Korea",
|
||||
// The buy panel warns before anyone fills it in — positively, as the
|
||||
// allow-list is the whole policy — and the preview script carries the
|
||||
// same lists so it cannot quote a total the server would refuse: `w` is
|
||||
// the shipping allow-list (a country is refused by its ABSENCE), `s` the
|
||||
// sanctioned countries. No deny-list survives in the payload
|
||||
// (ShouldShipContent pins the exact contents against the fixture).
|
||||
srv.BodyHas(shop, "Catcrafts currently ships to the Netherlands",
|
||||
"buy panel states where the shop ships");
|
||||
srv.BodyHas(shop, "cannot sell or ship to Russia, Belarus or North Korea",
|
||||
"buy panel states where the law forbids selling");
|
||||
srv.BodyHas("/shop/fp6-pmos", ""x":["US","CA"]",
|
||||
"total preview knows the refused destinations");
|
||||
srv.BodyHas("/shop/fp6-pmos", ""s":["RU","BY","KP"]",
|
||||
srv.BodyHas(shop, ""w":["NL",",
|
||||
"total preview carries the shipping allow-list");
|
||||
srv.BodyLacks(shop, ""x":[", "no deny-list survives in the preview payload");
|
||||
srv.BodyHas(shop, ""s":["RU","BY","KP"]",
|
||||
"total preview knows the sanctioned destinations");
|
||||
// The honeypot message must not name the trap, or it teaches the next
|
||||
// bot. Only the ERROR NOTICE is inspected: the re-rendered form
|
||||
// legitimately contains the name="website" field itself — that IS the
|
||||
// trap, re-armed.
|
||||
{
|
||||
const auto pot = srv.Post("/shop/fp6-pmos", Good("&website=x"));
|
||||
const auto pot = srv.Post(shop, Good("&website=x"));
|
||||
std::smatch m;
|
||||
Check(std::regex_search(pot.body, m, std::regex(R"lit(notice--error">([^<]*))lit")),
|
||||
"honeypot rejection renders an error notice");
|
||||
|
|
@ -826,7 +889,7 @@ void RejectedFormEcho(TestServer& srv) {
|
|||
//
|
||||
// Its own server, because a rail roster is fixed at startup. Donations are
|
||||
// what this asserts against: they are open in BOTH shop states, so this runs
|
||||
// whatever fp6-pmos's status is.
|
||||
// whatever the goods' status is, or whether any are listed.
|
||||
void OneRailDown(const char* binary) {
|
||||
ServerOptions options;
|
||||
options.extraArgs = { "--rail=off", "--crypto-rail=fake-crypto" };
|
||||
|
|
@ -856,19 +919,24 @@ int main(int argc, char** argv) {
|
|||
options.mailer = true;
|
||||
TestServer srv(argv[1], 8217, options);
|
||||
|
||||
if (srv.ShopOpen()) {
|
||||
OpenShopLifecycle(srv);
|
||||
const Product* pr = FirstPricedProduct();
|
||||
if (!pr) {
|
||||
std::println("note: the catalogue lists no priced product; the goods checkout, "
|
||||
"order-lifecycle and invoice checks did not run and re-arm when one "
|
||||
"is listed");
|
||||
} else if (srv.ShopOpen(pr->slug)) {
|
||||
OpenShopLifecycle(srv, *pr);
|
||||
} else {
|
||||
ComingSoon(srv);
|
||||
ComingSoon(srv, *pr);
|
||||
}
|
||||
|
||||
AlwaysOnValidation(srv);
|
||||
if (pr) AlwaysOnValidation(srv, *pr);
|
||||
|
||||
// The donation item is open in both shop states — that is the point of it.
|
||||
DonationLifecycle(srv);
|
||||
|
||||
if (srv.ShopOpen()) {
|
||||
RejectedFormEcho(srv);
|
||||
if (pr && srv.ShopOpen(pr->slug)) {
|
||||
RejectedFormEcho(srv, *pr);
|
||||
}
|
||||
|
||||
OneRailDown(argv[1]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue