diff --git a/app/config/openapi.js b/app/config/openapi.js index 6dd19f0..a6af967 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -344,6 +344,42 @@ const spec = { }, }, }, + '/v1/customer/bulk': { + post: { + summary: 'Bulk-create customers (transaction-wrapped, all-or-nothing)', + description: + 'Body: `{ customers: [{...}, ...] }`. Each entry follows the ' + + 'same shape as POST /v1/customer. Capped at 500 entries; ' + + 'ETL jobs should chunk. If any entry fails to insert the ' + + 'whole transaction rolls back.', + security: [{ authKey: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + customers: { + type: 'array', + minItems: 1, + maxItems: 500, + items: { $ref: '#/components/schemas/Customer' }, + }, + }, + required: ['customers'], + }, + }, + }, + }, + responses: { + 201: { description: 'All customers created' }, + 400: { description: 'Validation failure (array empty / master without custCompId on some entry)' }, + 403: { description: 'Missing authKey or cross-tenant create attempt' }, + 500: { description: 'Transaction rolled back due to DB error' }, + }, + }, + }, '/v1/customer/search': { get: { summary: 'Search customers by substring (company-scoped)', diff --git a/app/controllers/customercontroller.js b/app/controllers/customercontroller.js index 6b12992..cc46ed8 100644 --- a/app/controllers/customercontroller.js +++ b/app/controllers/customercontroller.js @@ -251,6 +251,110 @@ exports.getAllByCompanyId = async (req, res) => { } }; +/** + * POST /v1/customer/bulk + * + * Transaction-wrapped batch create. Body: { customers: [{...}, ...] }. + * + * All-or-nothing semantics: if any customer fails to create (DB + * constraint, etc.), the whole batch is rolled back. The endpoint + * either returns 201 with the full set OR a 500/4xx with no rows + * inserted. + * + * Auth contract matches POST /v1/customer: + * - missing authKey -> 403 + * - non-master + entry with custCompId mismatching scope -> 403 + * - non-master without custCompId on any entry -> defaults to scope + * - master without custCompId on any entry -> 400 + */ +exports.bulkCreate = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let isAuthKeyMasterKey; + try { + isAuthKeyMasterKey = await IsMaster(authKey); + } catch (error) { + log.error({ err: error }, 'IsMaster failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + const inputCustomers = (req.body && Array.isArray(req.body.customers)) + ? req.body.customers + : []; + if (inputCustomers.length === 0) { + return res.status(400).json({ message: "customers array is required and must be non-empty." }); + } + + // Resolve authKey company once (only needed for non-master path). + let authKeyCompanyId = null; + if (!isAuthKeyMasterKey) { + try { + authKeyCompanyId = await GetCompanyId(authKey); + } catch (error) { + log.error({ err: error }, '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 + auth-scope each entry. + const ALLOWED_FIELDS = [ + 'custCompanyName', 'custFName', 'custLName', + 'custAddress1', 'custAddress2', + 'custCity', 'custState', 'custZip', + 'custPhone', 'custEmail', 'custCompId', + ]; + const payloads = []; + for (let i = 0; i < inputCustomers.length; i += 1) { + const entry = inputCustomers[i] || {}; + const p = {}; + for (const f of ALLOWED_FIELDS) { + if (entry[f] !== undefined) p[f] = entry[f]; + } + if (isAuthKeyMasterKey) { + if (p.custCompId === undefined || Number(p.custCompId) <= 0) { + return res.status(400).json({ + message: `customers[${i}]: master-key requests must specify custCompId.`, + }); + } + } else { + if (p.custCompId !== undefined && Number(p.custCompId) !== authKeyCompanyId) { + return res.status(403).json({ + message: `customers[${i}]: cannot create a customer for a company you do not belong to.`, + }); + } + p.custCompId = authKeyCompanyId; + } + p.custArch = false; + payloads.push(p); + } + + // All-or-nothing: bulk insert inside a transaction. + const t = await db.sequelize.transaction(); + try { + const created = await Customer.bulkCreate(payloads, { + transaction: t, + validate: true, + returning: true, + }); + await t.commit(); + return res.status(201).json({ + message: `Created ${created.length} customer(s).`, + count: created.length, + customers: created, + }); + } catch (error) { + try { await t.rollback(); } catch (_) { /* swallow */ } + log.error({ err: error }, 'Customer.bulkCreate failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + /** * GET /v1/customer/search * diff --git a/app/routers/router.js b/app/routers/router.js index c194bbd..4d47bd0 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -68,6 +68,11 @@ router.get( v.query(customerSchemas.searchQuery), customer.search, ); +router.post( + '/v1/customer/bulk', + v.body(customerSchemas.bulkCustomerBody), + customer.bulkCreate, +); router.get( '/v1/customer/:id', v.params(customerSchemas.intIdParam), diff --git a/app/schemas/customer.schema.js b/app/schemas/customer.schema.js index df15ac8..10a9093 100644 --- a/app/schemas/customer.schema.js +++ b/app/schemas/customer.schema.js @@ -37,6 +37,20 @@ const createCustomerBody = z.object({ message: 'Unexpected field in body. Whitelist: custCompanyName, custFName, custLName, custAddress1, custAddress2, custCity, custState, custZip, custPhone, custEmail, custCompId.', }); +/** + * POST /v1/customer/bulk body. Array of customer-create bodies wrapped + * in { customers: [...] }. Each entry is validated by the same + * createCustomerBody schema, so unknown fields are rejected uniformly. + * + * Capped at 500 entries to keep individual requests bounded; an ETL + * job pushing more should chunk. + */ +const bulkCustomerBody = z.object({ + customers: z.array(createCustomerBody).min(1).max(500), +}).strict({ + message: 'Unexpected field in body. Whitelist: customers (array).', +}); + const listByCompanyQuery = z.object({ limit: z.coerce.number().int().positive().max(500).optional(), offset: z.coerce.number().int().nonnegative().optional(), @@ -67,6 +81,7 @@ const searchQuery = z.object({ module.exports = { intIdParam, createCustomerBody, + bulkCustomerBody, listByCompanyQuery, searchQuery, }; diff --git a/tests/api/customer-bulk.test.js b/tests/api/customer-bulk.test.js new file mode 100644 index 0000000..861fefe --- /dev/null +++ b/tests/api/customer-bulk.test.js @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// HTTP tests for POST /v1/customer/bulk. Same mock-doesn't-intercept +// constraint — behavioral testing of the transaction roll-back path +// lives in the integration suite. This file covers: +// +// - auth contract (403 without header) +// - body validation (customers required, non-empty, capped at 500, +// unknown top-level fields rejected, each entry's fields whitelisted) +// - route is mounted (not 404) + +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: { + bulkCreate: vi.fn().mockResolvedValue([]), + findByPk: vi.fn(), findAll: vi.fn(), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn(), + }, + TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, + Company: {}, Job: {}, Invoice: {}, CustomerPayment: {}, + InvoiceJob: {}, ProductEntry: {}, VersionInfo: {}, + PurchaseOrderVendor: {}, PurchaseOrderHeader: {}, PurchaseOrderLine: {}, + InventoryTransaction: {}, + 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); +}); + +describe('POST /v1/customer/bulk auth contract', () => { + test('returns 403 when authKey header is missing', async () => { + const res = await request(app) + .post('/v1/customer/bulk') + .send({ customers: [{ custCompanyName: 'Acme' }] }); + expect(res.status).toBe(403); + }); +}); + +describe('POST /v1/customer/bulk body validation', () => { + test('400 when customers field is missing', async () => { + const res = await request(app) + .post('/v1/customer/bulk') + .set('authKey', 'any') + .send({}); + expect(res.status).toBe(400); + }); + + test('400 when customers is an empty array', async () => { + const res = await request(app) + .post('/v1/customer/bulk') + .set('authKey', 'any') + .send({ customers: [] }); + expect(res.status).toBe(400); + }); + + test('400 when an entry has an unknown field', async () => { + const res = await request(app) + .post('/v1/customer/bulk') + .set('authKey', 'any') + .send({ customers: [{ custCompanyName: 'Acme', bogus: 'no' }] }); + expect(res.status).toBe(400); + }); + + test('400 when a top-level unknown field is present', async () => { + const res = await request(app) + .post('/v1/customer/bulk') + .set('authKey', 'any') + .send({ + customers: [{ custCompanyName: 'Acme' }], + bogus: 'reject me', + }); + expect(res.status).toBe(400); + }); + + test('400 when batch exceeds the 500-entry cap', async () => { + const customers = Array.from({ length: 501 }, () => ({ custCompanyName: 'Acme' })); + const res = await request(app) + .post('/v1/customer/bulk') + .set('authKey', 'any') + .send({ customers }); + expect(res.status).toBe(400); + }); + + test('exactly 500 entries passes validation (boundary)', async () => { + const customers = Array.from({ length: 500 }, () => ({ custCompanyName: 'Acme' })); + const res = await request(app) + .post('/v1/customer/bulk') + .set('authKey', 'any') + .send({ customers }); + // We're past the zod gate; downstream may 4xx/500 from auth or DB, + // but it should NOT be the validation 400 with a 500-cap message. + expect(res.status).not.toBe(404); + }); +}); + +describe('POST /v1/customer/bulk route mounting', () => { + test('route is mounted (not 404)', async () => { + const res = await request(app) + .post('/v1/customer/bulk') + .set('authKey', 'any') + .send({ customers: [{ custCompanyName: 'Acme' }] }); + expect(res.body).toBeTypeOf('object'); + expect(res.body.message).toBeDefined(); + }); +});