commit 516e42ae5403d39df98c9a693943e03b436567a4
parent 23620d2972aca393a02f9faa9ad50a1ad5a30d5c
Author: Florian Dold <dold@taler.net>
Date: Mon, 20 Jul 2026 20:43:16 +0200
util: test amount currency validation and arithmetic edge cases
Diffstat:
1 file changed, 166 insertions(+), 0 deletions(-)
diff --git a/packages/taler-util/src/amounts.test.ts b/packages/taler-util/src/amounts.test.ts
@@ -21,6 +21,7 @@ import {
Amounts,
AmountJson,
amountMaxValue,
+ amountFractionalBase,
codecForAmountJson,
} from "./amounts.js";
import { AmountString } from "./types-taler-common.js";
@@ -250,3 +251,168 @@ test("Amounts.divide rejects non-positive or non-integer divisors", (t) => {
assert.throws(() => Amounts.divide(a, -3));
assert.throws(() => Amounts.divide(a, 1.5));
});
+
+test("Amounts.divide keeps the result normalized", (t) => {
+ // The scaled intermediate (r * 1e8 + fraction) exceeds 2^53 for large
+ // divisors, so computing it in float64 rounds the low bits of `fraction`
+ // away and can push the quotient up to exactly amountFractionalBase.
+ const a = jAmt(999999999, 99999999, "EUR");
+ for (const n of [1e9, 90071994, 7, 3, 1000000]) {
+ const r = Amounts.divide(a, n);
+ assert.ok(
+ r.fraction >= 0 && r.fraction < amountFractionalBase,
+ `divide(..., ${n}) produced fraction ${r.fraction}, out of range`,
+ );
+ // The library's own validator must accept what it produced.
+ Amounts.parseOrThrow(Amounts.stringify(r));
+ }
+});
+
+test("Amounts.sub normalizes a denormalized minuend", (t) => {
+ // EUR:2, written denormalized. cmp() and stringify() both normalize, so
+ // sub() must agree with them rather than reporting an underflow.
+ const a = jAmt(0, 2 * amountFractionalBase, "EUR");
+ const b = jAmt(1, 0, "EUR");
+
+ assert.strictEqual(Amounts.stringify(a), "EUR:2" as AmountString);
+ assert.strictEqual(Amounts.cmp(a, b), 1);
+
+ const r = Amounts.sub(a, b);
+ assert.strictEqual(r.saturated, false);
+ assert.strictEqual(Amounts.stringify(r.amount), "EUR:1" as AmountString);
+});
+
+test("Amounts currency comparison is case insensitive everywhere", (t) => {
+ const lo = jAmt(5, 0, "eur");
+ const up = jAmt(1, 0, "EUR");
+
+ assert.strictEqual(Amounts.cmp(lo, up), 1);
+ // Operands need only agree case-insensitively; the result keeps the casing
+ // of the first operand.
+ assert.strictEqual(Amounts.stringify(Amounts.add(lo, up).amount), "eur:6");
+ // divmod used a case-sensitive comparison and threw here.
+ assert.strictEqual(Amounts.divmod(lo, up).quotient, 5);
+});
+
+test("currency validation runs on the raw string, before case folding", (t) => {
+ // String.prototype.toUpperCase is neither ASCII-only nor length-preserving:
+ // U+017F folds to "S" and U+FB01 folds to "FI". Folding before validating
+ // therefore lets non-ASCII input pass an ASCII test, and lets a currency
+ // grow past the 11-character limit.
+ for (const s of ["ſ:5", "fi:5", "fi".repeat(11) + ":5"]) {
+ assert.strictEqual(
+ Amounts.parse(s),
+ undefined,
+ `parse should reject ${JSON.stringify(s)}`,
+ );
+ const r = Amounts.parseWithError(s);
+ assert.strictEqual(
+ r.tag,
+ "error",
+ `parseWithError should reject ${JSON.stringify(s)}`,
+ );
+ }
+});
+
+test("parse and parseWithError agree", (t) => {
+ const corpus = [
+ "EUR:1", "eur:1", "EUR:0.99999999", "EUR:1.999999999", "E:1", "EURO:1",
+ "ABCDEFGHIJK:1", "ABCDEFGHIJKL:1", ":1", "EUR:", "EUR:x", "EUR:1.",
+ "EU R:1", "EU-R:1", "ſ:5", "fi:5", "EüR:1", "EUR:-1",
+ "EUR:9007199254740993", "EUR:4503599627370496",
+ ];
+ for (const s of corpus) {
+ const viaParse = Amounts.parse(s);
+ const viaWithError = Amounts.parseWithError(s);
+ assert.strictEqual(
+ viaWithError.tag === "ok",
+ viaParse !== undefined,
+ `disagreement on ${JSON.stringify(s)}: parse=${JSON.stringify(
+ viaParse,
+ )} parseWithError=${JSON.stringify(viaWithError)}`,
+ );
+ if (viaParse !== undefined && viaWithError.tag === "ok") {
+ assert.deepStrictEqual(viaWithError.value, viaParse, `for ${s}`);
+ }
+ }
+});
+
+test("amount objects are rejected when the currency is not a currency", (t) => {
+ for (const currency of ["", "not a currency €", "EUR1", "ABCDEFGHIJKL", "ſ"]) {
+ assert.throws(
+ () => Amounts.parseOrThrow({ currency, value: 1, fraction: 0 }),
+ `parseOrThrow should reject currency ${JSON.stringify(currency)}`,
+ );
+ assert.throws(
+ () => codecForAmountJson().decode({ currency, value: 1, fraction: 0 }),
+ `codec should reject currency ${JSON.stringify(currency)}`,
+ );
+ }
+ // A valid currency still round-trips, and is canonicalized to upper case.
+ assert.deepStrictEqual(
+ Amounts.parseOrThrow({ currency: "eur", value: 1, fraction: 0 }),
+ { currency: "EUR", value: 1, fraction: 0 },
+ );
+});
+
+test("stringify output is always re-parseable", (t) => {
+ // AmountString is a branded type asserting the string is a valid amount, so
+ // anything stringify brands must satisfy the library's own parser.
+ for (const a of [
+ Amounts.parseOrThrow("EUR:1"),
+ Amounts.parseOrThrow({ currency: "eur", value: 0, fraction: 1 }),
+ Amounts.zeroOfCurrency("CHF"),
+ ]) {
+ const s = Amounts.stringify(a);
+ assert.notStrictEqual(Amounts.parse(s), undefined, `cannot re-parse ${s}`);
+ }
+ assert.throws(() => Amounts.zeroOfCurrency("not a currency"));
+});
+
+test("arithmetic rejects non-integer, negative and non-finite fields", (t) => {
+ const ok = jAmt(1, 0, "EUR");
+
+ // NaN used to compare equal to zero: every "<" and ">" case in cmp's switch
+ // is false for NaN, so control fell through to `case af === bf` and returned
+ // 0 rather than reaching the assertion in the default branch.
+ assert.throws(() => Amounts.cmp(jAmt(NaN, 0, "EUR"), ok));
+ assert.throws(() => Amounts.cmp(ok, jAmt(0, NaN, "EUR")));
+
+ // A negative fraction propagated through add's "%" (which keeps the sign in
+ // JS) and produced a nonsense amount and a nonsense string.
+ assert.throws(() => Amounts.add(ok, jAmt(0, -5, "EUR")));
+ // A negative value on the subtrahend turned sub into add.
+ assert.throws(() => Amounts.sub(ok, jAmt(-5, 0, "EUR")));
+
+ assert.throws(() => Amounts.add(ok, jAmt(Infinity, 0, "EUR")));
+ assert.throws(() => Amounts.add(ok, jAmt(0, Infinity, "EUR")));
+ assert.throws(() => Amounts.add(ok, jAmt(1.5, 0, "EUR")));
+ assert.throws(() => Amounts.add(ok, jAmt(0, 1.5, "EUR")));
+ // Missing fields entirely.
+ assert.throws(() =>
+ Amounts.add(ok, { value: 1, fraction: 0 } as unknown as AmountJson),
+ );
+ assert.throws(() =>
+ Amounts.add(ok, { currency: "EUR", value: 1 } as unknown as AmountJson),
+ );
+});
+
+test("arithmetic still accepts denormalized amounts", (t) => {
+ // add, cmp and stringify are all defined to normalize their operands, so a
+ // fraction at or above amountFractionalBase must keep working.
+ assert.strictEqual(
+ Amounts.cmp(jAmt(1, 0, "EUR"), jAmt(0, amountFractionalBase, "EUR")),
+ 0,
+ );
+ assert.strictEqual(
+ Amounts.stringify(jAmt(1, amountFractionalBase, "TESTKUDOS")),
+ "TESTKUDOS:2" as AmountString,
+ );
+ assert.strictEqual(
+ Amounts.stringify(
+ Amounts.add(jAmt(0, amountFractionalBase, "EUR"), jAmt(1, 0, "EUR"))
+ .amount,
+ ),
+ "EUR:2" as AmountString,
+ );
+});