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'); }); });