From f16c80bb5f08bc5ef2824d0e3da8e5254ddedd02 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 18 May 2026 00:24:36 -0500 Subject: [PATCH] feat(api): bulk-create endpoints for 7 indirect-scoped entities (P3-H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to P3-H. The bulk surface was previously limited to the 5 entities with a direct *CompId column. This PR extends it to the remaining 7 soft-deletable entities, whose auth scope is resolved *through* a parent FK rather than carried directly: Customer-scoped (parent → Customer.custCompId): - POST /v1/job/bulk jobCustId - POST /v1/invoice/bulk invCustId - POST /v1/customerpayment/bulk cpayCustId Job-scoped (parent → Job → Customer.custCompId): - POST /v1/invoicejob/bulk injbJobId - POST /v1/productentry/bulk pentJobId Vendor/header-scoped: - POST /v1/purchaseorderheader/bulk pohPovId → vendor.povCompId - POST /v1/purchaseorderline/bulk polpoh → header → vendor Mechanics: - New factory `makeBulkCreateIndirect` in `app/controllers/_bulk-helpers.js` parameterizes over `parentFkField` + `resolveParentCompanyId(parentId)`. The 7 controllers gain ~10 LOC each instead of ~120. - Per-entry validation: parent FK is REQUIRED on every entry. For non-master keys the resolved parent company must equal the caller's company (else 403 with the offending index); master keys aren't pinned to a company so any resolved parent is fine. - Same 500-entry cap and transactional all-or-nothing insert as the direct family. Together with P3-H, the bulk surface now covers all 13 soft- deletable entities. Tests - `tests/api/bulk-indirect-scope.test.js`: 49 cases (7 entities x 7 assertions). Auth contract, outer-field 400s, empty/501-cap 400s, unknown-field 400, missing-parent-FK 400, route mounting. - Full suite: 469 pass / 4 skip (was 420/4 — +49 net new tests). - Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 ++ app/controllers/_bulk-helpers.js | 143 ++++++++++++++- app/controllers/customerpaymentcontroller.js | 12 ++ app/controllers/invoicecontroller.js | 12 ++ app/controllers/invoicejobcontroller.js | 12 ++ app/controllers/jobcontroller.js | 12 ++ app/controllers/productentrycontroller.js | 12 ++ .../purchaseorderheadercontroller.js | 12 ++ .../purchaseorderlinecontroller.js | 12 ++ app/routers/router.js | 35 ++++ app/schemas/customerpayment.schema.js | 7 + app/schemas/invoice.schema.js | 7 + app/schemas/invoicejob.schema.js | 7 + app/schemas/job.schema.js | 7 + app/schemas/productentry.schema.js | 7 + app/schemas/purchaseorderheader.schema.js | 7 + app/schemas/purchaseorderline.schema.js | 7 + tests/api/bulk-indirect-scope.test.js | 173 ++++++++++++++++++ 18 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 tests/api/bulk-indirect-scope.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c635c..21d92fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Bulk-create endpoints for 7 indirect-scoped entities** (P3-H2). + New `POST /v1//bulk` on Job, Invoice, CustomerPayment, + InvoiceJob, ProductEntry, PurchaseOrderHeader, PurchaseOrderLine. + Same 500-entry cap and transactional all-or-nothing semantics as + the direct-compId family from P3-H, but per-entry auth scope is + resolved through the parent FK (Customer / Job / Vendor / Header) + via the existing helpers in `app/middleware/auth.js`. A new + `makeBulkCreateIndirect` factory in + `app/controllers/_bulk-helpers.js` parameterizes over the parent + FK column + the auth-helper that resolves it; the 7 controllers + gain ~10 LOC each instead of ~120. The bulk surface now covers + **all 13 soft-deletable entities.** + ### Changed - **`app/middleware/auth.js` is now testable end-to-end** (P5-M). Two changes: diff --git a/app/controllers/_bulk-helpers.js b/app/controllers/_bulk-helpers.js index 06f4db6..dd4972f 100644 --- a/app/controllers/_bulk-helpers.js +++ b/app/controllers/_bulk-helpers.js @@ -127,4 +127,145 @@ function makeBulkCreate({ }; } -module.exports = { makeBulkCreate }; +/** + * Sibling factory for entities that scope auth INDIRECTLY through + * a parent FK rather than carrying their own *CompId column. + * + * Examples: + * - Job (jobCustId → Customer.custCompId) + * - Invoice (invCustId → Customer.custCompId) + * - CustomerPayment (cpayCustId → Customer.custCompId) + * - InvoiceJob (injbJobId → Job → Customer.custCompId) + * - ProductEntry (pentJobId → Job → Customer.custCompId) + * - PurchaseOrderHeader (pohPovId → PurchaseOrderVendor.povCompId) + * - PurchaseOrderLine (polpoh → PurchaseOrderHeader → vendor) + * + * Config (additions vs makeBulkCreate): + * - parentFkField the column on each entry that names the + * parent row whose scope governs (e.g. + * 'jobCustId', 'pohPovId', 'polpoh') + * - resolveParentCompanyId(parentId) + * async function returning the int company + * id, or -1 if the parent is missing/archived + * /unresolved (e.g. auth.getCompanyIdByCustomerId). + * - compIdField NOT used here — kept off the signature so + * callers don't confuse this with the direct + * version. If the entity grows its own column + * later, switch to makeBulkCreate. + * + * Per-entry contract: + * - parentFkField is REQUIRED on every entry. We need it to + * resolve scope; absent it we can't authorize the entry + * (return 400 with the offending index). + * - For non-master keys: the resolved parent company must equal + * authKey's company, else 403 with the offending index. Catches + * cross-tenant smuggling attempts in a single batch. + * - For master keys: the parent must resolve (404-style 400 if + * not), but master keys aren't pinned to a company so any + * resolved company is fine. + */ +function makeBulkCreateIndirect({ + Model, + modelKey, + parentFkField, + resolveParentCompanyId, + allowedFields, + archField, + bodyKey, + createdKey, +}) { + return async function bulkCreate(req, res) { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let isAuthKeyMasterKey; + try { + isAuthKeyMasterKey = await auth.isMaster(authKey); + } catch (error) { + log.error({ err: error }, `${modelKey}: isMaster failed`); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + const input = (req.body && Array.isArray(req.body[bodyKey])) + ? req.body[bodyKey] + : []; + if (input.length === 0) { + return res.status(400).json({ message: `${bodyKey} array is required and must be non-empty.` }); + } + + let authKeyCompanyId = null; + if (!isAuthKeyMasterKey) { + try { + authKeyCompanyId = await auth.getCompanyId(authKey); + } catch (error) { + log.error({ err: error }, `${modelKey}: getCompanyId failed`); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (authKeyCompanyId === -1) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + // Whitelist + per-entry parent-FK scope check. + const payloads = []; + for (let i = 0; i < input.length; i += 1) { + const entry = input[i] || {}; + const p = {}; + for (const f of allowedFields) { + if (entry[f] !== undefined) p[f] = entry[f]; + } + const parentId = Number(p[parentFkField]); + if (!Number.isInteger(parentId) || parentId <= 0) { + return res.status(400).json({ + message: `${bodyKey}[${i}]: ${parentFkField} is required.`, + }); + } + + let parentCompId; + try { + parentCompId = await resolveParentCompanyId(parentId); + } catch (error) { + log.error({ err: error }, `${modelKey}: parent scope resolve failed`); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (parentCompId === -1) { + return res.status(400).json({ + message: `${bodyKey}[${i}]: parent row not found or archived.`, + }); + } + + if (!isAuthKeyMasterKey && parentCompId !== authKeyCompanyId) { + return res.status(403).json({ + message: `${bodyKey}[${i}]: cannot create for a company you do not belong to.`, + }); + } + + p[archField] = false; + payloads.push(p); + } + + const t = await db.sequelize.transaction(); + try { + const created = await Model.bulkCreate(payloads, { + transaction: t, + validate: true, + returning: true, + }); + await t.commit(); + const responseBody = { + message: `Created ${created.length} ${modelKey}(s).`, + count: created.length, + }; + responseBody[createdKey] = created; + return res.status(201).json(responseBody); + } catch (error) { + try { await t.rollback(); } catch (_) { /* swallow */ } + log.error({ err: error }, `${modelKey}.bulkCreate failed`); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + }; +} + +module.exports = { makeBulkCreate, makeBulkCreateIndirect }; diff --git a/app/controllers/customerpaymentcontroller.js b/app/controllers/customerpaymentcontroller.js index 20a260c..823b5f1 100644 --- a/app/controllers/customerpaymentcontroller.js +++ b/app/controllers/customerpaymentcontroller.js @@ -6,6 +6,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreateIndirect } = require('./_bulk-helpers.js'); const CustomerPayment = db.CustomerPayment; const IsMaster = auth.isMaster; @@ -210,4 +211,15 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreateIndirect({ + Model: CustomerPayment, + modelKey: 'CustomerPayment', + parentFkField: 'cpayCustId', + resolveParentCompanyId: auth.getCompanyIdByCustomerId, + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'cpayArch', + bodyKey: 'customerPayments', + createdKey: 'customerPayments', +}); + exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId }; diff --git a/app/controllers/invoicecontroller.js b/app/controllers/invoicecontroller.js index be6cfaf..152b002 100644 --- a/app/controllers/invoicecontroller.js +++ b/app/controllers/invoicecontroller.js @@ -6,6 +6,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreateIndirect } = require('./_bulk-helpers.js'); const Invoice = db.Invoice; const IsMaster = auth.isMaster; @@ -211,4 +212,15 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreateIndirect({ + Model: Invoice, + modelKey: 'Invoice', + parentFkField: 'invCustId', + resolveParentCompanyId: auth.getCompanyIdByCustomerId, + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'invArch', + bodyKey: 'invoices', + createdKey: 'invoices', +}); + exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId }; diff --git a/app/controllers/invoicejobcontroller.js b/app/controllers/invoicejobcontroller.js index c3fe803..acc5d94 100644 --- a/app/controllers/invoicejobcontroller.js +++ b/app/controllers/invoicejobcontroller.js @@ -11,6 +11,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreateIndirect } = require('./_bulk-helpers.js'); const InvoiceJob = db.InvoiceJob; const IsMaster = auth.isMaster; @@ -229,4 +230,15 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreateIndirect({ + Model: InvoiceJob, + modelKey: 'InvoiceJob', + parentFkField: 'injbJobId', + resolveParentCompanyId: auth.getCompanyIdByJobId, + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'injbArch', + bodyKey: 'invoiceJobs', + createdKey: 'invoiceJobs', +}); + exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByJobId }; diff --git a/app/controllers/jobcontroller.js b/app/controllers/jobcontroller.js index e645aca..bcb195f 100644 --- a/app/controllers/jobcontroller.js +++ b/app/controllers/jobcontroller.js @@ -13,6 +13,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreateIndirect } = require('./_bulk-helpers.js'); const Job = db.Job; const IsMaster = auth.isMaster; @@ -219,4 +220,15 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreateIndirect({ + Model: Job, + modelKey: 'Job', + parentFkField: 'jobCustId', + resolveParentCompanyId: auth.getCompanyIdByCustomerId, + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'jobArch', + bodyKey: 'jobs', + createdKey: 'jobs', +}); + exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByCustomerId }; diff --git a/app/controllers/productentrycontroller.js b/app/controllers/productentrycontroller.js index 18a3427..2e40264 100644 --- a/app/controllers/productentrycontroller.js +++ b/app/controllers/productentrycontroller.js @@ -6,6 +6,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreateIndirect } = require('./_bulk-helpers.js'); const ProductEntry = db.ProductEntry; const IsMaster = auth.isMaster; @@ -207,4 +208,15 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreateIndirect({ + Model: ProductEntry, + modelKey: 'ProductEntry', + parentFkField: 'pentJobId', + resolveParentCompanyId: auth.getCompanyIdByJobId, + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'penArch', + bodyKey: 'productEntries', + createdKey: 'productEntries', +}); + exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByJobId }; diff --git a/app/controllers/purchaseorderheadercontroller.js b/app/controllers/purchaseorderheadercontroller.js index ca7d9e7..5ef7d33 100644 --- a/app/controllers/purchaseorderheadercontroller.js +++ b/app/controllers/purchaseorderheadercontroller.js @@ -11,6 +11,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreateIndirect } = require('./_bulk-helpers.js'); const PurchaseOrderHeader = db.PurchaseOrderHeader; const IsMaster = auth.isMaster; @@ -210,4 +211,15 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreateIndirect({ + Model: PurchaseOrderHeader, + modelKey: 'PurchaseOrderHeader', + parentFkField: 'pohPovId', + resolveParentCompanyId: auth.getCompanyIdByPovId, + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'pohArch', + bodyKey: 'purchaseOrderHeaders', + createdKey: 'purchaseOrderHeaders', +}); + exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByPovId }; diff --git a/app/controllers/purchaseorderlinecontroller.js b/app/controllers/purchaseorderlinecontroller.js index db6f7b8..687bc92 100644 --- a/app/controllers/purchaseorderlinecontroller.js +++ b/app/controllers/purchaseorderlinecontroller.js @@ -11,6 +11,7 @@ const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); const { buildLinkHeader } = require('../middleware/pagination.js'); +const { makeBulkCreateIndirect } = require('./_bulk-helpers.js'); const PurchaseOrderLine = db.PurchaseOrderLine; const IsMaster = auth.isMaster; @@ -210,4 +211,15 @@ exports.remove = async (req, res) => { } }; +exports.bulkCreate = makeBulkCreateIndirect({ + Model: PurchaseOrderLine, + modelKey: 'PurchaseOrderLine', + parentFkField: 'polpoh', + resolveParentCompanyId: auth.getCompanyIdByPohId, + allowedFields: ALLOWED_FIELDS_CREATE, + archField: 'polArch', + bodyKey: 'purchaseOrderLines', + createdKey: 'purchaseOrderLines', +}); + exports._internals = { IsMaster, GetCompanyId, GetCompanyIdByPohId }; diff --git a/app/routers/router.js b/app/routers/router.js index d679d2a..49b2d3a 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -296,6 +296,11 @@ router.delete( ); // v1 job routes. Customer-scoped via jobCustId → Customer.custCompId. +router.post( + '/v1/job/bulk', + v.body(jobSchemas.bulkJobBody), + job.bulkCreate, +); router.post( '/v1/job', v.body(jobSchemas.createJobBody), @@ -325,6 +330,11 @@ router.delete( ); // v1 invoice routes. +router.post( + '/v1/invoice/bulk', + v.body(invoiceSchemas.bulkInvoiceBody), + invoice.bulkCreate, +); router.post( '/v1/invoice', v.body(invoiceSchemas.createInvoiceBody), @@ -354,6 +364,11 @@ router.delete( ); // v1 customerpayment routes. +router.post( + '/v1/customerpayment/bulk', + v.body(customerPaymentSchemas.bulkCustomerPaymentBody), + customerPayment.bulkCreate, +); router.post( '/v1/customerpayment', v.body(customerPaymentSchemas.createCustomerPaymentBody), @@ -383,6 +398,11 @@ router.delete( ); // v1 invoicejob routes. Job-scoped via injbJobId → Job.jobCustId → Customer.custCompId. +router.post( + '/v1/invoicejob/bulk', + v.body(invoiceJobSchemas.bulkInvoiceJobBody), + invoiceJob.bulkCreate, +); router.post( '/v1/invoicejob', v.body(invoiceJobSchemas.createInvoiceJobBody), @@ -412,6 +432,11 @@ router.delete( ); // v1 productentry routes. +router.post( + '/v1/productentry/bulk', + v.body(productEntrySchemas.bulkProductEntryBody), + productEntry.bulkCreate, +); router.post( '/v1/productentry', v.body(productEntrySchemas.createProductEntryBody), @@ -504,6 +529,11 @@ router.delete( ); // v1 purchaseorderheader routes. Vendor-scoped via pohPovId → povCompId. +router.post( + '/v1/purchaseorderheader/bulk', + v.body(purchaseOrderHeaderSchemas.bulkBody), + purchaseOrderHeader.bulkCreate, +); router.post( '/v1/purchaseorderheader', v.body(purchaseOrderHeaderSchemas.createBody), @@ -533,6 +563,11 @@ router.delete( ); // v1 purchaseorderline routes. Header-scoped via polpoh → header → vendor.povCompId. +router.post( + '/v1/purchaseorderline/bulk', + v.body(purchaseOrderLineSchemas.bulkBody), + purchaseOrderLine.bulkCreate, +); router.post( '/v1/purchaseorderline', v.body(purchaseOrderLineSchemas.createBody), diff --git a/app/schemas/customerpayment.schema.js b/app/schemas/customerpayment.schema.js index 5c33af9..b3c65dd 100644 --- a/app/schemas/customerpayment.schema.js +++ b/app/schemas/customerpayment.schema.js @@ -36,9 +36,16 @@ const listByCustomerQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkCustomerPaymentBody = z.object({ + customerPayments: z.array(createCustomerPaymentBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: customerPayments (array).', +}); + module.exports = { intIdParam, createCustomerPaymentBody, updateCustomerPaymentBody, listByCustomerQuery, + bulkCustomerPaymentBody, }; diff --git a/app/schemas/invoice.schema.js b/app/schemas/invoice.schema.js index 49f6090..1e05d13 100644 --- a/app/schemas/invoice.schema.js +++ b/app/schemas/invoice.schema.js @@ -36,9 +36,16 @@ const listByCustomerQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkInvoiceBody = z.object({ + invoices: z.array(createInvoiceBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: invoices (array).', +}); + module.exports = { intIdParam, createInvoiceBody, updateInvoiceBody, listByCustomerQuery, + bulkInvoiceBody, }; diff --git a/app/schemas/invoicejob.schema.js b/app/schemas/invoicejob.schema.js index 1a61da3..6c4a399 100644 --- a/app/schemas/invoicejob.schema.js +++ b/app/schemas/invoicejob.schema.js @@ -29,9 +29,16 @@ const listByInvoiceQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkInvoiceJobBody = z.object({ + invoiceJobs: z.array(createInvoiceJobBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: invoiceJobs (array).', +}); + module.exports = { intIdParam, createInvoiceJobBody, updateInvoiceJobBody, listByInvoiceQuery, + bulkInvoiceJobBody, }; diff --git a/app/schemas/job.schema.js b/app/schemas/job.schema.js index e989550..7e0df8c 100644 --- a/app/schemas/job.schema.js +++ b/app/schemas/job.schema.js @@ -29,9 +29,16 @@ const listByCustomerQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkJobBody = z.object({ + jobs: z.array(createJobBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: jobs (array).', +}); + module.exports = { intIdParam, createJobBody, updateJobBody, listByCustomerQuery, + bulkJobBody, }; diff --git a/app/schemas/productentry.schema.js b/app/schemas/productentry.schema.js index 8eedc0b..6635bc9 100644 --- a/app/schemas/productentry.schema.js +++ b/app/schemas/productentry.schema.js @@ -32,9 +32,16 @@ const listByJobQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkProductEntryBody = z.object({ + productEntries: z.array(createProductEntryBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: productEntries (array).', +}); + module.exports = { intIdParam, createProductEntryBody, updateProductEntryBody, listByJobQuery, + bulkProductEntryBody, }; diff --git a/app/schemas/purchaseorderheader.schema.js b/app/schemas/purchaseorderheader.schema.js index 3cf9af4..095c1aa 100644 --- a/app/schemas/purchaseorderheader.schema.js +++ b/app/schemas/purchaseorderheader.schema.js @@ -37,9 +37,16 @@ const listByVendorQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkBody = z.object({ + purchaseOrderHeaders: z.array(createBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: purchaseOrderHeaders (array).', +}); + module.exports = { intIdParam, createBody, updateBody, listByVendorQuery, + bulkBody, }; diff --git a/app/schemas/purchaseorderline.schema.js b/app/schemas/purchaseorderline.schema.js index 947f086..8e424fa 100644 --- a/app/schemas/purchaseorderline.schema.js +++ b/app/schemas/purchaseorderline.schema.js @@ -34,9 +34,16 @@ const listByHeaderQuery = z.object({ message: 'Unexpected query parameter. Allowed: limit, offset.', }); +const bulkBody = z.object({ + purchaseOrderLines: z.array(createBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: purchaseOrderLines (array).', +}); + module.exports = { intIdParam, createBody, updateBody, listByHeaderQuery, + bulkBody, }; diff --git a/tests/api/bulk-indirect-scope.test.js b/tests/api/bulk-indirect-scope.test.js new file mode 100644 index 0000000..492e830 --- /dev/null +++ b/tests/api/bulk-indirect-scope.test.js @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// HTTP smoke tests for the 7 indirect-scoped bulk endpoints added in P3-H2: +// POST /v1/job/bulk customer-scoped +// POST /v1/invoice/bulk customer-scoped +// POST /v1/customerpayment/bulk customer-scoped +// POST /v1/invoicejob/bulk job-scoped +// POST /v1/productentry/bulk job-scoped +// POST /v1/purchaseorderheader/bulk vendor-scoped +// POST /v1/purchaseorderline/bulk header-scoped +// +// All seven share app/controllers/_bulk-helpers.js#makeBulkCreateIndirect, +// which parameterizes over the parent-FK column and the auth helper +// that resolves that FK to a company id. +// +// Coverage: +// - auth contract (403 without authKey) +// - schema validation: missing outer field, empty array, 501-cap, +// unknown top-level field, missing parent FK on an entry +// - route mounting (not 404) +// +// Per-entry parent-row-resolved-to-different-company-than-caller and +// transactional roll-back paths require the real DB and live in the +// integration suite. + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { + query: vi.fn().mockResolvedValue([]), + transaction: vi.fn().mockResolvedValue({ + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + }), + QueryTypes: { SELECT: 'SELECT' }, + }, + Sequelize: { Op: {} }, + Customer: {}, + Job: { bulkCreate: vi.fn().mockResolvedValue([]) }, + Invoice: { bulkCreate: vi.fn().mockResolvedValue([]) }, + CustomerPayment: { bulkCreate: vi.fn().mockResolvedValue([]) }, + InvoiceJob: { bulkCreate: vi.fn().mockResolvedValue([]) }, + ProductEntry: { bulkCreate: vi.fn().mockResolvedValue([]) }, + PurchaseOrderHeader: { bulkCreate: vi.fn().mockResolvedValue([]) }, + PurchaseOrderLine: { bulkCreate: vi.fn().mockResolvedValue([]) }, + Worker: {}, BillingType: {}, InventoryItem: {}, Company: {}, + PurchaseOrderVendor: {}, InventoryTransaction: {}, TimeEntry: {}, + VersionInfo: {}, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +// path, bodyKey, sampleEntry (must satisfy createBody), parentFkField +const ENTITIES = [ + [ + '/v1/job/bulk', + 'jobs', + { jobCustId: 5, jobDesc: 'Job A' }, + 'jobCustId', + ], + [ + '/v1/invoice/bulk', + 'invoices', + { invCustId: 5, invDate: '2026-05-18', invDueDate: '2026-06-18' }, + 'invCustId', + ], + [ + '/v1/customerpayment/bulk', + 'customerPayments', + { cpayCustId: 5, cpayDate: '2026-05-18', cpayAmount: 100 }, + 'cpayCustId', + ], + [ + '/v1/invoicejob/bulk', + 'invoiceJobs', + { injbInvId: 1, injbJobId: 1, injbAmount: 250 }, + 'injbJobId', + ], + [ + '/v1/productentry/bulk', + 'productEntries', + { pentQty: 3, pentJobId: 1, pentInvtId: 1 }, + 'pentJobId', + ], + [ + '/v1/purchaseorderheader/bulk', + 'purchaseOrderHeaders', + { + pohDate: '2026-05-18T12:00:00Z', + pohReference: 'PO-1', + pohTerms: 'Net 30', + pohPovId: 1, + }, + 'pohPovId', + ], + [ + '/v1/purchaseorderline/bulk', + 'purchaseOrderLines', + { polpoh: 1, polItemDesc: 'Widget', polQty: 2, polPrice: 10, polInvtId: 1 }, + 'polpoh', + ], +]; + +describe('indirect-scoped bulk endpoints: auth contract', () => { + test.each(ENTITIES)('POST %s returns 403 when authKey header is missing', async (path, bodyKey, sample) => { + const body = {}; + body[bodyKey] = [sample]; + const res = await request(app).post(path).send(body); + expect(res.status).toBe(403); + }); +}); + +describe('indirect-scoped bulk endpoints: body validation', () => { + test.each(ENTITIES)('POST %s 400 when outer field is missing', async (path) => { + const res = await request(app).post(path).set('authKey', 'any').send({}); + expect(res.status).toBe(400); + }); + + test.each(ENTITIES)('POST %s 400 when array is empty', async (path, bodyKey) => { + const body = {}; + body[bodyKey] = []; + const res = await request(app).post(path).set('authKey', 'any').send(body); + expect(res.status).toBe(400); + }); + + test.each(ENTITIES)('POST %s 400 when batch exceeds 500-entry cap', async (path, bodyKey, sample) => { + const body = {}; + body[bodyKey] = new Array(501).fill(sample); + const res = await request(app).post(path).set('authKey', 'any').send(body); + expect(res.status).toBe(400); + }); + + test.each(ENTITIES)('POST %s 400 when a top-level unknown field is present', async (path, bodyKey, sample) => { + const body = {}; + body[bodyKey] = [sample]; + body.bogus = 'no'; + const res = await request(app).post(path).set('authKey', 'any').send(body); + expect(res.status).toBe(400); + }); + + test.each(ENTITIES)('POST %s 400 when an entry omits the parent FK %s', async (path, bodyKey, sample, fkField) => { + const entry = { ...sample }; + delete entry[fkField]; + const body = {}; + body[bodyKey] = [entry]; + const res = await request(app).post(path).set('authKey', 'any').send(body); + // The zod schema requires the parent FK as a positive integer; + // missing it fails at the validate layer (400) rather than at + // the controller's per-entry check. + expect(res.status).toBe(400); + }); +}); + +describe('indirect-scoped bulk endpoints: route mounting (not 404)', () => { + test.each(ENTITIES)('POST %s reaches the validator/controller (not a missing route)', async (path, bodyKey, sample) => { + const body = {}; + body[bodyKey] = [sample]; + const res = await request(app).post(path).set('authKey', 'any').send(body); + expect(res.status).not.toBe(404); + }); +});