From df5771cae2c156fd3607cf55db4a7e2625daa747 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 19 May 2026 14:26:05 -0500 Subject: [PATCH] docs(openapi): declare bulkPath() 201 envelope for 12 factory endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulkPath() factory in `app/config/openapi.js` generates the spec entries for the 12 entities that go through `_bulk-helpers` (worker, billingtype, inventoryitem, inventorytransaction, purchaseordervendor, job, invoice, customerpayment, invoicejob, productentry, purchaseorderheader, purchaseorderline). Every one of those endpoints had a 201 response declaration with description + the Idempotency-Replay header but NO content schema — same drift fixed for customer/bulk in #332. The controllers (`makeBulkCreate` / `makeBulkCreateIndirect`) emit: { message, count, [bodyKey]: } …where the array key matches the request's bodyKey. Update the factory to declare that envelope once; all 12 endpoints inherit the fix. Add a sweep test in `tests/api/openapi.test.js` that walks the 12 factory-driven paths and pins (message, count, [bodyKey] → $ref schemaName) on each. One test, 12 assertions per — any of the 12 regressing fails CI together. Test count: 793 → 794. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/openapi.js | 24 ++++++++++++++++++++++++ tests/api/openapi.test.js | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/app/config/openapi.js b/app/config/openapi.js index b2a8679..42a1412 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -388,6 +388,30 @@ function bulkPath(bodyKey, schemaName) { 201: { description: 'All entries created (or a replay of a previously-cached create)', headers: idempotencyReplayResponseHeader, + // Controller (`_bulk-helpers.makeBulkCreate` / + // `makeBulkCreateIndirect`) emits {message, count, + // [bodyKey]: }. Same envelope every + // bulk endpoint uses — the convention is that the + // response's array key matches the request's + // bodyKey. Declaring the shape here means all 12 + // factory-driven bulk endpoints get the content + // schema in one place, parallel to the + // customer/bulk fix in #332. + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string' }, + count: { type: 'integer' }, + [bodyKey]: { + type: 'array', + items: { $ref: `#/components/schemas/${schemaName}` }, + }, + }, + }, + }, + }, }, 400: { description: 'Validation failure (array empty/capped, missing parent FK, master without scope)' }, 403: { description: 'Missing authKey or cross-tenant create attempt' }, diff --git a/tests/api/openapi.test.js b/tests/api/openapi.test.js index 3841705..55833c7 100644 --- a/tests/api/openapi.test.js +++ b/tests/api/openapi.test.js @@ -92,6 +92,43 @@ describe('OpenAPI spec', () => { expect(err.required).toEqual(['message']); }); + test('bulkPath factory declares the {message, count, [bodyKey]} envelope across all 12 factory-driven endpoints', async () => { + // The bulkPath() factory in app/config/openapi.js emits the + // schema for the 12 entities that use _bulk-helpers (worker, + // billingtype, inventoryitem, inventorytransaction, + // purchaseordervendor, job, invoice, customerpayment, + // invoicejob, productentry, purchaseorderheader, + // purchaseorderline). Customer/bulk uses a hand-rolled spec + // entry, fixed separately in #332. Pin the factory-side + // contract once so any of the 12 endpoints regressing fails + // CI as a group. + const res = await request(app).get('/openapi.json'); + const targets = [ + ['/v1/worker/bulk', 'workers', 'Worker'], + ['/v1/billingtype/bulk', 'billingTypes', 'BillingType'], + ['/v1/inventoryitem/bulk', 'inventoryItems', 'InventoryItem'], + ['/v1/inventorytransaction/bulk', 'inventoryTransactions','InventoryTransaction'], + ['/v1/purchaseordervendor/bulk', 'vendors', 'PurchaseOrderVendor'], + ['/v1/job/bulk', 'jobs', 'Job'], + ['/v1/invoice/bulk', 'invoices', 'Invoice'], + ['/v1/customerpayment/bulk', 'customerPayments', 'CustomerPayment'], + ['/v1/invoicejob/bulk', 'invoiceJobs', 'InvoiceJob'], + ['/v1/productentry/bulk', 'productEntries', 'ProductEntry'], + ['/v1/purchaseorderheader/bulk', 'purchaseOrderHeaders', 'PurchaseOrderHeader'], + ['/v1/purchaseorderline/bulk', 'purchaseOrderLines', 'PurchaseOrderLine'], + ]; + for (const [path, bodyKey, schemaName] of targets) { + const r201 = res.body.paths[path].post.responses['201']; + const schema = r201.content['application/json'].schema; + expect(schema.type, `${path} 201 should declare object`).toBe('object'); + expect(schema.properties.message).toBeDefined(); + expect(schema.properties.count.type).toBe('integer'); + expect(schema.properties[bodyKey], `${path} should declare ${bodyKey} array`).toBeDefined(); + expect(schema.properties[bodyKey].type).toBe('array'); + expect(schema.properties[bodyKey].items.$ref).toBe(`#/components/schemas/${schemaName}`); + } + }); + test('GET /v1/timeentry/bycompany/{id} 200 declares the {message, count, limit, offset, timeEntries} envelope', async () => { // Parallel to the customer/bycompany declaration in #340. // Pre-fix the spec said only `description: 'OK'`; SDK code-gen