catcrafts.net/tests/ShouldProcessCheckout/main.cpp
Jorijn van der Graaf abbd616b40
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
donation item, shop soft open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:04:03 +02:00

773 lines
41 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The whole order lifecycle against the real binary: checkout, the payment
// choice, export pricing, the refusals, the fake rail settling, the signed
// invoice, the confirmation email, and the financials page agreeing with the
// ledger. The fake payment rail makes this testable: it hands out pretend
// 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.
import std;
import Catcrafts.Shared;
import Crafter.Network;
import Catcrafts.E2eHarness;
using namespace Catcrafts;
using namespace Catcrafts::E2e;
namespace {
constexpr std::string_view kGood =
"email=e2e%40example.org&name=Ada%20Lovelace&street=Main%20St%201&postal=1234AB&city=Delft&country=nl";
std::string Good(std::string_view suffix = {}) {
return std::string(kGood) + std::string(suffix);
}
// The order token from a checkout redirect's Location header.
std::string TokenOf(const Crafter::HTTPResponse& r) {
const auto it = r.headers.find("location");
if (it == r.headers.end()) return {};
std::smatch m;
if (std::regex_search(it->second, m, std::regex(R"(/order/([0-9a-f]{32})$)"))) {
return m[1].str();
}
return {};
}
// The ledger is JSONL — one event per line.
std::vector<std::string> LedgerLines(TestServer& srv) {
std::vector<std::string> lines;
for (auto part : std::views::split(srv.OrdersText(), '\n')) {
std::string_view line(part.begin(), part.end());
if (!line.empty()) lines.emplace_back(line);
}
return lines;
}
std::size_t MailCount(TestServer& srv) {
std::size_t n = 0;
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")) ++n;
}
return n;
}
// Wait until `predicate` holds, on the reconciler/mailer cadence.
bool SettleUntil(std::function<bool()> predicate, std::int32_t tries = 40) {
for (std::int32_t i = 0; i < tries; ++i) {
if (predicate()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
return predicate();
}
bool GpgVerifies(const std::filesystem::path& file) {
return std::system(std::format("gpg --verify {} >/dev/null 2>&1",
file.string()).c_str()) == 0;
}
void OpenShopLifecycle(TestServer& srv) {
// ── 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 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)");
// 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.
Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos,
"a submission with no payment choice records bank");
}
// The order page: awaiting payment, pay link, reference, self-refreshing,
// never indexed, never cached.
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" }) {
Check(page.find(probe) != std::string::npos,
std::format("order page has {}", probe));
}
}
srv.HeaderHas(orderPath, "x-robots-tag", "noindex", "order page is noindex");
srv.HeaderHas(orderPath, "cache-control", "no-store", "order page is never cached");
// Unknown and malformed tokens are the same 404.
srv.CheckStatus("/order/00000000000000000000000000000000", "404");
srv.CheckStatus("/order/not-a-token", "404");
srv.CheckStatus("/order/deadbeef", "404");
// ── the payment choice ────────────────────────────────────────────
// Both slots are configured here, so the form must offer both and the
// picked one must survive all the way into the ledger. The ledger is the
// 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 Mollie.
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");
const std::string tokenCrypto = TokenOf(srv.Post("/shop/fp6-pmos", Good("&pay=crypto")));
Check(!tokenCrypto.empty(), "a crypto order goes through");
if (!tokenCrypto.empty()) {
bool recorded = false;
for (const std::string& line : LedgerLines(srv)) {
if (line.find(std::format("\"id\":\"{}\"", tokenCrypto)) != std::string::npos) {
recorded = recorded
|| line.find("\"pay_choice\":\"crypto\"") != std::string::npos;
}
}
Check(recorded, "the crypto choice is what the ledger records");
// The order page has to promise what is actually behind the button —
// the bank copy on a crypto order would send someone looking for
// iDEAL. (The shell suite pinned 'Lightning' here, CoinGate-era copy
// that had already left the codebase — this is the check that caught
// the drift when the port first ran against an open shop.)
const std::string page = srv.Body(std::format("/order/{}", tokenCrypto));
Check(page.find("completes your crypto payment") != std::string::npos,
"the crypto order page describes the crypto payment");
Check(page.find("iDEAL") == std::string::npos,
"the crypto order page does not promise iDEAL");
}
// A payment method nobody offers is refused, and refused as a FIELD error
// 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"));
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");
}
// 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));
// €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(page.find("indicative") != std::string::npos,
"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=GB&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");
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");
}
// 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"));
// 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");
// 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");
// 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
// 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"));
Check(heavy.status == "422", "an over-weight order is refused", heavy.status);
Check(heavy.body.find("up to 14 per order") != 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");
}
// A destination the carrier has no rate for. Since the zone fallback went
// away there is no price to invent, so this is a refusal — and
// specifically NOT the no-sale refusal, which is a different (policy)
// reason with different wording.
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));
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,
"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));
Check(LedgerLines(srv).size() == before, "a refused destination writes no order");
}
// No invoice exists before the money does — awaiting orders answer 404.
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
srv.CheckStatus("/order/00000000000000000000000000000000/invoice.md", "404");
// ── the payment lands ─────────────────────────────────────────────
// Create the fake rail's paid marker, then the reconciler (1 s cadence in
// fake mode) must flip the order within a few seconds. The paid state
// shows the confirmation notice, deliberately WITHOUT a second "paid"
// badge — so the success marker is the notice text.
WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), "");
{
const std::string page = srv.WaitForBody(orderPath, "order is confirmed");
Check(page.find("order is confirmed") != std::string::npos,
"order confirms after payment (arrival poll or reconciler)");
const std::size_t badges = CountOccurrences(page, "badge--active");
Check(badges == 0, "no duplicate paid badge next to the confirmation",
std::format("found {} active badges", badges));
Check(page.find("http-equiv=\"refresh\"") == std::string::npos,
"paid order page stops self-refreshing");
}
{
const std::string ledger = srv.OrdersText();
Check(ledger.find("\"type\":\"status\"") != std::string::npos
&& ledger.find("\"status\":\"paid\"") != std::string::npos,
"paid transition is an appended event, not a rewrite");
// The paid event records HOW it was paid — card money stays
// reversible for months, so the ledger must show which orders carry
// that tail.
Check(ledger.find("\"via\":\"fake\"") != std::string::npos,
"paid event records the payment method");
}
// ── the signed invoice ────────────────────────────────────────────
// Paid orders download a clearsigned markdown invoice: sequential number,
// 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" }) {
Check(invoice.body.find(probe) != std::string::npos,
std::format("invoice has {}", probe));
}
const auto disposition = invoice.headers.find("content-disposition");
Check(disposition != invoice.headers.end()
&& disposition->second.find("attachment") != std::string::npos,
"invoice downloads as an attachment");
const std::filesystem::path file = srv.Work() / "invoice.md";
WriteFile(file, invoice.body);
Check(GpgVerifies(file), "invoice signature verifies with gpg");
}
// Several orders were placed before the marker (two of them by the same
// email); the arrival poll paid one instantly, the reconciler sweeps the
// rest on its 1 s cadence — wait for all four invoices before judging the
// numbering.
SettleUntil([&] {
return CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") >= 4;
});
// Per-customer series, continuing the pre-shop administration: numbers
// are <customer-uuid>-<seq>, unique overall, and orders that share an
// email share a series with distinct sequence numbers.
{
const std::string ledger = srv.OrdersText();
const std::size_t invoices = CountOccurrences(ledger, "\"type\":\"invoice\"");
std::set<std::string> numbers;
bool uuidSeries = false;
const std::regex numberField(R"lit("number":"([0-9a-f-]*)")lit");
const std::regex uuidSeq(
R"(^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+$)");
for (auto it = std::sregex_iterator(ledger.begin(), ledger.end(), numberField);
it != std::sregex_iterator(); ++it) {
numbers.insert((*it)[1].str());
uuidSeries = uuidSeries || std::regex_match((*it)[1].str(), uuidSeq);
}
Check(invoices > 0 && invoices == numbers.size(),
std::format("invoice numbers are unique ({} issued)", invoices));
Check(uuidSeries, "invoice numbers are customer-uuid series");
// The GOOD email placed several paid orders in this run — all of them
// must sit in ONE customer series (same uuid), with as many distinct
// sequence numbers.
std::set<std::string> customers;
const std::regex customerField(R"lit("customer":"([0-9a-f-]*)")lit");
for (auto it = std::sregex_iterator(ledger.begin(), ledger.end(), customerField);
it != std::sregex_iterator(); ++it) {
customers.insert((*it)[1].str());
}
Check(customers.size() < invoices,
std::format("repeat customer shares one series ({} customers, {} invoices)",
customers.size(), invoices));
}
// ── the confirmation email ────────────────────────────────────────
// Every paid order gets exactly one confirmation with the signed invoice
// attached. The expected count comes from the LEDGER rather than a number
// written here: "one per paid order" is the actual property, and a
// literal would have to be edited by anyone who adds an order above — a
// test that fails for the wrong reason and gets bumped without being
// read. The mailer sweeps every 2 s.
const std::size_t paidCount = CountOccurrences(srv.OrdersText(), "\"status\":\"paid\"");
SettleUntil([&] { return MailCount(srv) >= paidCount; }, 60);
const std::size_t mailCount = MailCount(srv);
Check(mailCount == paidCount,
std::format("one confirmation email per paid order ({} sent)", mailCount),
std::format("expected {}", paidCount));
// 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;
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;
}
}
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-" }) {
Check(nlMail.find(probe) != std::string::npos,
std::format("email has {}", probe));
}
// The attached invoice must verify offline exactly like the download.
const std::size_t begin = nlMail.find("-----BEGIN PGP SIGNED MESSAGE-----");
const std::size_t end = nlMail.find("-----END PGP SIGNATURE-----");
Check(begin != std::string::npos && end != std::string::npos,
"email carries a full PGP block");
if (begin != std::string::npos && end != std::string::npos) {
const std::filesystem::path file = srv.Work() / "mail-invoice.asc";
WriteFile(file, nlMail.substr(
begin, end + std::string_view("-----END PGP SIGNATURE-----").size() - begin)
+ "\n");
Check(GpgVerifies(file), "emailed invoice signature verifies with gpg");
}
}
// The export order's message states the VAT treatment its invoice carries.
Check(!gbMail.empty() && gbMail.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
// timing — sit out two more sweeps and expect no extra message.
std::this_thread::sleep_for(std::chrono::seconds(5));
Check(MailCount(srv) == mailCount, "no order was emailed twice",
std::format("message count grew from {} to {}", mailCount, MailCount(srv)));
Check(srv.OrdersText().find("\"type\":\"notified\"") != std::string::npos,
"notified events recorded in the ledger");
// ── financials reflect the ledger ─────────────────────────────────
// Lifetime sales on /financials must equal the ledger: sum of total_minor
// over orders that have a paid status event. Derived from the ledger
// rather than written as a literal — same rule as the email count above:
// "the page equals the ledger" is the actual property.
{
std::set<std::string> paidIds;
std::int64_t wantMinor = 0;
std::size_t wantCount = 0;
const std::vector<std::string> lines = LedgerLines(srv);
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") == "status" && event->Str("status") == "paid") {
paidIds.insert(std::string(event->Str("id")));
}
}
for (const std::string& id : paidIds) {
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") == "order" && event->Str("id") == id) {
wantMinor += event->Int("total_minor");
++wantCount;
break;
}
}
}
const std::string page = srv.Body("/financials");
const std::string want = std::format(
"data-fin-sales-minor=\"{}\"", wantMinor);
Check(wantCount > 0 && page.find(want) != std::string::npos
&& page.find(std::format("data-fin-sales-count=\"{}\"", wantCount))
!= std::string::npos,
std::format("sales totals equal the ledger ({} orders, {} cents)",
wantCount, wantMinor));
// And the formatted euro figure for that total appears on the page.
const std::string euro = wantMinor % 100 == 0
? std::format("€{}", wantMinor / 100)
: std::format("€{}.{:02}", wantMinor / 100, wantMinor % 100);
Check(page.find(euro) != std::string::npos,
std::format("sales total renders as {}", euro));
}
}
void ComingSoon(TestServer& srv) {
// 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());
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",
"name=Ada&street=x&postal=1&city=y&country=NL"); // no email
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=nonsense&" + Good()); // bad email (dup keeps first)
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"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.
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");
srv.CheckStatus("/shop/fp6-pmos", "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",
"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",
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"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");
// 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");
srv.CheckStatus("/shop/nope", "404", "POST", Good()); // unknown product
}
// 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
// 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.
void DonationLifecycle(TestServer& srv) {
// ── validation ────────────────────────────────────────────────────
// The donation validator's refusals, over real HTTP. No address fields
// exist to miss; the amount is the field that carries the rules.
srv.CheckStatus("/shop/donation", "422", "POST", "email=a%40b.example"); // no amount
srv.CheckStatus("/shop/donation", "422", "POST", "amount=nonsense");
srv.CheckStatus("/shop/donation", "422", "POST", "amount=0.50"); // below €1
srv.CheckStatus("/shop/donation", "422", "POST", "amount=10000.01"); // above €10k
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&email=nonsense");
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&website=spam"); // honeypot
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&pay=free");
{
// A refused amount comes back in the form, like any rejected field.
const auto refused = srv.Post("/shop/donation", "amount=0.50");
Check(refused.body.find("field__error") != std::string::npos,
"a refused donation shows a field error");
Check(refused.body.find("€1 to €10000") != std::string::npos,
"the amount refusal names the bounds");
}
// ── a donation with no email at all ───────────────────────────────
// Identity is optional: the capability URL is the receipt.
const auto created = srv.Post("/shop/donation", "amount=25");
const std::string token = TokenOf(created);
Check(created.status == "303" && !token.empty(),
"POST donation -> 303 straight to payment", created.status);
{
const std::string line = [&] {
for (const std::string& l : LedgerLines(srv)) {
if (l.find(std::format("\"id\":\"{}\"", token)) != std::string::npos
&& l.find("\"type\":\"order\"") != std::string::npos) {
return l;
}
}
return std::string{};
}();
Check(!line.empty(), "the donation order is on the ledger");
for (std::string_view probe : { "\"donation\":true", "\"total_minor\":2500",
"\"shipping_minor\":0", "\"vat_included\":false",
"\"product\":\"donation\"", "\"email\":\"\"" }) {
Check(line.find(probe) != std::string::npos,
std::format("donation ledger line has {}", probe));
}
}
const std::string orderPath = std::format("/order/{}", token);
{
const std::string page = srv.Body(orderPath);
Check(page.find("€25") != std::string::npos,
"donation order page shows the amount");
Check(page.find("Shipping") == std::string::npos,
"donation order page has no shipping row");
}
// ── the payment lands ─────────────────────────────────────────────
// Same fake-rail marker as checkout (idempotent if the open-shop half
// already created it). The paid state is a thank-you, not a dispatch
// promise, and there is no invoice to download — not before, not after.
const std::size_t invoicesBefore =
CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"");
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), "");
{
const std::string page = srv.WaitForBody(orderPath, "Thank you");
Check(page.find("Thank you") != std::string::npos,
"a paid donation says thank you");
Check(page.find("VAT 0%") != std::string::npos,
"a paid donation states the 0% VAT treatment");
Check(page.find("Zero-rated export") == std::string::npos,
"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,
"a paid donation promises no dispatch");
}
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
// Settle a moment: no invoice event may appear for a donation, ever.
SettleUntil([&] {
return srv.OrdersText().find(std::format("\"id\":\"{}\",\"status\":\"paid\"", token))
!= std::string::npos;
});
Check(CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") == invoicesBefore,
"a paid donation is assigned no invoice number");
// ── /financials books it under donations ──────────────────────────
// Ledger-derived, same rule as the sales assertion: paid donation orders
// sum into the donations pair, paid goods orders into sales, and no euro
// sits in both.
{
std::set<std::string> paidIds;
const std::vector<std::string> lines = LedgerLines(srv);
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") == "status" && event->Str("status") == "paid") {
paidIds.insert(std::string(event->Str("id")));
}
}
std::int64_t wantSales = 0, wantDonations = 0;
std::size_t wantSalesCount = 0, wantDonationCount = 0;
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") != "order"
|| !paidIds.contains(std::string(event->Str("id")))) {
continue;
}
if (event->Bool("donation")) {
wantDonations += event->Int("total_minor");
++wantDonationCount;
} else {
wantSales += event->Int("total_minor");
++wantSalesCount;
}
}
const std::string page = srv.Body("/financials");
Check(wantDonationCount > 0, "at least one paid donation is on the ledger");
Check(page.find(std::format("data-fin-donations-count=\"{}\"", wantDonationCount))
!= std::string::npos
&& page.find(std::format("data-fin-donations-minor=\"{}\"", wantDonations))
!= std::string::npos,
std::format("donation totals equal the ledger ({} donations, {} cents)",
wantDonationCount, wantDonations));
Check(page.find(std::format("data-fin-sales-count=\"{}\"", wantSalesCount))
!= std::string::npos
&& page.find(std::format("data-fin-sales-minor=\"{}\"", wantSales))
!= std::string::npos,
"sales totals exclude the donation");
}
// ── the confirmation email ────────────────────────────────────────
// With an email given: a thank-you, no invoice attached (none exists).
// Without one: silence — no address means the donor asked for nothing.
const std::size_t mailsBefore = MailCount(srv);
const std::string tokenMailed = TokenOf(srv.Post("/shop/donation",
"amount=10&email=donor%40example.org"));
Check(!tokenMailed.empty(), "a donation with an email goes through");
SettleUntil([&] { return MailCount(srv) > mailsBefore; }, 60);
std::string donationMail;
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/{}", tokenMailed)) != std::string::npos) {
donationMail = mail;
}
}
Check(!donationMail.empty(), "a donation with an email gets a confirmation");
if (!donationMail.empty()) {
for (std::string_view probe : { "To: donor@example.org",
"Subject: Catcrafts donation CC-",
"Thank you", "€10",
"No VAT applies" }) {
Check(donationMail.find(probe) != std::string::npos,
std::format("donation email has {}", probe));
}
Check(donationMail.find("BEGIN PGP SIGNED MESSAGE") == std::string::npos
&& donationMail.find("filename=\"catcrafts-invoice-") == std::string::npos,
"donation email attaches no invoice");
}
// The no-email donation stays unmailed: sit out two mailer sweeps and
// expect no message carrying its link.
std::this_thread::sleep_for(std::chrono::seconds(5));
bool mailedAnyway = false;
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;
if (ReadFile(entry.path()).find(std::format("/order/{}", token))
!= std::string::npos) {
mailedAnyway = true;
}
}
Check(!mailedAnyway, "a donation without an email is never emailed");
}
// 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) {
// 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",
"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\"" }) {
Check(rejected.body.find(probe) != std::string::npos,
std::format("rejected form preserves {}", probe));
}
Check(rejected.body.find("field__error") != std::string::npos,
"rejected form shows a field error");
}
// 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.
{
const auto refused = srv.Post("/shop/fp6-pmos",
"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")
!= std::string::npos,
"refusal explains itself on the form");
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
"a refused submission keeps what was typed");
}
// A sanctioned destination gets the sanctions sentence, not the policy
// 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",
"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,
"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",
"buy panel states where the law forbids selling");
srv.BodyHas("/shop/fp6-pmos", "&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]",
"total preview knows the refused destinations");
srv.BodyHas("/shop/fp6-pmos", "&quot;s&quot;:[&quot;RU&quot;,&quot;BY&quot;,&quot;KP&quot;]",
"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"));
std::smatch m;
Check(std::regex_search(pot.body, m, std::regex(R"lit(notice--error">([^<]*))lit")),
"honeypot rejection renders an error notice");
if (!m.empty()) {
std::string notice = m[1].str();
for (char& c : notice) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const bool names = notice.find("honeypot") != std::string::npos
|| notice.find("website") != std::string::npos
|| notice.find("hidden") != std::string::npos
|| notice.find("trap") != std::string::npos;
Check(!names, "honeypot failure does not name the trap", m[1].str());
}
}
}
} // namespace
int main(int argc, char** argv) {
ServerOptions options;
options.gpg = true;
options.mailer = true;
TestServer srv(argv[1], 8217, options);
if (srv.ShopOpen()) {
OpenShopLifecycle(srv);
} else {
ComingSoon(srv);
}
AlwaysOnValidation(srv);
// The donation item is open in both shop states — that is the point of it.
DonationLifecycle(srv);
if (srv.ShopOpen()) {
RejectedFormEcho(srv);
}
return Finish();
}