Files
core2026/app/game-api/test/inputEventJournal.test.ts
T

130 lines
4.6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { procedure, router } from '../src/trpc.js';
const testRouter = router({
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
ctx.changeJournal?.mark('front.general', 7);
if (input.fail) throw new Error('injected rollback');
return { ok: true };
}),
});
const createContext = (payload: unknown = {}) => {
const order: string[] = [];
const queryRaw = vi.fn(async (query: { sql?: string }) => {
if (query.sql?.includes('FROM input_event')) {
order.push('locked');
return [
{
target: 'API',
eventType: 'mutate',
payload: createApiInputPayloadIdentity(payload),
actorUserId: null,
status: 'PENDING',
result: null,
attempts: 0,
},
];
}
order.push('journal');
return [{ domain: 'front.general', entityId: 7, revision: 1n, outboxId: 11n }];
});
const transaction = {
$queryRaw: queryRaw,
$executeRaw: vi.fn(async () => {
order.push('accepted');
return 1;
}),
$executeRawUnsafe: vi.fn(async (statement: string) => {
if (statement.startsWith('SAVEPOINT ')) order.push('savepoint');
else if (statement.startsWith('ROLLBACK TO ')) order.push('savepoint-rollback');
else if (statement.startsWith('RELEASE ')) order.push('savepoint-release');
return 0;
}),
inputEvent: {
update: vi.fn(async (args: { data: { status: string } }) => {
if (args.data.status === 'PROCESSING') order.push('processing');
else if (args.data.status === 'SUCCEEDED') order.push('succeeded');
else if (args.data.status === 'FAILED') order.push('failed');
return {};
}),
},
};
const db = {
$transaction: vi.fn(async (callback: (db: typeof transaction) => Promise<unknown>) => {
order.push('transaction-begin');
try {
const result = await callback(transaction);
order.push('commit');
return result;
} catch (error) {
order.push('rollback');
throw error;
}
}),
};
const redisPublish = vi.fn();
const wake = vi.fn(() => order.push('wake'));
const context = {
requestId: 'journal-unit',
db,
redis: { publish: redisPublish },
readModelOutbox: { wake },
testOrder: order,
} as unknown as GameApiContext & { testOrder: string[] };
return { context, order, queryRaw, redisPublish, wake };
};
describe('API input-event change journal boundary', () => {
it('writes the journal with SUCCEEDED, commits, and only then schedules delivery', async () => {
const fixture = createContext();
await expect(testRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
expect(fixture.order).toEqual([
'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler',
'journal',
'succeeded',
'savepoint-release',
'commit',
'wake',
]);
expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).toHaveBeenCalledTimes(1);
});
it('rolls back a handler mark without writing or scheduling an outbox row', async () => {
const fixture = createContext({ fail: true });
await expect(testRouter.createCaller(fixture.context).mutate({ fail: true })).rejects.toThrow(
'injected rollback'
);
expect(fixture.order).toEqual([
'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler',
'savepoint-rollback',
'savepoint-release',
'failed',
'commit',
]);
expect(fixture.queryRaw).toHaveBeenCalledTimes(1);
expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).not.toHaveBeenCalled();
});
});