manicanldes-backend/test/purchase-orders.spec.ts

192 lines
5.6 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { secondActor } from './helpers/commerce';
describe('purchase-order API', () => {
let ctx: IdentityApp;
const auth = { type: 'bearer' as const };
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
async function source(code: string, currency = 'INR') {
const supplier = await ctx
.api()
.post('/api/v1/suppliers')
.auth(ctx.token, auth)
.send({
name: `Supplier ${code}`,
code: `S-${code}`,
contactName: 'Asha Shah',
email: `${code}@example.com`,
phone: '+919876543210',
})
.expect(201);
const material = await ctx
.api()
.post('/api/v1/materials')
.auth(ctx.token, auth)
.send({
name: `Wax ${code}`,
code: `M-${code}`,
kind: 'WAX',
unit: 'KILOGRAM',
})
.expect(201);
const quote = {
supplierSku: `SKU-${code}`,
leadTimeDays: 7,
minOrderQuantity: '2.500',
unitPrice: '120.00',
currency,
};
await ctx
.api()
.put(
`/api/v1/suppliers/${supplier.body.id}/materials/${material.body.id}`,
)
.auth(ctx.token, auth)
.send(quote)
.expect(200);
return { supplier: supplier.body, material: material.body, quote };
}
it('snapshots active supplier quotes and advances a draft only once', async () => {
const first = await source(randomUUID().slice(0, 8));
const created = await ctx
.api()
.post('/api/v1/purchase-orders')
.auth(ctx.token, auth)
.send({
supplierId: first.supplier.id,
expectedDeliveryDate: '2026-10-01',
lines: [{ materialId: first.material.id, quantity: '2.500' }],
})
.expect(201);
expect(created.body).toMatchObject({
status: 'DRAFT',
currency: 'INR',
merchandiseTotal: '300',
lines: [
{
materialId: first.material.id,
supplierSku: first.quote.supplierSku,
unitPrice: '120',
lineTotal: '300',
},
],
});
await ctx
.api()
.put(
`/api/v1/suppliers/${first.supplier.id}/materials/${first.material.id}`,
)
.auth(ctx.token, auth)
.send({ ...first.quote, unitPrice: '150.00' })
.expect(200);
const detail = await ctx
.api()
.get(`/api/v1/purchase-orders/${created.body.id}`)
.auth(ctx.token, auth)
.expect(200);
expect(detail.body.lines[0].unitPrice).toBe('120');
const issued = await ctx
.api()
.post(`/api/v1/purchase-orders/${created.body.id}/issue`)
.auth(ctx.token, auth)
.expect(201);
expect(issued.body).toMatchObject({ status: 'ISSUED' });
const invalid = await ctx
.api()
.post(`/api/v1/purchase-orders/${created.body.id}/cancel`)
.auth(ctx.token, auth)
.expect(409);
expect(invalid.body.code).toBe('PURCHASE_ORDER_STATE');
expect(
await ctx.db.auditEvent.count({
where: {
organizationId: ctx.owner.organizationId,
action: 'purchase_order.issued',
},
}),
).toBe(1);
const cancellable = await ctx
.api()
.post('/api/v1/purchase-orders')
.auth(ctx.token, auth)
.send({
supplierId: first.supplier.id,
lines: [{ materialId: first.material.id, quantity: '2.500' }],
})
.expect(201);
const cancelled = await ctx
.api()
.post(`/api/v1/purchase-orders/${cancellable.body.id}/cancel`)
.auth(ctx.token, auth)
.expect(201);
expect(cancelled.body.status).toBe('CANCELLED');
});
it('rejects unavailable sources, supplier minimums and mixed currencies', async () => {
const first = await source(randomUUID().slice(0, 8));
const belowMinimum = await ctx
.api()
.post('/api/v1/purchase-orders')
.auth(ctx.token, auth)
.send({
supplierId: first.supplier.id,
lines: [{ materialId: first.material.id, quantity: '2.499' }],
})
.expect(409);
expect(belowMinimum.body.code).toBe('PURCHASE_ORDER_MINIMUM');
const foreignMaterial = await source(randomUUID().slice(0, 8), 'USD');
await ctx
.api()
.put(
`/api/v1/suppliers/${first.supplier.id}/materials/${foreignMaterial.material.id}`,
)
.auth(ctx.token, auth)
.send({ ...foreignMaterial.quote, currency: 'USD' })
.expect(200);
const mismatch = await ctx
.api()
.post('/api/v1/purchase-orders')
.auth(ctx.token, auth)
.send({
supplierId: first.supplier.id,
lines: [
{ materialId: first.material.id, quantity: '2.500' },
{ materialId: foreignMaterial.material.id, quantity: '2.500' },
],
})
.expect(409);
expect(mismatch.body.code).toBe('PURCHASE_ORDER_CURRENCY');
});
it('enforces scope and read/manage permissions', async () => {
const reader = await secondActor(ctx, true, ['procurement.read']);
await ctx
.api()
.get('/api/v1/purchase-orders')
.auth(reader.token, auth)
.expect(200);
await ctx
.api()
.post('/api/v1/purchase-orders')
.auth(reader.token, auth)
.send({
supplierId: randomUUID(),
lines: [{ materialId: randomUUID(), quantity: '1.000' }],
})
.expect(403);
const missing = await ctx
.api()
.get(`/api/v1/purchase-orders/${randomUUID()}`)
.auth(ctx.token, auth)
.expect(404);
expect(missing.body.code).toBe('PURCHASE_ORDER_NOT_FOUND');
});
});