Tighten PO payload validator (disc/vat range, raw_ocr shape, PB filter escape)
- Validate disc_percent and vat_percent are finite numbers in [0, 100] - Reject raw_ocr arrays (was passing typeof === 'object' check) - Escape backslashes before quotes in matchSite's PocketBase filter Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
108b17b556
commit
75770073a6
2 changed files with 51 additions and 2 deletions
|
|
@ -56,6 +56,37 @@ describe("validatePurchaseOrderPayload", () => {
|
|||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects item with disc_percent > 100", () => {
|
||||
const result = validatePurchaseOrderPayload({
|
||||
...VALID_PAYLOAD,
|
||||
items: [{ description: "x", qty: 1, rate: 10, disc_percent: 200 }],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.details?.items).toMatch(/disc_percent/);
|
||||
});
|
||||
|
||||
it("rejects item with disc_percent that is not a number", () => {
|
||||
const result = validatePurchaseOrderPayload({
|
||||
...VALID_PAYLOAD,
|
||||
items: [{ description: "x", qty: 1, rate: 10, disc_percent: "foo" }],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects item with negative vat_percent", () => {
|
||||
const result = validatePurchaseOrderPayload({
|
||||
...VALID_PAYLOAD,
|
||||
items: [{ description: "x", qty: 1, rate: 10, vat_percent: -5 }],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects raw_ocr that is an array", () => {
|
||||
const result = validatePurchaseOrderPayload({ ...VALID_PAYLOAD, raw_ocr: [] });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.details?.raw_ocr).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects bad date format", () => {
|
||||
const result = validatePurchaseOrderPayload({
|
||||
...VALID_PAYLOAD,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,24 @@ export function validatePurchaseOrderPayload(input: unknown): ValidationResult {
|
|||
if (typeof it.rate !== "number" || it.rate < 0) {
|
||||
itemErrors.push(`items[${i}].rate: must be a number >= 0`);
|
||||
}
|
||||
if (
|
||||
it.disc_percent !== undefined &&
|
||||
(typeof it.disc_percent !== "number" ||
|
||||
!Number.isFinite(it.disc_percent) ||
|
||||
it.disc_percent < 0 ||
|
||||
it.disc_percent > 100)
|
||||
) {
|
||||
itemErrors.push(`items[${i}].disc_percent: must be a number in [0, 100]`);
|
||||
}
|
||||
if (
|
||||
it.vat_percent !== undefined &&
|
||||
(typeof it.vat_percent !== "number" ||
|
||||
!Number.isFinite(it.vat_percent) ||
|
||||
it.vat_percent < 0 ||
|
||||
it.vat_percent > 100)
|
||||
) {
|
||||
itemErrors.push(`items[${i}].vat_percent: must be a number in [0, 100]`);
|
||||
}
|
||||
});
|
||||
if (itemErrors.length > 0) {
|
||||
details.items = itemErrors.join("; ");
|
||||
|
|
@ -113,7 +131,7 @@ export function validatePurchaseOrderPayload(input: unknown): ValidationResult {
|
|||
}
|
||||
}
|
||||
|
||||
if (p.raw_ocr === undefined || p.raw_ocr === null || typeof p.raw_ocr !== "object") {
|
||||
if (p.raw_ocr === undefined || p.raw_ocr === null || typeof p.raw_ocr !== "object" || Array.isArray(p.raw_ocr)) {
|
||||
details.raw_ocr = "must be an object";
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +168,7 @@ type PbLike = {
|
|||
};
|
||||
|
||||
export async function matchSite(pb: PbLike, projectCode: string): Promise<SiteMatchResult> {
|
||||
const safe = projectCode.replace(/"/g, '\\"');
|
||||
const safe = projectCode.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
|
||||
const exact = await pb.collection("sites").getFullList({
|
||||
filter: `project_code = "${safe}"`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue