Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/config/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
104 changes: 104 additions & 0 deletions app/controllers/customercontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
5 changes: 5 additions & 0 deletions app/routers/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
15 changes: 15 additions & 0 deletions app/schemas/customer.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -67,6 +81,7 @@ const searchQuery = z.object({
module.exports = {
intIdParam,
createCustomerBody,
bulkCustomerBody,
listByCompanyQuery,
searchQuery,
};
126 changes: 126 additions & 0 deletions tests/api/customer-bulk.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading