manicanldes-backend/test/events.spec.ts

201 lines
6.6 KiB
TypeScript

import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture } from './helpers/checkout';
import { secondActor } from './helpers/commerce';
import { DeliveryService } from '../src/events/delivery.service';
import { DeliveryStore } from '../src/events/delivery.store';
import { DisabledDelivery } from '../src/events/disabled-delivery';
import * as audit from '../src/identity/audit';
describe('transactional commerce event delivery', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
await ctx.db.eventDelivery.updateMany({
data: { deliveredAt: new Date(), leaseToken: null, leaseExpiresAt: null },
});
});
afterEach(() => jest.restoreAllMocks());
async function event() {
const f = await checkoutFixture(ctx);
const order = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
return {
...f,
order: order.body,
event: await ctx.db.commerceEvent.findFirstOrThrow({
where: { orderId: order.body.id },
}),
};
}
it('emits one event per committed transition and none after rollback', async () => {
const f = await event();
await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
await ctx
.api()
.post('/api/v1/orders/' + f.order.id + '/cancel')
.auth(f.actor.token, { type: 'bearer' })
.expect(201);
await ctx
.api()
.post('/api/v1/orders/' + f.order.id + '/cancel')
.auth(f.actor.token, { type: 'bearer' })
.expect(201);
expect(
await ctx.db.commerceEvent.count({ where: { orderId: f.order.id } }),
).toBe(2);
const next = await checkoutFixture(ctx);
jest
.spyOn(audit, 'recordAudit')
.mockRejectedValueOnce(new Error('Synthetic final write failure'));
await ctx
.api()
.post('/api/v1/checkout')
.auth(next.actor.token, { type: 'bearer' })
.send(next.input)
.expect(500);
expect(
await ctx.db.commerceEvent.count({
where: { order: { userId: next.actor.userId } },
}),
).toBe(0);
await expect(
ctx.executeSql(`DELETE FROM commerce_events WHERE id = '${f.event.id}'`),
).rejects.toThrow();
});
it('keeps delivery disabled by default and uses a test adapter with stable event IDs', async () => {
const f = await event();
await expect(
ctx.app.get(DeliveryService).dispatchOne(),
).rejects.toMatchObject({ code: 'DELIVERY_UNAVAILABLE' });
await expect(new DisabledDelivery().deliver()).rejects.toThrow();
const adapter = {
assertConfigured: jest.fn(),
deliver: jest.fn().mockResolvedValue(undefined),
};
const service = new DeliveryService(adapter, ctx.app.get(DeliveryStore));
expect(await service.dispatchOne()).toBe(true);
expect(adapter.deliver).toHaveBeenCalledWith({
id: f.event.id,
orderId: f.order.id,
organizationId: f.actor.organizationId,
kind: 'order.created',
});
expect(await service.dispatchOne()).toBe(false);
expect(
(
await ctx.db.eventDelivery.findUniqueOrThrow({
where: { eventId: f.event.id },
})
).deliveredAt,
).not.toBeNull();
});
it('backs off failures, bounds attempts and permits authorized manual retries', async () => {
const f = await event();
const store = ctx.app.get(DeliveryStore);
const service = new DeliveryService(
{
assertConfigured() {},
deliver: jest
.fn()
.mockRejectedValue(new Error('Private transport error')),
},
store,
);
await service.dispatchOne();
const failed = await ctx.db.eventDelivery.findUniqueOrThrow({
where: { eventId: f.event.id },
});
expect(failed).toMatchObject({
attempts: 1,
lastErrorCode: 'DELIVERY_FAILED',
leaseToken: null,
});
expect(failed.availableAt.getTime()).toBeGreaterThan(Date.now());
expect(await store.claim()).toBeNull();
await ctx.db.eventDelivery.update({
where: { eventId: f.event.id },
data: { attempts: 5, availableAt: new Date(0) },
});
expect(await store.claim()).toBeNull();
const summary = await ctx
.api()
.get('/api/v1/admin/operations/summary')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(summary.body.failedDeliveries).toBe(1);
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(f.actor.token, { type: 'bearer' })
.expect(403);
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(ctx.token, { type: 'bearer' })
.expect(201);
expect((await store.claim())?.attempts).toBe(1);
});
it('rejects stale lease acknowledgements and protects cross-organization event access', async () => {
const f = await event();
const store = ctx.app.get(DeliveryStore);
const first = (await store.claim())!;
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(ctx.token, { type: 'bearer' })
.expect(409);
await ctx.db.eventDelivery.update({
where: { eventId: f.event.id },
data: { leaseExpiresAt: new Date(0) },
});
const next = (await store.claim())!;
expect(next.leaseToken).not.toBe(first.leaseToken);
expect((await store.acknowledge(f.event.id, first.leaseToken!)).count).toBe(
0,
);
expect((await store.fail(f.event.id, first.leaseToken!, 1)).count).toBe(0);
await store.acknowledge(f.event.id, next.leaseToken!);
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(ctx.token, { type: 'bearer' })
.expect(409);
const other = await secondActor(ctx, false, [
'operations.read',
'notifications.retry',
]);
const list = await ctx
.api()
.get('/api/v1/admin/operations/events')
.auth(other.token, { type: 'bearer' })
.expect(200);
expect(list.body).toEqual([]);
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(other.token, { type: 'bearer' })
.expect(404);
const own = await ctx
.api()
.get('/api/v1/admin/operations/events?limit=1')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(own.body[0].delivery.leaseToken).toBeUndefined();
});
});