diff --git a/tests/paymentValidation.test.js b/tests/paymentValidation.test.js new file mode 100644 index 0000000..bf64cd9 --- /dev/null +++ b/tests/paymentValidation.test.js @@ -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 }; }