Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions app/schemas/purchaseorderline.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,39 @@ 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.',
});

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.',
Expand Down
37 changes: 37 additions & 0 deletions tests/api/purchaseorderline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});