taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-util/src/amounts.test.ts | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, + ); +});