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:
null 2026-07-05 12:59:57 -05:00
parent b3ce334987
commit 3c2f6ca3b5
1 changed files with 71 additions and 0 deletions

View File

@ -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 }; }