This commit is contained in:
parent
47d302a9a4
commit
45992c4f91
10 changed files with 670 additions and 13 deletions
194
tests/ShouldCreateMollieTestPayments/main.cpp
Normal file
194
tests/ShouldCreateMollieTestPayments/main.cpp
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
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 <server-binary>");
|
||||
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();
|
||||
}
|
||||
Loading…
Reference in a new issue