2026-07-05 12:59:57 -05:00
|
|
|
'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 {
|
2026-07-06 14:23:53 -05:00
|
|
|
validatePaymentInput,
|
|
|
|
|
validatePositiveAmount,
|
|
|
|
|
validateIsoDate,
|
|
|
|
|
validatePaymentSource,
|
|
|
|
|
PAYMENT_SOURCES,
|
2026-07-05 13:51:37 -05:00
|
|
|
} = require('../services/paymentValidation.cts');
|
2026-07-05 12:59:57 -05:00
|
|
|
|
|
|
|
|
test('happy path: normalizes dollars → integer cents and echoes the fields', () => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const r = validatePaymentInput({
|
|
|
|
|
bill_id: '5',
|
|
|
|
|
amount: 100,
|
|
|
|
|
paid_date: '2026-07-01',
|
|
|
|
|
payment_source: 'manual',
|
|
|
|
|
});
|
2026-07-05 12:59:57 -05:00
|
|
|
assert.equal(r.error, undefined);
|
2026-07-06 14:23:53 -05:00
|
|
|
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',
|
|
|
|
|
);
|
2026-07-05 12:59:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('required fields: each missing field errors with its field tag', () => {
|
2026-07-06 14:23:53 -05:00
|
|
|
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',
|
|
|
|
|
});
|
2026-07-05 12:59:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('bill_id must be a positive integer', () => {
|
2026-07-06 14:23:53 -05:00
|
|
|
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/,
|
|
|
|
|
);
|
2026-07-05 12:59:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
2026-07-06 14:23:53 -05:00
|
|
|
assert.match(
|
|
|
|
|
validateIsoDate('2026-13-01').error,
|
|
|
|
|
/real calendar date/,
|
|
|
|
|
'month 13 rejected (format-valid, calendar-invalid)',
|
|
|
|
|
);
|
2026-07-05 12:59:57 -05:00
|
|
|
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
|
2026-07-06 14:23:53 -05:00
|
|
|
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,
|
|
|
|
|
);
|
2026-07-05 12:59:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('option matrix: require*=false skips an absent field but still validates a present one', () => {
|
|
|
|
|
const opts = { requireBillId: false, requireAmount: false, requirePaidDate: false };
|
2026-07-06 14:23:53 -05:00
|
|
|
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',
|
|
|
|
|
);
|
2026-07-05 12:59:57 -05:00
|
|
|
// 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)', () => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const r = validatePaymentInput(
|
|
|
|
|
{ amount: 1, paid_date: '2026-07-01' },
|
|
|
|
|
{ fieldPrefix: 'items.0.' },
|
|
|
|
|
);
|
2026-07-05 12:59:57 -05:00
|
|
|
assert.equal(r.field, 'items.0.bill_id');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function pick(r) {
|
|
|
|
|
return { error: r.error, field: r.field };
|
|
|
|
|
}
|