From 342958816ba58694655faa471a2ba257223a5dc4 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Sun, 17 May 2026 18:48:49 -0500 Subject: [PATCH] feat(api): PurchaseOrderVendor endpoints (1 of 4 newly-migrated tables) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full CRUD for the vendors a Company issues purchase orders to. POST /v1/purchaseordervendor GET /v1/purchaseordervendor/:id GET /v1/purchaseordervendor/bycompany/:id (paginated) PATCH /v1/purchaseordervendor/:id DELETE /v1/purchaseordervendor/:id (soft-delete via povArch) Direct compId scoping via povCompId — same auth shape as Worker/BillingType/InventoryItem. Schema whitelist enforced by zod at the middleware boundary AND a server-side ALLOWED_FIELDS allowlist in the controller (mass-assignment defense in depth). povCompId is not patchable post-create (would amount to moving a vendor between companies, which would break auth invariants). This is 1 of 4 endpoints for the tables added by the 20260517000000-purchase-orders-and-archive-columns migration. Vendors first because PurchaseOrderHeaders FK-references this table; headers and lines will follow in their own PR. The fourth table, InventoryTransactions, is unrelated and gets its own PR too. Tests: 25 files / 175 tests (was 24 / 167). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/db.config.js | 1 + app/config/openapi.js | 50 ++++ .../purchaseordervendorcontroller.js | 235 ++++++++++++++++++ app/models/purchaseordervendor.model.js | 42 ++++ app/routers/router.js | 31 +++ app/schemas/purchaseordervendor.schema.js | 59 +++++ tests/api/purchaseordervendor.test.js | 64 +++++ 7 files changed, 482 insertions(+) create mode 100644 app/controllers/purchaseordervendorcontroller.js create mode 100644 app/models/purchaseordervendor.model.js create mode 100644 app/schemas/purchaseordervendor.schema.js create mode 100644 tests/api/purchaseordervendor.test.js diff --git a/app/config/db.config.js b/app/config/db.config.js index 2fd455f..68a0fd4 100644 --- a/app/config/db.config.js +++ b/app/config/db.config.js @@ -33,5 +33,6 @@ db.CustomerPayment = require('../models/customerpayment.model.js')(sequelize, Se db.InvoiceJob = require('../models/invoicejob.model.js')(sequelize, Sequelize); db.ProductEntry = require('../models/productentry.model.js')(sequelize, Sequelize); db.VersionInfo = require('../models/versioninfo.model.js')(sequelize, Sequelize); +db.PurchaseOrderVendor = require('../models/purchaseordervendor.model.js')(sequelize, Sequelize); module.exports = db; diff --git a/app/config/openapi.js b/app/config/openapi.js index e03c5a0..2b8bcd0 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -171,6 +171,30 @@ const versionInfoSchema = { }, }; +const purchaseOrderVendorSchema = { + type: 'object', + properties: { + povId: { type: 'integer', readOnly: true }, + povName: { type: 'string' }, + povMailingAddress1: { type: 'string' }, + povMailingAddress2: { type: 'string' }, + povMailingCity: { type: 'string' }, + povMailingState: { type: 'string' }, + povMailingCountry: { type: 'string' }, + povMailingZip: { type: 'string' }, + povBillingAddress1: { type: 'string' }, + povBillingAddress2: { type: 'string' }, + povBillingCity: { type: 'string' }, + povBillingState: { type: 'string' }, + povBillingCountry: { type: 'string' }, + povBillingZip: { type: 'string' }, + povPhone: { type: 'string' }, + povEMail: { type: 'string', format: 'email' }, + povCompId: { type: 'integer' }, + povArch: { type: 'boolean', readOnly: true }, + }, +}; + const timeEntrySchema = { type: 'object', properties: { @@ -220,6 +244,7 @@ const spec = { InvoiceJob: invoiceJobSchema, ProductEntry: productEntrySchema, VersionInfo: versionInfoSchema, + PurchaseOrderVendor: purchaseOrderVendorSchema, Error: errorResponse, }, }, @@ -664,6 +689,31 @@ const spec = { patch: { summary: 'Partial update of a version info (master keys only)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionInfo' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Non-master key' } } }, delete: { summary: 'Hard-delete a version info (master keys only — no archive column on this table)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Deleted' }, 404: { description: 'Not found' }, 403: { description: 'Non-master key' } } }, }, + '/v1/purchaseordervendor': { + post: { + summary: 'Create a PO vendor', + security: [{ authKey: [] }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderVendor' } } } }, + responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, + }, + }, + '/v1/purchaseordervendor/{id}': { + get: { summary: 'Get one PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + patch: { summary: 'Partial update of a PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderVendor' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + delete: { summary: 'Soft-delete a PO vendor', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, + }, + '/v1/purchaseordervendor/bycompany/{id}': { + get: { + summary: 'List PO vendors in a company (paginated)', + security: [{ authKey: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + ], + responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } }, + }, + }, }, }; diff --git a/app/controllers/purchaseordervendorcontroller.js b/app/controllers/purchaseordervendorcontroller.js new file mode 100644 index 0000000..7ecca7b --- /dev/null +++ b/app/controllers/purchaseordervendorcontroller.js @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * PurchaseOrderVendor controller — direct compId scoping via povCompId. + * Same auth shape as Worker/BillingType/InventoryItem. + */ + +const db = require('../config/db.config.js'); +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); +const PurchaseOrderVendor = db.PurchaseOrderVendor; + +const IsMaster = auth.isMaster; +const GetCompanyId = auth.getCompanyId; + +// The schema whitelist already validates fields; we re-state it here so +// the controller doesn't trust the request body verbatim (mass-assignment +// defense — a forged povId in the body wouldn't make it through this +// allowlist even if the schema were bypassed). +const ALLOWED_FIELDS_CREATE = [ + 'povName', 'povMailingAddress1', 'povMailingAddress2', 'povMailingCity', + 'povMailingState', 'povMailingCountry', 'povMailingZip', + 'povBillingAddress1', 'povBillingAddress2', 'povBillingCity', + 'povBillingState', 'povBillingCountry', 'povBillingZip', + 'povPhone', 'povEMail', 'povCompId', +]; +const ALLOWED_FIELDS_UPDATE = ALLOWED_FIELDS_CREATE.filter(f => f !== 'povCompId'); + +exports.create = 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 body = req.body || {}; + const payload = {}; + for (const f of ALLOWED_FIELDS_CREATE) { + if (body[f] !== undefined) payload[f] = body[f]; + } + + if (!isAuthKeyMasterKey) { + let authKeyCompanyId; + 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." }); + } + if (payload.povCompId !== undefined && Number(payload.povCompId) !== authKeyCompanyId) { + return res.status(403).json({ + message: "Cannot create a PO vendor for a company you do not belong to.", + }); + } + payload.povCompId = authKeyCompanyId; + } else { + if (payload.povCompId === undefined || Number(payload.povCompId) <= 0) { + return res.status(400).json({ + message: "Master-key requests must specify povCompId.", + }); + } + } + + payload.povArch = false; + + try { + const created = await PurchaseOrderVendor.create(payload); + return res.status(201).json({ message: "PO vendor created.", purchaseOrderVendor: created }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderVendor.create failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.getById = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let vendor; + try { + vendor = await PurchaseOrderVendor.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!vendor || vendor.povArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || vendor.povCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + return res.status(200).json({ message: "Found.", purchaseOrderVendor: vendor }); +}; + +exports.listByCompany = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + const targetCompanyId = Number(req.params.id); + if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) { + return res.status(400).json({ message: "Invalid company id." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || companyId !== targetCompanyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const requestedLimit = parseInt(req.query.limit, 10); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 500) + : 100; + const requestedOffset = parseInt(req.query.offset, 10); + const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 + ? requestedOffset + : 0; + + try { + const { count, rows } = await PurchaseOrderVendor.findAndCountAll({ + where: { povCompId: targetCompanyId, povArch: false }, + limit, offset, + order: [['povId', 'ASC']], + }); + return res.status(200).json({ + message: "Successfully retrieved PO vendors with CompanyId " + targetCompanyId, + count, limit, offset, purchaseOrderVendors: rows, + }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderVendor.findAndCountAll failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.update = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let vendor; + try { + vendor = await PurchaseOrderVendor.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!vendor || vendor.povArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || vendor.povCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + const body = req.body || {}; + const updates = {}; + for (const f of ALLOWED_FIELDS_UPDATE) { + if (body[f] !== undefined) updates[f] = body[f]; + } + if (Object.keys(updates).length === 0) { + return res.status(400).json({ message: "No updatable fields supplied." }); + } + + try { + await vendor.update(updates); + return res.status(200).json({ message: "Updated.", purchaseOrderVendor: vendor }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderVendor.update failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports.remove = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let vendor; + try { + vendor = await PurchaseOrderVendor.findByPk(req.params.id); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderVendor.findByPk failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + if (!vendor || vendor.povArch) { + return res.status(404).json({ message: "Not found." }); + } + + const isMaster = await IsMaster(authKey); + if (!isMaster) { + const companyId = await GetCompanyId(authKey); + if (companyId === -1 || vendor.povCompId !== companyId) { + return res.status(403).json({ message: "Invalid Authorization Key." }); + } + } + + try { + await vendor.update({ povArch: true }); + return res.status(200).json({ message: "Archived.", id: vendor.povId }); + } catch (error) { + log.error({ err: error }, 'PurchaseOrderVendor archive failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } +}; + +exports._internals = { IsMaster, GetCompanyId }; diff --git a/app/models/purchaseordervendor.model.js b/app/models/purchaseordervendor.model.js new file mode 100644 index 0000000..e2f6532 --- /dev/null +++ b/app/models/purchaseordervendor.model.js @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * PurchaseOrderVendor — a vendor that POs are issued to. Has both a + * mailing address (where invoices arrive) and a billing address + * (where checks get sent). Direct company scoping via povCompId. + * Soft-deletes via povArch. + */ +module.exports = (sequelize, Sequelize) => { + const PurchaseOrderVendor = sequelize.define('PurchaseOrderVendor', { + povId: { + field: 'povId', + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + povName: { field: 'povName', type: Sequelize.TEXT, allowNull: false }, + povMailingAddress1: { field: 'povMailingAddress1', type: Sequelize.TEXT, allowNull: false }, + povMailingAddress2: { field: 'povMailingAddress2', type: Sequelize.TEXT }, + povMailingCity: { field: 'povMailingCity', type: Sequelize.TEXT, allowNull: false }, + povMailingState: { field: 'povMailingState', type: Sequelize.TEXT }, + povMailingCountry: { field: 'povMailingCountry', type: Sequelize.TEXT }, + povMailingZip: { field: 'povMailingZip', type: Sequelize.TEXT }, + povBillingAddress1: { field: 'povBillingAddress1', type: Sequelize.TEXT }, + povBillingAddress2: { field: 'povBillingAddress2', type: Sequelize.TEXT }, + povBillingCity: { field: 'povBillingCity', type: Sequelize.TEXT }, + povBillingState: { field: 'povBillingState', type: Sequelize.TEXT }, + povBillingCountry: { field: 'povBillingCountry', type: Sequelize.TEXT }, + povBillingZip: { field: 'povBillingZip', type: Sequelize.TEXT }, + povPhone: { field: 'povPhone', type: Sequelize.TEXT }, + povEMail: { field: 'povEMail', type: Sequelize.TEXT }, + povCompId: { field: 'povCompId', type: Sequelize.INTEGER, allowNull: false }, + povArch: { field: 'povArch', type: Sequelize.BOOLEAN, defaultValue: false }, + }, { + tableName: 'PurchaseOrderVendors', + timestamps: false, + }); + + return PurchaseOrderVendor; +}; diff --git a/app/routers/router.js b/app/routers/router.js index 6e5369a..b16c716 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -18,6 +18,7 @@ const customerPayment = require('../controllers/customerpaymentcontroller.js'); const invoiceJob = require('../controllers/invoicejobcontroller.js'); const productEntry = require('../controllers/productentrycontroller.js'); const versionInfo = require('../controllers/versioninfocontroller.js'); +const purchaseOrderVendor = require('../controllers/purchaseordervendorcontroller.js'); const openapiSpec = require('../config/openapi.js'); const v = require('../middleware/validate.js'); const customerSchemas = require('../schemas/customer.schema.js'); @@ -32,6 +33,7 @@ const customerPaymentSchemas = require('../schemas/customerpayment.schema.js'); const invoiceJobSchemas = require('../schemas/invoicejob.schema.js'); const productEntrySchemas = require('../schemas/productentry.schema.js'); const versionInfoSchemas = require('../schemas/versioninfo.schema.js'); +const purchaseOrderVendorSchemas = require('../schemas/purchaseordervendor.schema.js'); // Health / readiness probe. No auth required — only exposes liveness // of the API process and reachability of the database. @@ -382,4 +384,33 @@ router.delete( versionInfo.remove, ); +// v1 purchaseordervendor routes. Direct compId scoping via povCompId. +router.post( + '/v1/purchaseordervendor', + v.body(purchaseOrderVendorSchemas.createBody), + purchaseOrderVendor.create, +); +router.get( + '/v1/purchaseordervendor/bycompany/:id', + v.params(purchaseOrderVendorSchemas.intIdParam), + v.query(purchaseOrderVendorSchemas.listByCompanyQuery), + purchaseOrderVendor.listByCompany, +); +router.get( + '/v1/purchaseordervendor/:id', + v.params(purchaseOrderVendorSchemas.intIdParam), + purchaseOrderVendor.getById, +); +router.patch( + '/v1/purchaseordervendor/:id', + v.params(purchaseOrderVendorSchemas.intIdParam), + v.body(purchaseOrderVendorSchemas.updateBody), + purchaseOrderVendor.update, +); +router.delete( + '/v1/purchaseordervendor/:id', + v.params(purchaseOrderVendorSchemas.intIdParam), + purchaseOrderVendor.remove, +); + module.exports = router; diff --git a/app/schemas/purchaseordervendor.schema.js b/app/schemas/purchaseordervendor.schema.js new file mode 100644 index 0000000..84dae40 --- /dev/null +++ b/app/schemas/purchaseordervendor.schema.js @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +const { z } = require('zod'); + +const intIdParam = z.object({ + id: z.coerce.number().int().positive(), +}); + +// Shared shape — both create and update use the same field whitelist, +// but create requires the NOT-NULL columns while update treats every +// field as optional. +const baseFields = { + povName: z.string().min(1).max(255), + povMailingAddress1: z.string().min(1).max(255), + povMailingAddress2: z.string().max(255).optional(), + povMailingCity: z.string().min(1).max(255), + povMailingState: z.string().max(255).optional(), + povMailingCountry: z.string().max(255).optional(), + povMailingZip: z.string().max(32).optional(), + povBillingAddress1: z.string().max(255).optional(), + povBillingAddress2: z.string().max(255).optional(), + povBillingCity: z.string().max(255).optional(), + povBillingState: z.string().max(255).optional(), + povBillingCountry: z.string().max(255).optional(), + povBillingZip: z.string().max(32).optional(), + povPhone: z.string().max(64).optional(), + povEMail: z.string().email().max(255).optional(), + povCompId: z.coerce.number().int().positive().optional(), +}; + +const createBody = z.object(baseFields).strict({ + message: 'Unexpected field in body. See OpenAPI spec for the whitelist.', +}); + +// PATCH variant — all fields optional, including the ones that are +// NOT NULL on create. +const updateBody = z.object({ + ...Object.fromEntries( + Object.entries(baseFields).map(([k, v]) => [k, v.optional()]), + ), +}).strict({ + message: 'Unexpected field in body. See OpenAPI spec for the whitelist.', +}).omit({ povCompId: true }); + +const listByCompanyQuery = z.object({ + limit: z.coerce.number().int().positive().max(500).optional(), + offset: z.coerce.number().int().nonnegative().optional(), +}).strict({ + message: 'Unexpected query parameter. Allowed: limit, offset.', +}); + +module.exports = { + intIdParam, + createBody, + updateBody, + listByCompanyQuery, +}; diff --git a/tests/api/purchaseordervendor.test.js b/tests/api/purchaseordervendor.test.js new file mode 100644 index 0000000..47f4c6a --- /dev/null +++ b/tests/api/purchaseordervendor.test.js @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +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([]), QueryTypes: { SELECT: 'SELECT' } }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, + Company: {}, Job: {}, Invoice: {}, CustomerPayment: {}, + InvoiceJob: {}, ProductEntry: {}, VersionInfo: {}, + PurchaseOrderVendor: { + findByPk: vi.fn().mockResolvedValue(null), + findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }), + create: vi.fn().mockResolvedValue({ povId: 1 }), + }, + 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('PurchaseOrderVendor auth contract', () => { + test('GET 403 without authKey', async () => { expect((await request(app).get('/v1/purchaseordervendor/1')).status).toBe(403); }); + test('POST 403 without authKey', async () => { + const res = await request(app).post('/v1/purchaseordervendor').send({ + povName: 'Acme', povMailingAddress1: '1 Main', povMailingCity: 'Lincoln', + }); + expect(res.status).toBe(403); + }); + test('GET /bycompany/:id 403 without authKey', async () => { expect((await request(app).get('/v1/purchaseordervendor/bycompany/1')).status).toBe(403); }); + test('PATCH 403 without authKey', async () => { expect((await request(app).patch('/v1/purchaseordervendor/1').send({ povName: 'x' })).status).toBe(403); }); + test('DELETE 403 without authKey', async () => { expect((await request(app).delete('/v1/purchaseordervendor/1')).status).toBe(403); }); +}); + +describe('PurchaseOrderVendor route mounting', () => { + test('routes mounted', async () => { + expect((await request(app).get('/v1/purchaseordervendor/1').set('authKey', 'any')).status).not.toBe(404); + }); +}); + +describe('PurchaseOrderVendor body validation', () => { + test('POST rejects unknown field', async () => { + const res = await request(app).post('/v1/purchaseordervendor').set('authKey', 'any').send({ + povName: 'Acme', povMailingAddress1: '1 Main', povMailingCity: 'Lincoln', bogus: 'no', + }); + expect(res.status).toBe(400); + }); + test('POST rejects missing required povName', async () => { + const res = await request(app).post('/v1/purchaseordervendor').set('authKey', 'any').send({ + povMailingAddress1: '1 Main', povMailingCity: 'Lincoln', + }); + expect(res.status).toBe(400); + }); +});