test(money): lock in paymentValidation — the gate for every payment (Phase 1)
Pure-function coverage of the validator that normalizes dollars→cents and rejects bad input before it touches a balance: required-field errors + field tag, dollars→cents conversion, positive-amount enforcement (rejects 0/negative/non-numeric; documents the intentional lack of an upper bound), real-calendar-date validation (Feb 30 / month 13), payment_source whitelist, and the require* option matrix. Suite 215 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b3ce334987
commit
3c2f6ca3b5
|
|
@ -0,0 +1,71 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Phase 1 — paymentValidation gates EVERY payment write (manual/quick/bulk/
|
||||||
|
// import). It normalizes dollars → integer cents and rejects bad input before it
|
||||||
|
// can touch a balance. Pure functions, no DB. Pins: required-field errors + the
|
||||||
|
// field tag, dollars→cents conversion, amount/date validity, payment_source
|
||||||
|
// whitelist, and the require* option matrix.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
validatePaymentInput, validatePositiveAmount, validateIsoDate, validatePaymentSource, PAYMENT_SOURCES,
|
||||||
|
} = require('../services/paymentValidation');
|
||||||
|
|
||||||
|
test('happy path: normalizes dollars → integer cents and echoes the fields', () => {
|
||||||
|
const r = validatePaymentInput({ bill_id: '5', amount: 100, paid_date: '2026-07-01', payment_source: 'manual' });
|
||||||
|
assert.equal(r.error, undefined);
|
||||||
|
assert.deepEqual(r.normalized, { bill_id: 5, amount: 10000, paid_date: '2026-07-01', payment_source: 'manual' });
|
||||||
|
assert.equal(validatePaymentInput({ bill_id: 1, amount: '12.50', paid_date: '2026-01-31' }).normalized.amount, 1250, 'string dollars convert too');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('required fields: each missing field errors with its field tag', () => {
|
||||||
|
assert.deepEqual(pick(validatePaymentInput({ amount: 1, paid_date: '2026-07-01' })), { error: 'bill_id is required', field: 'bill_id' });
|
||||||
|
assert.deepEqual(pick(validatePaymentInput({ bill_id: 1, paid_date: '2026-07-01' })), { error: 'amount is required', field: 'amount' });
|
||||||
|
assert.deepEqual(pick(validatePaymentInput({ bill_id: 1, amount: 1 })), { error: 'paid_date is required', field: 'paid_date' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bill_id must be a positive integer', () => {
|
||||||
|
assert.match(validatePaymentInput({ bill_id: 'abc', amount: 1, paid_date: '2026-07-01' }).error, /positive integer/);
|
||||||
|
assert.match(validatePaymentInput({ bill_id: '-5', amount: 1, paid_date: '2026-07-01' }).error, /positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('amount must be a positive number (rejects 0, negative, non-numeric)', () => {
|
||||||
|
for (const bad of [0, -5, 'abc', NaN]) {
|
||||||
|
assert.match(validatePositiveAmount(bad).error, /positive number/, `rejects ${String(bad)}`);
|
||||||
|
}
|
||||||
|
assert.equal(validatePositiveAmount(100).value, 10000);
|
||||||
|
// NB: the validator enforces a positive lower bound but NO upper bound — a very
|
||||||
|
// large amount is accepted (64-bit INTEGER column). Pinned as intended behaviour.
|
||||||
|
assert.equal(validatePositiveAmount(100000000).value, 10000000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paid_date must be a real YYYY-MM-DD calendar date', () => {
|
||||||
|
assert.equal(validateIsoDate('2026-07-01').value, '2026-07-01');
|
||||||
|
assert.match(validateIsoDate('2026-13-01').error, /real calendar date/, 'month 13 rejected (format-valid, calendar-invalid)');
|
||||||
|
assert.match(validateIsoDate('2026-02-30').error, /real calendar date/, 'Feb 30 rejected');
|
||||||
|
assert.match(validateIsoDate('07/01/2026').error, /valid date/, 'wrong format rejected');
|
||||||
|
assert.match(validateIsoDate(20260701).error, /valid date/, 'non-string rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payment_source is whitelisted, and only validated when present', () => {
|
||||||
|
for (const s of PAYMENT_SOURCES) assert.equal(validatePaymentSource(s).value, s);
|
||||||
|
assert.match(validatePaymentSource('hacker').error, /must be one of/);
|
||||||
|
// absent → no error, not in normalized
|
||||||
|
assert.equal(validatePaymentInput({ bill_id: 1, amount: 1, paid_date: '2026-07-01' }).normalized.payment_source, undefined);
|
||||||
|
assert.equal(validatePaymentInput({ bill_id: 1, amount: 1, paid_date: '2026-07-01', payment_source: 'nope' }).error !== undefined, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('option matrix: require*=false skips an absent field but still validates a present one', () => {
|
||||||
|
const opts = { requireBillId: false, requireAmount: false, requirePaidDate: false };
|
||||||
|
assert.deepEqual(validatePaymentInput({ amount: 5 }, opts).normalized, { amount: 500 }, 'only the present field is normalized');
|
||||||
|
assert.equal(validatePaymentInput({}, opts).error, undefined, 'all-absent is valid when nothing is required');
|
||||||
|
// present-but-invalid is still caught even when not required
|
||||||
|
assert.match(validatePaymentInput({ bill_id: 'x' }, opts).error, /positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fieldPrefix namespaces the field tag (for bulk row errors)', () => {
|
||||||
|
const r = validatePaymentInput({ amount: 1, paid_date: '2026-07-01' }, { fieldPrefix: 'items.0.' });
|
||||||
|
assert.equal(r.field, 'items.0.bill_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick(r) { return { error: r.error, field: r.field }; }
|
||||||
Loading…
Reference in New Issue