Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions backend/src/__tests__/eventOutboxModelGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
21 changes: 21 additions & 0 deletions backend/src/eventOutbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,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 {
Expand All @@ -153,6 +168,7 @@ class EventOutboxService {
* Returns the created outbox record.
*/
async writeEvent(input: OutboxWriteInput): Promise<EventOutboxRecord> {
assertEventOutboxModelAvailable();
const now = new Date();
const record = await getEventOutboxDelegate().create({
data: {
Expand Down Expand Up @@ -424,6 +440,8 @@ class EventOutboxService {
* Used to recover any events that were written but not relayed before a crash.
*/
async replayOnStartup(): Promise<OutboxRelayResult> {
assertEventOutboxModelAvailable();
const pendingCount = await prisma.eventOutbox.count({
const pendingCount = await getEventOutboxDelegate().count({
where: { status: { in: ['pending', 'failed'] } },
});
Expand Down Expand Up @@ -458,6 +476,9 @@ class EventOutboxService {
return;
}

// Fail fast at initialization rather than logging on every poll cycle.
assertEventOutboxModelAvailable();

this.isRunning = true;
this.signal = signal;
const intervalMs = getPollIntervalMs();
Expand Down
Loading