feat(events): add leased outbox delivery and scoped operational APIs
This commit is contained in:
parent
5e5f6c1faa
commit
cef6c5ecb0
|
|
@ -9,6 +9,7 @@ import { checkoutSnapshot } from './checkout-snapshot';
|
|||
import { holdOrderStock } from './stock-allocation';
|
||||
import { orderView } from './order-view';
|
||||
import { snapshotPrice } from '../pricing/snapshot-price';
|
||||
import { enqueueEvent } from '../events/enqueue-event';
|
||||
|
||||
@Injectable()
|
||||
export class CheckoutStore {
|
||||
|
|
@ -68,6 +69,7 @@ export class CheckoutStore {
|
|||
});
|
||||
await holdOrderStock(tx, actor, order.id, expiresAt, lines);
|
||||
const pricing = await snapshotPrice(tx, order);
|
||||
await enqueueEvent(tx, actor.organizationId, order.id, 'order.created');
|
||||
await tx.cartLine.deleteMany({ where: { cartId } });
|
||||
await tx.cart.update({
|
||||
where: { id: cartId },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { recordAudit } from '../identity/audit';
|
|||
import { AppError } from '../common/errors/app-error';
|
||||
import { lockStock } from '../inventory/stock-lock';
|
||||
import { orderView, orderStatus } from './order-view';
|
||||
import { enqueueEvent } from '../events/enqueue-event';
|
||||
|
||||
@Injectable()
|
||||
export class OrderStore {
|
||||
|
|
@ -85,6 +86,7 @@ export class OrderStore {
|
|||
data: { status: 'CANCELLED' },
|
||||
include: { lines: true, pricing: true },
|
||||
});
|
||||
await enqueueEvent(tx, actor.organizationId, id, 'order.cancelled');
|
||||
await recordAudit(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
export interface DeliveryMessage {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
orderId: string;
|
||||
kind: string;
|
||||
}
|
||||
export abstract class DeliveryPort {
|
||||
abstract assertConfigured(): void;
|
||||
// Delivery is at-least-once. The adapter must deduplicate using message.id.
|
||||
abstract deliver(message: DeliveryMessage): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DeliveryPort } from './delivery.port';
|
||||
import { DeliveryStore } from './delivery.store';
|
||||
@Injectable()
|
||||
export class DeliveryService {
|
||||
private readonly logger = new Logger('CommerceDelivery');
|
||||
constructor(
|
||||
private readonly delivery: DeliveryPort,
|
||||
private readonly store: DeliveryStore,
|
||||
) {}
|
||||
async dispatchOne(): Promise<boolean> {
|
||||
this.delivery.assertConfigured();
|
||||
const row = await this.store.claim();
|
||||
if (!row) return false;
|
||||
try {
|
||||
await this.delivery.deliver({
|
||||
id: row.event.id,
|
||||
organizationId: row.event.organizationId,
|
||||
orderId: row.event.orderId,
|
||||
kind: row.event.kind,
|
||||
});
|
||||
const result = await this.store.acknowledge(row.eventId, row.leaseToken!);
|
||||
if (!result.count)
|
||||
this.logger.warn(
|
||||
JSON.stringify({
|
||||
event: 'DELIVERY_LEASE_LOST',
|
||||
eventId: row.eventId,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
await this.store.fail(row.eventId, row.leaseToken!, row.attempts);
|
||||
this.logger.error(
|
||||
JSON.stringify({
|
||||
event: 'DELIVERY_FAILED',
|
||||
eventId: row.eventId,
|
||||
attempt: row.attempts,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class DeliveryStore {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
claim() {
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const rows = await tx.$queryRaw<{ event_id: string }[]>`
|
||||
SELECT event_id FROM event_deliveries
|
||||
WHERE delivered_at IS NULL AND attempts < 5 AND available_at <= ${now}
|
||||
AND (lease_expires_at IS NULL OR lease_expires_at <= ${now})
|
||||
ORDER BY available_at, event_id FOR UPDATE SKIP LOCKED LIMIT 1`;
|
||||
if (!rows[0]) return null;
|
||||
return tx.eventDelivery.update({
|
||||
where: { eventId: rows[0].event_id },
|
||||
data: {
|
||||
attempts: { increment: 1 },
|
||||
leaseToken: randomUUID(),
|
||||
leaseExpiresAt: new Date(now.getTime() + 60000),
|
||||
},
|
||||
include: { event: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
acknowledge(eventId: string, leaseToken: string) {
|
||||
return this.db.eventDelivery.updateMany({
|
||||
where: {
|
||||
eventId,
|
||||
leaseToken,
|
||||
deliveredAt: null,
|
||||
leaseExpiresAt: { gt: new Date() },
|
||||
},
|
||||
data: {
|
||||
deliveredAt: new Date(),
|
||||
leaseToken: null,
|
||||
leaseExpiresAt: null,
|
||||
lastErrorCode: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
fail(eventId: string, leaseToken: string, attempts: number) {
|
||||
return this.db.eventDelivery.updateMany({
|
||||
where: { eventId, leaseToken, deliveredAt: null },
|
||||
data: {
|
||||
leaseToken: null,
|
||||
leaseExpiresAt: null,
|
||||
lastErrorCode: 'DELIVERY_FAILED',
|
||||
availableAt: new Date(
|
||||
Date.now() + Math.min(3600, 30 * 2 ** (attempts - 1)) * 1000,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AppError } from '../common/errors/app-error';
|
||||
import { DeliveryPort } from './delivery.port';
|
||||
@Injectable()
|
||||
export class DisabledDelivery extends DeliveryPort {
|
||||
assertConfigured(): void {
|
||||
throw new AppError('DELIVERY_UNAVAILABLE');
|
||||
}
|
||||
async deliver(): Promise<void> {
|
||||
this.assertConfigured();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import type { Prisma } from '../generated/prisma/client';
|
||||
export async function enqueueEvent(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
orderId: string,
|
||||
kind: 'order.created' | 'order.cancelled',
|
||||
) {
|
||||
return tx.commerceEvent.upsert({
|
||||
where: { orderId_kind: { orderId, kind } },
|
||||
create: { organizationId, orderId, kind, delivery: { create: {} } },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CurrentPrincipal,
|
||||
RequirePermission,
|
||||
} from '../identity/access.decorator';
|
||||
import type { Principal } from '../identity/identity.types';
|
||||
import { pageSchema, type PageInput } from '../identity/identity.schemas';
|
||||
import { SchemaPipe } from '../common/validation.pipe';
|
||||
import { OperationsStore } from './operations.store';
|
||||
@Controller('admin/operations')
|
||||
export class OperationsController {
|
||||
constructor(private readonly operations: OperationsStore) {}
|
||||
@Get('summary')
|
||||
@RequirePermission('operations.read')
|
||||
summary(@CurrentPrincipal() actor: Principal) {
|
||||
return this.operations.summary(actor);
|
||||
}
|
||||
@Get('events')
|
||||
@RequirePermission('operations.read')
|
||||
events(
|
||||
@CurrentPrincipal() actor: Principal,
|
||||
@Query(new SchemaPipe(pageSchema)) page: PageInput,
|
||||
) {
|
||||
return this.operations.events(actor, page);
|
||||
}
|
||||
@Post('events/:id/retry')
|
||||
@RequirePermission('notifications.retry')
|
||||
retry(
|
||||
@CurrentPrincipal() actor: Principal,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.operations.retry(actor, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,21 @@ import { DatabaseModule } from '../database/database.module';
|
|||
import { IdentityModule } from '../identity/identity.module';
|
||||
import { PricingController } from '../pricing/pricing.controller';
|
||||
import { PricingStore } from '../pricing/pricing.store';
|
||||
import { OperationsController } from './operations.controller';
|
||||
import { OperationsStore } from './operations.store';
|
||||
import { DeliveryPort } from '../events/delivery.port';
|
||||
import { DisabledDelivery } from '../events/disabled-delivery';
|
||||
import { DeliveryStore } from '../events/delivery.store';
|
||||
import { DeliveryService } from '../events/delivery.service';
|
||||
@Module({
|
||||
imports: [DatabaseModule, IdentityModule],
|
||||
controllers: [PricingController],
|
||||
providers: [PricingStore],
|
||||
controllers: [PricingController, OperationsController],
|
||||
providers: [
|
||||
PricingStore,
|
||||
OperationsStore,
|
||||
DeliveryStore,
|
||||
DeliveryService,
|
||||
{ provide: DeliveryPort, useClass: DisabledDelivery },
|
||||
],
|
||||
})
|
||||
export class OperationsModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AccessStore } from '../identity/access.store';
|
||||
import type { Principal } from '../identity/identity.types';
|
||||
import type { PageInput } from '../identity/identity.schemas';
|
||||
import { AppError } from '../common/errors/app-error';
|
||||
import { recordAudit } from '../identity/audit';
|
||||
|
||||
@Injectable()
|
||||
export class OperationsStore {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly access: AccessStore,
|
||||
) {}
|
||||
async summary(actor: Principal) {
|
||||
const organizationId = actor.organizationId;
|
||||
const now = new Date();
|
||||
return this.db.$transaction(
|
||||
async (tx) => ({
|
||||
pendingOrders: await tx.order.count({
|
||||
where: {
|
||||
organizationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
}),
|
||||
expiredOrders: await tx.order.count({
|
||||
where: {
|
||||
organizationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
}),
|
||||
cancelledOrders: await tx.order.count({
|
||||
where: { organizationId, status: 'CANCELLED' },
|
||||
}),
|
||||
unpricedActiveOrders: await tx.order.count({
|
||||
where: {
|
||||
organizationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
expiresAt: { gt: now },
|
||||
pricing: null,
|
||||
},
|
||||
}),
|
||||
pendingDeliveries: await tx.eventDelivery.count({
|
||||
where: {
|
||||
event: { organizationId },
|
||||
deliveredAt: null,
|
||||
attempts: { lt: 5 },
|
||||
},
|
||||
}),
|
||||
failedDeliveries: await tx.eventDelivery.count({
|
||||
where: {
|
||||
event: { organizationId },
|
||||
deliveredAt: null,
|
||||
attempts: { gte: 5 },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ isolationLevel: 'RepeatableRead' },
|
||||
);
|
||||
}
|
||||
events(actor: Principal, page: PageInput) {
|
||||
return this.db.commerceEvent.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
take: page.limit,
|
||||
skip: page.offset,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
orderId: true,
|
||||
kind: true,
|
||||
createdAt: true,
|
||||
delivery: {
|
||||
select: {
|
||||
attempts: true,
|
||||
availableAt: true,
|
||||
deliveredAt: true,
|
||||
lastErrorCode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
retry(actor: Principal, id: string) {
|
||||
return this.access.mutate(actor, 'notifications.retry', async (tx) => {
|
||||
const event = await tx.commerceEvent.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!event) throw new AppError('EVENT_NOT_FOUND');
|
||||
const updated = await tx.eventDelivery.updateMany({
|
||||
where: {
|
||||
eventId: id,
|
||||
deliveredAt: null,
|
||||
OR: [
|
||||
{ leaseExpiresAt: null },
|
||||
{ leaseExpiresAt: { lte: new Date() } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
attempts: 0,
|
||||
availableAt: new Date(),
|
||||
leaseToken: null,
|
||||
leaseExpiresAt: null,
|
||||
lastErrorCode: null,
|
||||
},
|
||||
});
|
||||
if (!updated.count) throw new AppError('EVENT_NOT_RETRYABLE');
|
||||
await recordAudit(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
actor.userId,
|
||||
'notification.retry.requested',
|
||||
id,
|
||||
);
|
||||
return { queued: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
import { DeliveryService } from '../src/events/delivery.service';
|
||||
import type { DeliveryStore } from '../src/events/delivery.store';
|
||||
describe('delivery lease and logging policy', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('warns when delivery succeeds after lease ownership is lost', async () => {
|
||||
const warning = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const store = {
|
||||
claim: jest.fn().mockResolvedValue({
|
||||
eventId: 'event',
|
||||
leaseToken: 'lease',
|
||||
attempts: 1,
|
||||
event: {
|
||||
id: 'event',
|
||||
organizationId: 'org',
|
||||
orderId: 'order',
|
||||
kind: 'order.created',
|
||||
},
|
||||
}),
|
||||
acknowledge: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
fail: jest.fn(),
|
||||
};
|
||||
const service = new DeliveryService(
|
||||
{ assertConfigured() {}, async deliver() {} },
|
||||
store as unknown as DeliveryStore,
|
||||
);
|
||||
expect(await service.dispatchOne()).toBe(true);
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
JSON.stringify({ event: 'DELIVERY_LEASE_LOST', eventId: 'event' }),
|
||||
);
|
||||
expect(store.fail).not.toHaveBeenCalled();
|
||||
});
|
||||
it('never logs adapter exception text or notification content', async () => {
|
||||
const logging = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const store = {
|
||||
claim: jest.fn().mockResolvedValue({
|
||||
eventId: 'event',
|
||||
leaseToken: 'lease',
|
||||
attempts: 2,
|
||||
event: {
|
||||
id: 'event',
|
||||
organizationId: 'org',
|
||||
orderId: 'order',
|
||||
kind: 'order.created',
|
||||
},
|
||||
}),
|
||||
acknowledge: jest.fn(),
|
||||
fail: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
};
|
||||
const service = new DeliveryService(
|
||||
{
|
||||
assertConfigured() {},
|
||||
async deliver() {
|
||||
throw new Error('private-address-and-secret');
|
||||
},
|
||||
},
|
||||
store as unknown as DeliveryStore,
|
||||
);
|
||||
await service.dispatchOne();
|
||||
expect(logging).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
event: 'DELIVERY_FAILED',
|
||||
eventId: 'event',
|
||||
attempt: 2,
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(logging.mock.calls)).not.toContain('private-address');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||
import { checkoutFixture } from './helpers/checkout';
|
||||
import { DeliveryStore } from '../src/events/delivery.store';
|
||||
const native = process.env.TEST_DATABASE_URL ? describe : describe.skip;
|
||||
native('native PostgreSQL event claims', () => {
|
||||
let ctx: IdentityApp;
|
||||
beforeAll(async () => {
|
||||
ctx = await identityApp();
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await ctx.close();
|
||||
});
|
||||
it('assigns distinct events to simultaneous workers', async () => {
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const f = await checkoutFixture(ctx);
|
||||
await ctx
|
||||
.api()
|
||||
.post('/api/v1/checkout')
|
||||
.auth(f.actor.token, { type: 'bearer' })
|
||||
.send(f.input)
|
||||
.expect(201);
|
||||
}
|
||||
const store = ctx.app.get(DeliveryStore);
|
||||
const claims = await Promise.all([store.claim(), store.claim()]);
|
||||
expect(claims.every(Boolean)).toBe(true);
|
||||
expect(new Set(claims.map((claim) => claim!.eventId)).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue