192 lines
5.6 KiB
TypeScript
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');
|
|
});
|
|
});
|