/* 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 Mollie rail against the REAL api.mollie.com, on a test-mode key: a €1 // donation whose payment is actually created at Mollie, polled by the real // reconciler, and read back by this suite with its own authenticated GET. // The fake-rail suites prove the lifecycle; the parser suite proves the // decoding; what neither can prove is the live conversation — the bearer // auth, the amount formatting Mollie accepts, the JSON shape they answer // with today. This suite is a MANDATORY deploy gate in CI (the operator's // call — caution over convenience); locally it skips unless // MOLLIE_TEST_API_KEY is exported, and in CI that same missing secret is a // FAILURE, never a quiet skip. // // Coverage stops at Pending, deliberately. Mollie has no API that marks a // test payment paid — the test-mode checkout page is where a human (or a // headless browser this repo does not carry) picks the outcome. So the paid // transition stays covered by the fake rail and by the pre-launch manual // click-through; what this suite pins is everything up to it: create, the // checkout URL, the ledger record, the poll reading "open" as still-awaiting // rather than as dead, and no errors on the wire. Test-mode payments expire // at Mollie on their own; nothing is left behind. // // The key must be a test_ key. A live_ key is refused outright, in every // environment: this suite creates payments, and a payment created on the // live key is a real invoice in the shop's Mollie dashboard. import std; import Crafter.Network; import Catcrafts.E2eHarness; using namespace Catcrafts::E2e; namespace { bool IsCi() { for (const char* v : { "CI", "GITHUB_ACTIONS", "FORGEJO_ACTIONS" }) { if (const char* s = std::getenv(v); s && *s) return true; } return false; } std::string FirstMatch(const std::string& text, const std::string& pattern) { std::smatch m; if (std::regex_search(text, m, std::regex(pattern))) return m[1].str(); return {}; } // One authenticated GET against the live API, the suite's own eyes on the // payment the server created — same endpoint the reconciler polls, but read // here independently so a server-side bug cannot vouch for itself. std::string MollieGet(const std::string& key, const std::string& path) { try { Crafter::ClientHTTP1 client("api.mollie.com", 443, Crafter::TLSClientCredentials{}); Crafter::HTTPRequest req; req.method = "GET"; req.path = path; req.authority = "api.mollie.com"; req.headers["authorization"] = "Bearer " + key; req.headers["user-agent"] = "catcrafts.net-e2e/1.0 (+https://catcrafts.net)"; const Crafter::HTTPResponse res = client.Send(req); if (res.status.size() != 3 || res.status[0] != '2') { std::println(std::cerr, "mollie e2e: GET {} -> {} {}", path, res.status, res.body.substr(0, 200)); return {}; } return res.body; } catch (const std::exception& e) { std::println(std::cerr, "mollie e2e: GET {} failed: {}", path, e.what()); return {}; } } } // namespace int main(int argc, char** argv) { if (argc < 2) { std::println(std::cerr, "usage: ShouldCreateMollieTestPayments "); return 2; } const char* keyEnv = std::getenv("MOLLIE_TEST_API_KEY"); if (!keyEnv || !*keyEnv) { if (IsCi()) { std::println(std::cerr, "FAIL: MOLLIE_TEST_API_KEY is not set. This suite is a mandatory " "deploy gate: add the secret in Forgejo (repo Settings -> Actions -> " "Secrets) — the test_ key from the Mollie dashboard, Developers -> " "API keys. See deploy/README.md \"Live payment suites in CI\"."); return 1; } std::println("ShouldCreateMollieTestPayments: skipped — MOLLIE_TEST_API_KEY is " "not set (mandatory in CI, opt-in locally)"); return 0; } const std::string key(keyEnv); if (!key.starts_with("test_")) { std::println(std::cerr, "FAIL: MOLLIE_TEST_API_KEY does not start with test_ — refusing to run " "a payment-creating suite on anything but a test-mode key."); return 1; } ServerOptions options; options.extraArgs = { "--rail=mollie", "--crypto-rail=fake-crypto" }; options.env = { { "MOLLIE_API_KEY", key } }; TestServer srv(argv[1], 8219, options); // ── checkout: a €1 donation on the bank rail ───────────────────── // The 303 goes to Mollie's hosted checkout, not the order page — that IS // the assertion: a real payment now exists and has somewhere to be paid. const auto created = srv.Post("/shop/donation", "amount=1"); Check(created.status == "303", "a €1 donation 303s to the payment", created.status); std::string checkoutUrl; if (const auto it = created.headers.find("location"); it != created.headers.end()) { checkoutUrl = it->second; } Check(checkoutUrl.starts_with("https://") && checkoutUrl.find("mollie.com") != std::string::npos, "the redirect is Mollie's hosted checkout", checkoutUrl); if (created.status != "303" || checkoutUrl.empty()) { std::println(std::cerr, "server log:\n{}", ReadFile(srv.Work() / "server.log")); return Finish(); } // ── the ledger records the live payment ────────────────────────── const std::string ledger = srv.OrdersText(); const std::string token = FirstMatch(ledger, R"lit("type":"order".*?"id":"([0-9a-f]{32})")lit"); const std::string payId = FirstMatch(ledger, R"lit("pay_id":"(tr_[A-Za-z0-9]+)")lit"); Check(!token.empty(), "the order reached the ledger"); Check(!payId.empty(), "the ledger carries Mollie's tr_ payment id"); Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos, "a donation with no pay field lands on the bank rail"); Check(ledger.find("\"total_minor\":100") != std::string::npos, "€1 is stored as 100 cents"); if (token.empty() || payId.empty()) return Finish(); const std::string orderPath = std::format("/order/{}", token); // ── the suite's own read of the payment at Mollie ───────────────── // status open (fresh, method not yet chosen), the exact amount format // Mollie accepted, and OUR redirect back to this order — the round trip // that proves FormatMinor and the create body against the live API. { const std::string payment = MollieGet(key, "/v2/payments/" + payId); Check(!payment.empty(), "the payment the server created exists at Mollie"); if (!payment.empty()) { Check(payment.find("\"status\":\"open\"") != std::string::npos, "a fresh test payment reads as open", FirstMatch(payment, R"lit("status":"([a-z]+)")lit")); Check(payment.find("\"currency\":\"EUR\"") != std::string::npos && payment.find("\"value\":\"1.00\"") != std::string::npos, "the amount arrived as EUR 1.00"); Check(payment.find("/order/" + token) != std::string::npos, "the payment's redirectUrl returns to this order"); } } // ── the real poll reads open as still-awaiting ──────────────────── // Rendering the page triggers the arrival poll and the reconciler polls // on Mollie's 10 s cadence; give both time for at least two live GETs. // "Open" must stay awaiting — parsed as Dead it would cancel the order, // parsed as an error it would log below. { const std::string page = srv.Body(orderPath); Check(page.find("awaiting payment") != std::string::npos, "the fresh order page shows awaiting payment"); Check(page.find("Resume payment") != std::string::npos, "the order page offers the resume link"); Check(page.find("mollie.com") != std::string::npos, "the resume link points at the hosted checkout"); } std::this_thread::sleep_for(std::chrono::seconds(12)); Check(srv.Body(orderPath).find("awaiting payment") != std::string::npos, "an open payment is still awaiting after live reconciler polls"); { // Any "mollie:" line is a failed call — auth, transport, or a JSON // shape the parser refused. Create succeeded (the checkout URL above), // so a clean log here means the polls succeeded too. const std::string log = ReadFile(srv.Work() / "server.log"); Check(log.find("mollie:") == std::string::npos, "no Mollie call failed during create or polling", FirstMatch(log, R"((mollie:[^\n]*))")); } return Finish(); }