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