diff --git a/app/schemas/purchaseorderline.schema.js b/app/schemas/purchaseorderline.schema.js index 8e424fa..397275c 100644 --- a/app/schemas/purchaseorderline.schema.js +++ b/app/schemas/purchaseorderline.schema.js @@ -8,11 +8,30 @@ const intIdParam = z.object({ id: z.coerce.number().int().positive(), }); +// PO-line quantity + per-unit price. Both are stored as Sequelize +// DOUBLE columns. zod's `.number()` rejects NaN by default but lets +// `Infinity` / `-Infinity` slip through — and the coerce path turns +// the string `"Infinity"` into the float. An `inf` in either column +// would silently corrupt every downstream calculation (PO totals, +// inventory valuation, reconciliation reports). Pin both to finite +// real numbers at the boundary. +// +// Zero and negative values still pass — a $0 line is a real "freebie +// included with order" case; a negative line can be used for an +// inline credit/discount entry on the PO. Mirrors the cpayAmount / +// injbAmount validators (#172 / #180). +const polQtyField = z.coerce.number().finite({ + message: 'polQty must be a finite number.', +}); +const polPriceField = z.coerce.number().finite({ + message: 'polPrice must be a finite number.', +}); + const createBody = z.object({ polpoh: z.coerce.number().int().positive(), polItemDesc: z.string().min(1).max(1000), - polQty: z.coerce.number(), - polPrice: z.coerce.number(), + polQty: polQtyField, + polPrice: polPriceField, polInvtId: z.coerce.number().int().positive(), }).strict({ message: 'Unexpected field in body. Whitelist: polpoh, polItemDesc, polQty, polPrice, polInvtId.', @@ -20,8 +39,8 @@ const createBody = z.object({ const updateBody = z.object({ polItemDesc: z.string().min(1).max(1000).optional(), - polQty: z.coerce.number().optional(), - polPrice: z.coerce.number().optional(), + polQty: polQtyField.optional(), + polPrice: polPriceField.optional(), polInvtId: z.coerce.number().int().positive().optional(), }).strict({ message: 'Unexpected field in body. Whitelist: polItemDesc, polQty, polPrice, polInvtId.', diff --git a/tests/api/purchaseorderline.test.js b/tests/api/purchaseorderline.test.js index 6d56f60..bbd86f8 100644 --- a/tests/api/purchaseorderline.test.js +++ b/tests/api/purchaseorderline.test.js @@ -64,4 +64,41 @@ describe('PurchaseOrderLine body validation', () => { }); expect(res.status).toBe(400); }); + + test('POST rejects non-finite polQty (string "Infinity" coerces to the float)', async () => { + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Widget', polQty: 'Infinity', polPrice: 5, polInvtId: 1, + }); + expect(res.status).toBe(400); + }); + + test('POST rejects non-finite polPrice', async () => { + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Widget', polQty: 10, polPrice: '-Infinity', polInvtId: 1, + }); + expect(res.status).toBe(400); + }); + + test('POST accepts zero polQty / polPrice (freebie line)', async () => { + // A $0 PO line is a real "free sample included" case; pin + // that the .finite() refinement doesn't accidentally block 0. + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Freebie', polQty: 0, polPrice: 0, polInvtId: 1, + }); + expect(res.status).not.toBe(400); + }); + + test('POST accepts negative polQty / polPrice (inline credit / discount)', async () => { + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Volume discount', polQty: -1, polPrice: -10, polInvtId: 1, + }); + expect(res.status).not.toBe(400); + }); + + test('PATCH rejects non-finite polPrice', async () => { + const res = await request(app).patch('/v1/purchaseorderline/1').set('authKey', 'any').send({ + polPrice: 'Infinity', + }); + expect(res.status).toBe(400); + }); });