This commit is contained in:
parent
94a995d814
commit
aaa7a8ce99
10 changed files with 543 additions and 8 deletions
|
|
@ -148,6 +148,109 @@ int main() {
|
|||
R"("amount":{"currency":"EUR","value":"1.234"}}}]})").empty(),
|
||||
"an unparseable amount is skipped rather than read as zero");
|
||||
|
||||
// ── the callback shape ────────────────────────────────────────────
|
||||
//
|
||||
// bunq does not sign these, so the parser is the only thing between an
|
||||
// arbitrary POST body and a line in the credits file. It must therefore be
|
||||
// strict about what it accepts and must never invent a field.
|
||||
{
|
||||
constexpr std::string_view kHook = R"({
|
||||
"NotificationUrl": {
|
||||
"category": "MUTATION",
|
||||
"object": {"Payment": {
|
||||
"id": 4155999,
|
||||
"type": "EBA_SCT",
|
||||
"description": "CC-2B6457",
|
||||
"amount": {"currency": "EUR", "value": "570.43"}
|
||||
}}
|
||||
}
|
||||
})";
|
||||
const auto c = Server::ParseBunqCallback(kHook);
|
||||
Check(c.has_value(), "a MUTATION callback decodes to a credit");
|
||||
if (c) {
|
||||
Check(c->id == "4155999", "the payment id is kept", c->id);
|
||||
Check(c->amountMinor == 57043, "the amount decodes",
|
||||
std::format("{}", c->amountMinor));
|
||||
Check(c->reference == "CC-2B6457", "the description is the reference",
|
||||
c->reference);
|
||||
Check(c->method == "sepa", "the type maps to a via", c->method);
|
||||
}
|
||||
|
||||
// An id is what deduplication rests on, and bunq RETRIES callbacks. A
|
||||
// credit with no id could therefore be applied twice, so it must be
|
||||
// refused outright rather than stored with a blank.
|
||||
Check(!Server::ParseBunqCallback(R"({"NotificationUrl":{"object":{"Payment":{)"
|
||||
R"("type":"EBA_SCT","description":"x",)"
|
||||
R"("amount":{"currency":"EUR","value":"1.00"}}}}})").has_value(),
|
||||
"a payment with no id is refused, because retries need one");
|
||||
|
||||
// Everything that is not a payment notification: bunq sends several
|
||||
// categories, and none of the others may produce a credit.
|
||||
for (const std::string_view junk : {
|
||||
std::string_view(""),
|
||||
std::string_view("not json"),
|
||||
std::string_view(R"({})"),
|
||||
std::string_view(R"({"NotificationUrl":{}})"),
|
||||
std::string_view(R"({"NotificationUrl":{"object":{}}})"),
|
||||
std::string_view(R"({"NotificationUrl":{"object":{"Payment":{}}}})"),
|
||||
// A Payment whose amount is another currency, or unparseable.
|
||||
std::string_view(R"({"NotificationUrl":{"object":{"Payment":{"id":1,)"
|
||||
R"("amount":{"currency":"USD","value":"1.00"}}}}})"),
|
||||
std::string_view(R"({"NotificationUrl":{"object":{"Payment":{"id":1,)"
|
||||
R"("amount":{"currency":"EUR","value":"1.234"}}}}})"),
|
||||
}) {
|
||||
Check(!Server::ParseBunqCallback(junk).has_value(),
|
||||
"a body with no usable euro payment yields nothing", junk);
|
||||
}
|
||||
|
||||
// The sign survives here too: an outgoing payment must not be able to
|
||||
// arrive through the webhook as income.
|
||||
const auto out = Server::ParseBunqCallback(
|
||||
R"({"NotificationUrl":{"object":{"Payment":{"id":7,"type":"EBA_SCT",)"
|
||||
R"("description":"supplier","amount":{"currency":"EUR","value":"-99.00"}}}}})");
|
||||
Check(out.has_value() && out->amountMinor == -9900,
|
||||
"an outgoing callback keeps its sign for the caller to reject",
|
||||
out ? std::format("{}", out->amountMinor) : "nullopt");
|
||||
}
|
||||
|
||||
// ── appending, and refusing to append twice ───────────────────────
|
||||
//
|
||||
// bunq retries a callback about six times, and a periodic pull re-reads an
|
||||
// overlapping window, so the same payment WILL arrive more than once. If a
|
||||
// second copy landed in the file it would double the money against one
|
||||
// reference — enough to settle a part-paid order whose balance never came.
|
||||
{
|
||||
const std::filesystem::path dir =
|
||||
std::filesystem::temp_directory_path() / "cc-bunq-append-test";
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(dir, ec);
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
const std::filesystem::path credits = dir / "credits.jsonl";
|
||||
|
||||
Server::BankCredit c;
|
||||
c.id = "999";
|
||||
c.reference = "CC-2B6457";
|
||||
c.amountMinor = 2500;
|
||||
c.method = "sepa";
|
||||
|
||||
Check(Server::AppendCreditTo(credits, c), "the first append succeeds");
|
||||
Check(!Server::AppendCreditTo(credits, c),
|
||||
"the same payment id is refused the second time");
|
||||
auto src = Server::MakeFileCreditSource(credits);
|
||||
const auto all = src->Recent();
|
||||
Check(all && all->size() == 1, "the file holds exactly one copy",
|
||||
all ? std::format("{}", all->size()) : "nullopt");
|
||||
if (all) {
|
||||
Check(Server::MatchCredits(*all, "CC-2B6457").paidMinor == 2500,
|
||||
"so the order sees 25.00 once, not twice");
|
||||
}
|
||||
// A credit with no id cannot be deduplicated and must be refused.
|
||||
Server::BankCredit noId = c;
|
||||
noId.id.clear();
|
||||
Check(!Server::AppendCreditTo(credits, noId), "a credit with no id is refused");
|
||||
std::filesystem::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
|
|
|
|||
Loading…
Reference in a new issue