diff --git a/app/schemas/inventoryitem.schema.js b/app/schemas/inventoryitem.schema.js index db83f13..e55f1ed 100644 --- a/app/schemas/inventoryitem.schema.js +++ b/app/schemas/inventoryitem.schema.js @@ -8,9 +8,25 @@ const intIdParam = z.object({ id: z.coerce.number().int().positive(), }); +// `invitQty` is the on-hand inventory quantity stored as a Sequelize +// DOUBLE column. zod's `.number()` rejects NaN by default but allows +// `Infinity` / `-Infinity` — and the coerce path turns the string +// `"Infinity"` into the float. An `inf` qty silently corrupts every +// downstream consumer that does arithmetic against it (PO line +// receiving, inventory-transaction net-position, valuation reports). +// +// Add `.finite()` at the boundary. Zero stays valid (a stocked item +// momentarily at 0 on-hand) and negatives stay valid (backorders, or +// historical fractional reconciliation entries that some accounting +// flows allow). Mirrors the polQtyField / polPriceField validators +// from #194 and btHourlyRateField from #206. +const invitQtyField = z.coerce.number().finite({ + message: 'invitQty must be a finite number.', +}); + const createInventoryItemBody = z.object({ invitDescription: z.string().min(1).max(1000), - invitQty: z.coerce.number(), + invitQty: invitQtyField, invitCompId: z.coerce.number().int().positive().optional(), }).strict({ message: 'Unexpected field in body. Whitelist: invitDescription, invitQty, invitCompId.', @@ -18,7 +34,7 @@ const createInventoryItemBody = z.object({ const updateInventoryItemBody = z.object({ invitDescription: z.string().min(1).max(1000).optional(), - invitQty: z.coerce.number().optional(), + invitQty: invitQtyField.optional(), }).strict({ message: 'Unexpected field in body. Whitelist: invitDescription, invitQty.', }); diff --git a/tests/api/inventoryitem.test.js b/tests/api/inventoryitem.test.js index ee92d1c..e5cbfe6 100644 --- a/tests/api/inventoryitem.test.js +++ b/tests/api/inventoryitem.test.js @@ -75,6 +75,40 @@ describe('InventoryItem body validation', () => { .send({ invitDescription: 'X' }); expect(res.status).toBe(400); }); + + test('POST rejects non-finite invitQty (string "Infinity" coerces to the float)', async () => { + const res = await request(app) + .post('/v1/inventoryitem') + .set('authKey', 'any') + .send({ invitDescription: 'Widget', invitQty: 'Infinity' }); + expect(res.status).toBe(400); + }); + + test('POST accepts zero invitQty (item at 0 on-hand)', async () => { + // Pin that the .finite() guard doesn't accidentally block 0 — + // an item momentarily out of stock should still be modelable. + const res = await request(app) + .post('/v1/inventoryitem') + .set('authKey', 'any') + .send({ invitDescription: 'Widget', invitQty: 0 }); + expect(res.status).not.toBe(400); + }); + + test('POST accepts negative invitQty (backorder / reconciliation entries)', async () => { + const res = await request(app) + .post('/v1/inventoryitem') + .set('authKey', 'any') + .send({ invitDescription: 'Backorder', invitQty: -3 }); + expect(res.status).not.toBe(400); + }); + + test('PATCH rejects -Infinity invitQty', async () => { + const res = await request(app) + .patch('/v1/inventoryitem/1') + .set('authKey', 'any') + .send({ invitQty: '-Infinity' }); + expect(res.status).toBe(400); + }); }); describe('InventoryItem tenant-enumeration defense (secure 404)', () => {