201 lines
6.6 KiB
TypeScript
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();
|
|
});
|
|
});
|