From 72cd54857d3af066606ca1d94c3b0c26456c9540 Mon Sep 17 00:00:00 2001 From: solaawojobi00-bit Date: Tue, 29 Sep 2026 10:27:54 +0100 Subject: [PATCH] fix(outbox): fail fast when Prisma client lacks eventOutbox model (#1380) --- .../__tests__/eventOutboxModelGuard.test.ts | 73 +++++++++++++++++++ backend/src/eventOutbox.ts | 20 +++++ 2 files changed, 93 insertions(+) create mode 100644 backend/src/__tests__/eventOutboxModelGuard.test.ts diff --git a/backend/src/__tests__/eventOutboxModelGuard.test.ts b/backend/src/__tests__/eventOutboxModelGuard.test.ts new file mode 100644 index 000000000..13fba9cf5 --- /dev/null +++ b/backend/src/__tests__/eventOutboxModelGuard.test.ts @@ -0,0 +1,73 @@ +/** + * Regression tests for #1380: a Prisma client without the eventOutbox model + * must fail fast instead of surfacing as a swallowed TypeError. + */ + +const MISSING_MODEL_MESSAGE = /missing the eventOutbox model/; + +function loadWithPrisma(fakePrisma: unknown): typeof import('../eventOutbox') { + let mod!: typeof import('../eventOutbox'); + jest.isolateModules(() => { + jest.doMock('../prisma', () => ({ prisma: fakePrisma })); + mod = require('../eventOutbox'); + }); + return mod; +} + +describe('eventOutbox model guard', () => { + afterEach(() => { + jest.dontMock('../prisma'); + }); + + describe('assertEventOutboxModelAvailable', () => { + const { assertEventOutboxModelAvailable } = loadWithPrisma({}); + + it.each([ + ['undefined client', undefined], + ['null client', null], + ['client without eventOutbox', {}], + ['eventOutbox without create', { eventOutbox: {} }], + ])('throws for %s', (_label, client) => { + expect(() => assertEventOutboxModelAvailable(client)).toThrow(MISSING_MODEL_MESSAGE); + }); + + it('passes when the eventOutbox delegate exposes create', () => { + expect(() => + assertEventOutboxModelAvailable({ eventOutbox: { create: jest.fn() } }), + ).not.toThrow(); + }); + }); + + describe('EventOutboxService with a client missing eventOutbox', () => { + const input = { + eventType: 'transaction.deposit.created' as const, + payload: { + transactionId: 'tx-guard-001', + amount: '1', + asset: 'USDC', + walletAddress: `G${'A'.repeat(55)}`, + transactionHash: '0xguard', + status: 'completed', + timestamp: new Date().toISOString(), + }, + aggregateType: 'transaction' as const, + aggregateId: 'tx-guard-001', + }; + + it('start() throws synchronously and does not mark the processor active', () => { + const { eventOutboxService } = loadWithPrisma({}); + expect(() => eventOutboxService.start()).toThrow(MISSING_MODEL_MESSAGE); + expect(eventOutboxService.isActive).toBe(false); + }); + + it('writeEvent rejects with a descriptive error instead of a TypeError', async () => { + const { eventOutboxService } = loadWithPrisma({}); + await expect(eventOutboxService.writeEvent(input)).rejects.toThrow(MISSING_MODEL_MESSAGE); + }); + + it('replayOnStartup rejects with a descriptive error', async () => { + const { eventOutboxService } = loadWithPrisma({}); + await expect(eventOutboxService.replayOnStartup()).rejects.toThrow(MISSING_MODEL_MESSAGE); + }); + }); +}); diff --git a/backend/src/eventOutbox.ts b/backend/src/eventOutbox.ts index 05dea308e..0580fea6a 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -102,6 +102,21 @@ function getInstanceId(): string { return process.env.OUTBOX_INSTANCE_ID || `instance-${crypto.randomUUID().slice(0, 8)}`; } +/** + * Throws if the Prisma client has no `eventOutbox` model delegate (e.g. a stale + * generated client or a mocked client missing the model). Without this guard + * the failure surfaces as a `Cannot read properties of undefined` TypeError + * deep inside fire-and-forget writes, where callers only log it. + */ +export function assertEventOutboxModelAvailable(client: unknown = prisma): void { + const delegate = (client as { eventOutbox?: { create?: unknown } } | null | undefined)?.eventOutbox; + if (!delegate || typeof delegate.create !== 'function') { + throw new Error( + 'Prisma client is missing the eventOutbox model. Run `prisma generate` and ensure the EventOutbox migration is applied.', + ); + } +} + // ─── Service ───────────────────────────────────────────────────────────────── class EventOutboxService { @@ -124,6 +139,7 @@ class EventOutboxService { * Returns the created outbox record. */ async writeEvent(input: OutboxWriteInput): Promise { + assertEventOutboxModelAvailable(); const now = new Date(); const record = await prisma.eventOutbox.create({ data: { @@ -385,6 +401,7 @@ class EventOutboxService { * Used to recover any events that were written but not relayed before a crash. */ async replayOnStartup(): Promise { + assertEventOutboxModelAvailable(); const pendingCount = await prisma.eventOutbox.count({ where: { status: { in: ['pending', 'failed'] } }, }); @@ -413,6 +430,9 @@ class EventOutboxService { return; } + // Fail fast at initialization rather than logging on every poll cycle. + assertEventOutboxModelAvailable(); + this.isRunning = true; const intervalMs = getPollIntervalMs();