fix: API 입력 이벤트의 원자적 재실행을 보장한다
This commit is contained in:
@@ -4,6 +4,7 @@ import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { z } from 'zod';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
|
||||
|
||||
import {
|
||||
accessAuthedProcedure,
|
||||
@@ -303,13 +304,35 @@ describe('general access tracking', () => {
|
||||
const transactionClient = {
|
||||
$queryRaw: vi.fn(async (query: unknown) => {
|
||||
const sql = (query as { sql?: string }).sql ?? '';
|
||||
if (sql.includes('FROM input_event')) {
|
||||
return [
|
||||
{
|
||||
target: 'API',
|
||||
eventType: 'board.writeArticle',
|
||||
payload: createApiInputPayloadIdentity({ value: 'ok' }),
|
||||
actorUserId: 'user-7',
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
attempts: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
return sql.includes('read_model_revision')
|
||||
? [{ domain: 'access.general', entityId: 7, revision: 1n, outboxId: 1n }]
|
||||
: [{ id: 41 }];
|
||||
}),
|
||||
$executeRaw: vi.fn(async () => 1),
|
||||
$executeRaw: vi.fn(async (query: unknown) => {
|
||||
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) {
|
||||
events.push('input-event-create');
|
||||
}
|
||||
return 1;
|
||||
}),
|
||||
$executeRawUnsafe: vi.fn(async () => 0),
|
||||
inputEvent: {
|
||||
update: vi.fn(async () => ({})),
|
||||
update: vi.fn(async (args: { data: { status: string } }) => {
|
||||
if (args.data.status === 'FAILED') events.push('input-event-failed');
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
};
|
||||
const db = {
|
||||
@@ -338,17 +361,6 @@ describe('general access tracking', () => {
|
||||
},
|
||||
})),
|
||||
},
|
||||
inputEvent: {
|
||||
create: vi.fn(async () => {
|
||||
events.push('input-event-create');
|
||||
return {};
|
||||
}),
|
||||
update: vi.fn(async () => {
|
||||
events.push('input-event-failed');
|
||||
return {};
|
||||
}),
|
||||
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (client: typeof transactionClient) => Promise<unknown>) => {
|
||||
transactionCount += 1;
|
||||
events.push(transactionCount === 1 ? 'access-transaction' : 'business-transaction');
|
||||
@@ -385,8 +397,8 @@ describe('general access tracking', () => {
|
||||
expect(events).toEqual([
|
||||
'input-parse',
|
||||
'access-transaction',
|
||||
'input-event-create',
|
||||
'business-transaction',
|
||||
'input-event-create',
|
||||
'resolver',
|
||||
'input-event-failed',
|
||||
]);
|
||||
|
||||
@@ -2,8 +2,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { DuplicateInputEventError, executeInputEvent } from '../src/inputEventBoundary.js';
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import {
|
||||
createApiInputPayloadIdentity,
|
||||
DuplicateInputEventError,
|
||||
executeInputEvent,
|
||||
} from '../src/inputEventBoundary.js';
|
||||
import { procedure, router } from '../src/trpc.js';
|
||||
import {
|
||||
ConflictingTurnDaemonCommandError,
|
||||
@@ -74,6 +78,7 @@ integration('API input event boundary', () => {
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.success',
|
||||
payload: { markerId },
|
||||
actorUserId: 'user-7',
|
||||
execute: async (transaction) => {
|
||||
await transaction.inputEvent.create({
|
||||
@@ -94,6 +99,8 @@ integration('API input event boundary', () => {
|
||||
expect(event).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
actorUserId: 'user-7',
|
||||
payload: createApiInputPayloadIdentity({ markerId }),
|
||||
result: { ok: true },
|
||||
attempts: 1,
|
||||
});
|
||||
expect(event.processingAt).toBeInstanceOf(Date);
|
||||
@@ -168,6 +175,12 @@ integration('API input event boundary', () => {
|
||||
const outboxes = await db.readModelOutbox.findMany({ select: { payload: true } });
|
||||
expect(outboxes.some(({ payload }) => payloadHasGeneral(payload, journalGeneralIds[1]))).toBe(false);
|
||||
expect(wake).not.toHaveBeenCalled();
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:mutate` } })).toMatchObject({
|
||||
payload: createApiInputPayloadIdentity({ generalId: journalGeneralIds[1], fail: true }),
|
||||
status: 'FAILED',
|
||||
result: null,
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back business writes, records failure, and permits one explicit retry', async () => {
|
||||
@@ -178,6 +191,7 @@ integration('API input event boundary', () => {
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.failure',
|
||||
payload: { markerId },
|
||||
execute: async (transaction) => {
|
||||
await transaction.inputEvent.create({
|
||||
data: {
|
||||
@@ -194,6 +208,8 @@ integration('API input event boundary', () => {
|
||||
expect(await db.inputEvent.findUnique({ where: { requestId: markerId } })).toBeNull();
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
status: 'FAILED',
|
||||
payload: createApiInputPayloadIdentity({ markerId }),
|
||||
result: null,
|
||||
attempts: 1,
|
||||
error: 'injected transaction failure',
|
||||
});
|
||||
@@ -202,15 +218,17 @@ integration('API input event boundary', () => {
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.failure',
|
||||
payload: { markerId },
|
||||
execute: async () => ({ ok: true }),
|
||||
});
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
result: { ok: true },
|
||||
attempts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a concurrent duplicate idempotency key', async () => {
|
||||
it('serializes an exact concurrent retry and replays the original result without re-executing business', async () => {
|
||||
const requestId = 'integration:api:duplicate';
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
let signalStarted: (() => void) | undefined;
|
||||
@@ -224,25 +242,270 @@ integration('API input event boundary', () => {
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.duplicate',
|
||||
payload: { value: 7 },
|
||||
execute: async () => {
|
||||
signalStarted?.();
|
||||
await release;
|
||||
return { ok: true };
|
||||
return { ok: true, revision: 17 };
|
||||
},
|
||||
});
|
||||
await started;
|
||||
|
||||
const duplicateExecute = vi.fn(async () => ({ ok: true, revision: 99 }));
|
||||
const duplicate = executeInputEvent({
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.duplicate',
|
||||
payload: { value: 7 },
|
||||
execute: duplicateExecute,
|
||||
});
|
||||
releaseFirst?.();
|
||||
await expect(Promise.all([first, duplicate])).resolves.toEqual([
|
||||
{ ok: true, revision: 17 },
|
||||
{ ok: true, revision: 17 },
|
||||
]);
|
||||
expect(duplicateExecute).not.toHaveBeenCalled();
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
payload: createApiInputPayloadIdentity({ value: 7 }),
|
||||
result: { ok: true, revision: 17 },
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects request-id reuse with a changed payload, event type, or actor', async () => {
|
||||
const requestId = 'integration:api:identity-conflict';
|
||||
const original = await executeInputEvent({
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.identity',
|
||||
payload: { value: 1, nested: { left: true, right: false } },
|
||||
actorUserId: 'user-identity',
|
||||
execute: async () => ({ ok: true, revision: 4 }),
|
||||
});
|
||||
expect(original).toEqual({ ok: true, revision: 4 });
|
||||
|
||||
const conflicts = [
|
||||
{
|
||||
eventType: 'test.identity',
|
||||
payload: { value: 2, nested: { left: true, right: false } },
|
||||
actorUserId: 'user-identity',
|
||||
},
|
||||
{
|
||||
eventType: 'test.other-identity',
|
||||
payload: { value: 1, nested: { left: true, right: false } },
|
||||
actorUserId: 'user-identity',
|
||||
},
|
||||
{
|
||||
eventType: 'test.identity',
|
||||
payload: { value: 1, nested: { left: true, right: false } },
|
||||
actorUserId: 'other-user',
|
||||
},
|
||||
];
|
||||
for (const conflict of conflicts) {
|
||||
const conflictingExecute = vi.fn(async () => ({ ok: false }));
|
||||
await expect(
|
||||
executeInputEvent({ db, requestId, ...conflict, execute: conflictingExecute })
|
||||
).rejects.toBeInstanceOf(DuplicateInputEventError);
|
||||
expect(conflictingExecute).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
eventType: 'test.identity',
|
||||
actorUserId: 'user-identity',
|
||||
payload: createApiInputPayloadIdentity({ value: 1, nested: { left: true, right: false } }),
|
||||
result: { ok: true, revision: 4 },
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('reclaims an exact PENDING row under lock and counts one execution attempt', async () => {
|
||||
const requestId = 'integration:api:pending-reclaim';
|
||||
const payload = { value: 'pending' };
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'API',
|
||||
eventType: 'test.pending',
|
||||
payload: { ...createApiInputPayloadIdentity(payload) },
|
||||
actorUserId: 'pending-user',
|
||||
status: 'PENDING',
|
||||
attempts: 3,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeInputEvent({
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.duplicate',
|
||||
eventType: 'test.pending',
|
||||
payload,
|
||||
actorUserId: 'pending-user',
|
||||
execute: async () => ({ ok: true, attempt: 4 }),
|
||||
})
|
||||
).resolves.toEqual({ ok: true, attempt: 4 });
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
result: { ok: true, attempt: 4 },
|
||||
attempts: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('adopts only a matching legacy FAILED placeholder and replaces it with the canonical digest', async () => {
|
||||
const requestId = 'integration:api:legacy-failed';
|
||||
const payload = { value: 'legacy-retry' };
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'API',
|
||||
eventType: 'test.legacy-failed',
|
||||
payload: {},
|
||||
actorUserId: 'legacy-user',
|
||||
status: 'FAILED',
|
||||
attempts: 2,
|
||||
error: 'legacy failure',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeInputEvent({
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.legacy-failed',
|
||||
payload,
|
||||
actorUserId: 'legacy-user',
|
||||
execute: async () => ({ ok: true }),
|
||||
})
|
||||
).rejects.toBeInstanceOf(DuplicateInputEventError);
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
payload: createApiInputPayloadIdentity(payload),
|
||||
status: 'SUCCEEDED',
|
||||
result: { ok: true },
|
||||
error: null,
|
||||
attempts: 3,
|
||||
});
|
||||
});
|
||||
|
||||
releaseFirst?.();
|
||||
await first;
|
||||
it('fails closed on a committed legacy PROCESSING placeholder', async () => {
|
||||
const requestId = 'integration:api:legacy-processing';
|
||||
const processingAt = new Date(Date.now() - 60 * 60 * 1_000);
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'API',
|
||||
eventType: 'test.legacy-processing',
|
||||
payload: {},
|
||||
actorUserId: 'legacy-user',
|
||||
status: 'PROCESSING',
|
||||
attempts: 1,
|
||||
processingAt,
|
||||
},
|
||||
});
|
||||
const retryExecute = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
await expect(
|
||||
executeInputEvent({
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.legacy-processing',
|
||||
payload: { value: 'cannot-prove-legacy-identity' },
|
||||
actorUserId: 'legacy-user',
|
||||
execute: retryExecute,
|
||||
})
|
||||
).rejects.toBeInstanceOf(DuplicateInputEventError);
|
||||
expect(retryExecute).not.toHaveBeenCalled();
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
payload: {},
|
||||
status: 'PROCESSING',
|
||||
attempts: 1,
|
||||
processingAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('commits FAILED before a blocked exact retry and preserves the exact attempt count after success', async () => {
|
||||
const requestId = 'integration:api:failure-race';
|
||||
const payload = { value: 'race' };
|
||||
let releaseFailure: (() => void) | undefined;
|
||||
let signalStarted: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
signalStarted = resolve;
|
||||
});
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releaseFailure = resolve;
|
||||
});
|
||||
const failed = executeInputEvent({
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.failure-race',
|
||||
payload,
|
||||
execute: async () => {
|
||||
signalStarted?.();
|
||||
await release;
|
||||
throw new Error('first attempt failed');
|
||||
},
|
||||
});
|
||||
await started;
|
||||
const retry = executeInputEvent({
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.failure-race',
|
||||
payload,
|
||||
execute: async () => ({ ok: true, attempt: 2 }),
|
||||
});
|
||||
|
||||
releaseFailure?.();
|
||||
await expect(failed).rejects.toThrow('first attempt failed');
|
||||
await expect(retry).resolves.toEqual({ ok: true, attempt: 2 });
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
result: { ok: true, attempt: 2 },
|
||||
error: null,
|
||||
attempts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a late unexpected-failure recorder overwrite a transaction that actually committed', async () => {
|
||||
const requestId = 'integration:api:ambiguous-commit';
|
||||
const payload = { value: 'committed-before-client-error' };
|
||||
const ambiguousCommitDb = new Proxy(db, {
|
||||
get(target, property, receiver) {
|
||||
if (property !== '$transaction') return Reflect.get(target, property, receiver);
|
||||
return async (callback: (transaction: DatabaseClient) => Promise<unknown>) => {
|
||||
await db.$transaction(async (transaction) => callback(transaction));
|
||||
throw new Error('injected post-commit transport failure');
|
||||
};
|
||||
},
|
||||
}) as unknown as DatabaseClient;
|
||||
|
||||
await expect(
|
||||
executeInputEvent({
|
||||
db: ambiguousCommitDb,
|
||||
requestId,
|
||||
eventType: 'test.ambiguous-commit',
|
||||
payload,
|
||||
execute: async () => ({ ok: true, revision: 8 }),
|
||||
})
|
||||
).rejects.toThrow('injected post-commit transport failure');
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
result: { ok: true, revision: 8 },
|
||||
error: null,
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
const replayExecute = vi.fn(async () => ({ ok: false }));
|
||||
await expect(
|
||||
executeInputEvent({
|
||||
db,
|
||||
requestId,
|
||||
eventType: 'test.ambiguous-commit',
|
||||
payload,
|
||||
execute: replayExecute,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, revision: 8 });
|
||||
expect(replayExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses the same engine child event but rejects a changed retry payload', async () => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
|
||||
|
||||
describe('API input-event payload identity', () => {
|
||||
it('hashes canonical JSON independently of object key order', () => {
|
||||
expect(
|
||||
createApiInputPayloadIdentity({
|
||||
second: [{ z: true, a: 1 }],
|
||||
first: 'value',
|
||||
})
|
||||
).toEqual(
|
||||
createApiInputPayloadIdentity({
|
||||
first: 'value',
|
||||
second: [{ a: 1, z: true }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes changed values and array order', () => {
|
||||
const original = createApiInputPayloadIdentity({ value: 1, items: ['a', 'b'] });
|
||||
expect(createApiInputPayloadIdentity({ value: 2, items: ['a', 'b'] })).not.toEqual(original);
|
||||
expect(createApiInputPayloadIdentity({ value: 1, items: ['b', 'a'] })).not.toEqual(original);
|
||||
});
|
||||
|
||||
it('stores only a bounded digest envelope for a large or private payload', () => {
|
||||
const privatePayload = { dataUrl: `data:image/png;base64,${'A'.repeat(100_000)}`, text: 'private-message' };
|
||||
const identity = createApiInputPayloadIdentity(privatePayload);
|
||||
|
||||
expect(identity).toEqual({
|
||||
version: 1,
|
||||
digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u),
|
||||
});
|
||||
expect(JSON.stringify(identity)).not.toContain('private-message');
|
||||
expect(JSON.stringify(identity).length).toBeLessThan(128);
|
||||
});
|
||||
});
|
||||
@@ -2,46 +2,60 @@ 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 };
|
||||
}),
|
||||
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 = () => {
|
||||
const createContext = (payload: unknown = {}) => {
|
||||
const order: string[] = [];
|
||||
const queryRaw = vi.fn(async () => {
|
||||
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 () => {
|
||||
order.push('succeeded');
|
||||
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 = {
|
||||
inputEvent: {
|
||||
create: vi.fn(async () => {
|
||||
order.push('accepted');
|
||||
return {};
|
||||
}),
|
||||
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||
update: vi.fn(async () => {
|
||||
order.push('failed');
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (db: typeof transaction) => Promise<unknown>) => {
|
||||
order.push('transaction-begin');
|
||||
try {
|
||||
@@ -73,11 +87,15 @@ describe('API input-event change journal boundary', () => {
|
||||
await expect(testRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.order).toEqual([
|
||||
'accepted',
|
||||
'transaction-begin',
|
||||
'accepted',
|
||||
'locked',
|
||||
'processing',
|
||||
'savepoint',
|
||||
'handler',
|
||||
'journal',
|
||||
'succeeded',
|
||||
'savepoint-release',
|
||||
'commit',
|
||||
'wake',
|
||||
]);
|
||||
@@ -86,14 +104,25 @@ describe('API input-event change journal boundary', () => {
|
||||
});
|
||||
|
||||
it('rolls back a handler mark without writing or scheduling an outbox row', async () => {
|
||||
const fixture = createContext();
|
||||
const fixture = createContext({ fail: true });
|
||||
|
||||
await expect(testRouter.createCaller(fixture.context).mutate({ fail: true })).rejects.toThrow(
|
||||
'injected rollback'
|
||||
);
|
||||
|
||||
expect(fixture.order).toEqual(['accepted', 'transaction-begin', 'handler', 'rollback', 'failed']);
|
||||
expect(fixture.queryRaw).not.toHaveBeenCalled();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
|
||||
import { scopeApiInputEventRequestId } from '../src/trpc.js';
|
||||
|
||||
describe('HTTP idempotency request IDs', () => {
|
||||
it('is stable for one principal and isolated across users and profiles', () => {
|
||||
@@ -24,4 +25,12 @@ describe('HTTP idempotency request IDs', () => {
|
||||
expect(scoped).toMatch(/^http:[0-9a-f]{64}$/u);
|
||||
expect(scoped).toHaveLength(69);
|
||||
});
|
||||
|
||||
it('keeps the first call compatible and isolates later calls in a same-path batch', () => {
|
||||
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 0)).toBe('http:base:messages.send');
|
||||
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 1)).toBe('http:base:messages.send:batch:1');
|
||||
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 2)).not.toBe(
|
||||
scopeApiInputEventRequestId('http:base', 'messages.send', 1)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
|
||||
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
|
||||
import { createGameApiServer } from '../src/server.js';
|
||||
import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js';
|
||||
@@ -46,6 +47,7 @@ const matrixApiEventTypes = [
|
||||
'messages.send',
|
||||
'turns.reserved.setGeneral',
|
||||
'turns.reserved.setNation',
|
||||
'turns.reserved.setNationBulk',
|
||||
] as const;
|
||||
const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId];
|
||||
const secret = 'security-http-e2e-secret';
|
||||
@@ -74,6 +76,7 @@ let disconnectDb: (() => Promise<void>) | null = null;
|
||||
let redis: RedisConnector | null = null;
|
||||
let accessTokenStore: RedisAccessTokenStore;
|
||||
let createdFixtureWorld = false;
|
||||
let reservationWorldId = fixtureWorldId;
|
||||
let gatewayStatusServer: HttpServer | null = null;
|
||||
let receivedGatewayWebPushEvents: Array<{ internalToken: string | null; body: unknown }> = [];
|
||||
|
||||
@@ -285,6 +288,7 @@ const readReservedMutationState = async () => ({
|
||||
where: {
|
||||
OR: [
|
||||
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
],
|
||||
},
|
||||
@@ -420,7 +424,12 @@ const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>>
|
||||
const expectApiInputEvent = async (
|
||||
idempotencyKey: string,
|
||||
procedure: string,
|
||||
expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null
|
||||
expected: {
|
||||
actorUserId: string;
|
||||
status: 'FAILED' | 'SUCCEEDED';
|
||||
payload?: unknown;
|
||||
result?: unknown;
|
||||
} | null
|
||||
): Promise<void> => {
|
||||
const events = await db.inputEvent.findMany({
|
||||
// The HTTP boundary hashes the raw client key together with profile and
|
||||
@@ -455,10 +464,16 @@ const expectApiInputEvent = async (
|
||||
requestId,
|
||||
target: 'API',
|
||||
eventType: procedure,
|
||||
payload: {},
|
||||
payload:
|
||||
expected.payload === undefined
|
||||
? {
|
||||
version: 1,
|
||||
digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u),
|
||||
}
|
||||
: createApiInputPayloadIdentity(expected.payload),
|
||||
actorUserId: expected.actorUserId,
|
||||
status: expected.status,
|
||||
result: expected.status === 'SUCCEEDED' ? { ok: true } : null,
|
||||
result: expected.status === 'SUCCEEDED' ? (expected.result ?? expect.objectContaining({ ok: true })) : null,
|
||||
error: expected.status === 'SUCCEEDED' ? null : expect.any(String),
|
||||
attempts: 1,
|
||||
lockedBy: null,
|
||||
@@ -523,6 +538,33 @@ const requestReservedNation = (accessToken: string, idempotencyKey: string, targ
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
const requestReservedNationBulk = (accessToken: string, idempotencyKey: string, targetGeneralId: number) =>
|
||||
requestTrpc('turns.reserved.setNationBulk', {
|
||||
method: 'POST',
|
||||
input: {
|
||||
generalId: targetGeneralId,
|
||||
entries: [{ turnList: [0, 1], action: '휴식', args: {} }],
|
||||
expectedRevision: 0,
|
||||
},
|
||||
accessToken,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
type NationReservationKind = 'single' | 'bulk';
|
||||
|
||||
const nationReservationProcedure = (kind: NationReservationKind) =>
|
||||
kind === 'single' ? 'turns.reserved.setNation' : 'turns.reserved.setNationBulk';
|
||||
|
||||
const requestNationReservation = (
|
||||
kind: NationReservationKind,
|
||||
accessToken: string,
|
||||
idempotencyKey: string,
|
||||
targetGeneralId = generalId
|
||||
) =>
|
||||
kind === 'single'
|
||||
? requestReservedNation(accessToken, idempotencyKey, targetGeneralId)
|
||||
: requestReservedNationBulk(accessToken, idempotencyKey, targetGeneralId);
|
||||
|
||||
const ownershipDenialCases = [
|
||||
{
|
||||
label: 'authenticated user without a general',
|
||||
@@ -652,6 +694,13 @@ integration('game API security over HTTP transport', () => {
|
||||
});
|
||||
createdFixtureWorld = true;
|
||||
}
|
||||
const reservationWorlds = await db.worldState.findMany({ select: { id: true } });
|
||||
if (reservationWorlds.length !== 1 || !reservationWorlds[0]) {
|
||||
throw new Error(
|
||||
`security transport fixture requires exactly one world row, got ${reservationWorlds.length}`
|
||||
);
|
||||
}
|
||||
reservationWorldId = reservationWorlds[0].id;
|
||||
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||
await db.readModelOutbox.deleteMany();
|
||||
@@ -683,6 +732,7 @@ integration('game API security over HTTP transport', () => {
|
||||
where: {
|
||||
OR: [
|
||||
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
],
|
||||
},
|
||||
@@ -704,6 +754,14 @@ integration('game API security over HTTP transport', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await deleteMatrixInputEvents();
|
||||
await db.general.update({
|
||||
where: { id: generalId },
|
||||
data: { npcState: 0, meta: {}, penalty: {} },
|
||||
});
|
||||
await db.worldState.update({
|
||||
where: { id: reservationWorldId },
|
||||
data: { meta: {} },
|
||||
});
|
||||
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
@@ -715,6 +773,7 @@ integration('game API security over HTTP transport', () => {
|
||||
where: {
|
||||
OR: [
|
||||
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
],
|
||||
},
|
||||
@@ -1051,6 +1110,258 @@ integration('game API security over HTTP transport', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(
|
||||
(['single', 'bulk'] as const).flatMap((kind) =>
|
||||
[
|
||||
{ label: 'zero', value: 0 },
|
||||
{ label: 'false', value: false },
|
||||
{ label: 'null', value: null },
|
||||
].map((penalty) => ({ kind, ...penalty }))
|
||||
)
|
||||
)(
|
||||
'rejects $kind nation reservation when noChiefTurnInput is $label without committing queue or journal',
|
||||
async ({ kind, label, value }) => {
|
||||
const procedure = nationReservationProcedure(kind);
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-penalty-${kind}-${label}`;
|
||||
const accessToken = await createAccessToken(`matrix-nation-penalty-${kind}-${label}`, {});
|
||||
await db.general.update({
|
||||
where: { id: generalId },
|
||||
data: {
|
||||
meta: { killturn: 3, marker: 'penalty-preserved' },
|
||||
penalty: { noChiefTurnInput: value },
|
||||
},
|
||||
});
|
||||
await db.worldState.update({
|
||||
where: { id: reservationWorldId },
|
||||
data: { meta: { killturn: 12 } },
|
||||
});
|
||||
|
||||
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
|
||||
|
||||
expect(result.response.status).toBe(412);
|
||||
expect(result.body).toMatchObject({
|
||||
error: {
|
||||
message: '수뇌 턴 입력 불가능',
|
||||
data: { code: 'PRECONDITION_FAILED' },
|
||||
},
|
||||
});
|
||||
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(0);
|
||||
expect(
|
||||
await db.nationTurnRevision.findUnique({
|
||||
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
|
||||
})
|
||||
).toBeNull();
|
||||
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
|
||||
meta: { killturn: 3, marker: 'penalty-preserved' },
|
||||
penalty: { noChiefTurnInput: value },
|
||||
});
|
||||
expect(await db.readModelRevision.count()).toBe(0);
|
||||
expect(await db.readModelOutbox.count()).toBe(0);
|
||||
await expectApiInputEvent(idempotencyKey, procedure, {
|
||||
actorUserId: userId,
|
||||
status: 'FAILED',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ kind: 'single' as const, label: 'single' },
|
||||
{ kind: 'bulk' as const, label: 'bulk' },
|
||||
])('commits $label nation queue, JSONB killturn refill, and read-model journal together', async ({ kind }) => {
|
||||
const procedure = nationReservationProcedure(kind);
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-refill-${kind}`;
|
||||
const accessToken = await createAccessToken(`matrix-nation-refill-${kind}`, {});
|
||||
await db.general.update({
|
||||
where: { id: generalId },
|
||||
data: {
|
||||
npcState: 0,
|
||||
meta: { killturn: 3, marker: 'preserved-by-jsonb-set' },
|
||||
penalty: {},
|
||||
},
|
||||
});
|
||||
await db.worldState.update({
|
||||
where: { id: reservationWorldId },
|
||||
data: { meta: { killturn: 12, marker: 'world-preserved' } },
|
||||
});
|
||||
|
||||
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||
expect(
|
||||
await db.nationTurn.findMany({
|
||||
where: { nationId: ownerNationId, officerLevel: 12 },
|
||||
select: { turnIdx: true, actionCode: true, arg: true },
|
||||
orderBy: { turnIdx: 'asc' },
|
||||
})
|
||||
).toEqual(
|
||||
Array.from({ length: 12 }, (_, turnIdx) => ({
|
||||
turnIdx,
|
||||
actionCode: '휴식',
|
||||
arg: {},
|
||||
}))
|
||||
);
|
||||
expect(
|
||||
await db.nationTurnRevision.findUniqueOrThrow({
|
||||
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
|
||||
})
|
||||
).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null });
|
||||
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
|
||||
npcState: 0,
|
||||
meta: { killturn: 12, marker: 'preserved-by-jsonb-set' },
|
||||
});
|
||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: reservationWorldId } })).toMatchObject({
|
||||
meta: { killturn: 12, marker: 'world-preserved' },
|
||||
});
|
||||
expect(
|
||||
await db.readModelRevision.findMany({
|
||||
select: { domain: true, entityId: true, revision: true },
|
||||
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||
})
|
||||
).toEqual([
|
||||
{ domain: 'dashboard.global', entityId: 0, revision: 1n },
|
||||
{ domain: 'general.content', entityId: generalId, revision: 1n },
|
||||
]);
|
||||
expect(await db.readModelOutbox.findMany({ select: { payload: true } })).toEqual([
|
||||
{
|
||||
payload: {
|
||||
version: 1,
|
||||
changes: [
|
||||
['dashboard.global', 0, '1'],
|
||||
['general.content', generalId, '1'],
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
await expectApiInputEvent(idempotencyKey, procedure, {
|
||||
actorUserId: userId,
|
||||
status: 'SUCCEEDED',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: 'single' as const,
|
||||
label: 'already-higher user killturn',
|
||||
npcState: 0,
|
||||
currentKillturn: 20,
|
||||
},
|
||||
{
|
||||
kind: 'bulk' as const,
|
||||
label: 'NPC actor',
|
||||
npcState: 2,
|
||||
currentKillturn: 3,
|
||||
},
|
||||
])('keeps $label unchanged while committing its nation queue', async ({ kind, npcState, currentKillturn }) => {
|
||||
const procedure = nationReservationProcedure(kind);
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-refill-noop-${kind}`;
|
||||
const accessToken = await createAccessToken(`matrix-nation-refill-noop-${kind}`, {});
|
||||
await db.general.update({
|
||||
where: { id: generalId },
|
||||
data: {
|
||||
npcState,
|
||||
meta: { killturn: currentKillturn, marker: 'no-op-preserved' },
|
||||
penalty: {},
|
||||
},
|
||||
});
|
||||
await db.worldState.update({
|
||||
where: { id: reservationWorldId },
|
||||
data: { meta: { killturn: 12 } },
|
||||
});
|
||||
|
||||
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(12);
|
||||
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
|
||||
npcState,
|
||||
meta: { killturn: currentKillturn, marker: 'no-op-preserved' },
|
||||
});
|
||||
expect(await db.readModelRevision.count()).toBe(0);
|
||||
expect(await db.readModelOutbox.count()).toBe(0);
|
||||
await expectApiInputEvent(idempotencyKey, procedure, {
|
||||
actorUserId: userId,
|
||||
status: 'SUCCEEDED',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: 'single' as const, label: 'single' },
|
||||
{ kind: 'bulk' as const, label: 'bulk' },
|
||||
])(
|
||||
'rolls back $label queue, killturn, and read-model revisions when journal persistence fails',
|
||||
async ({ kind }) => {
|
||||
const procedure = nationReservationProcedure(kind);
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-refill-rollback-${kind}`;
|
||||
const accessToken = await createAccessToken(`matrix-nation-refill-rollback-${kind}`, {});
|
||||
await db.general.update({
|
||||
where: { id: generalId },
|
||||
data: {
|
||||
npcState: 0,
|
||||
meta: { killturn: 3, marker: 'rollback-preserved' },
|
||||
penalty: {},
|
||||
},
|
||||
});
|
||||
await db.worldState.update({
|
||||
where: { id: reservationWorldId },
|
||||
data: { meta: { killturn: 12 } },
|
||||
});
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE FUNCTION security_transport_fail_read_model_outbox()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'forced security transport read-model journal failure';
|
||||
END;
|
||||
$$
|
||||
`);
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TRIGGER security_transport_fail_read_model_outbox
|
||||
BEFORE INSERT ON read_model_outbox
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION security_transport_fail_read_model_outbox()
|
||||
`);
|
||||
|
||||
const result = await (async () => {
|
||||
try {
|
||||
return await requestNationReservation(kind, accessToken, idempotencyKey);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe(
|
||||
'DROP TRIGGER IF EXISTS security_transport_fail_read_model_outbox ON read_model_outbox'
|
||||
);
|
||||
await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS security_transport_fail_read_model_outbox()');
|
||||
}
|
||||
})();
|
||||
|
||||
expect(result.response.status).toBe(500);
|
||||
expect(result.body).toMatchObject({ error: { data: { code: 'INTERNAL_SERVER_ERROR' } } });
|
||||
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(0);
|
||||
expect(
|
||||
await db.nationTurnRevision.findUnique({
|
||||
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
|
||||
})
|
||||
).toBeNull();
|
||||
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
|
||||
npcState: 0,
|
||||
meta: { killturn: 3, marker: 'rollback-preserved' },
|
||||
});
|
||||
expect(await db.readModelRevision.count()).toBe(0);
|
||||
expect(await db.readModelOutbox.count()).toBe(0);
|
||||
await expectApiInputEvent(idempotencyKey, procedure, {
|
||||
actorUserId: userId,
|
||||
status: 'FAILED',
|
||||
});
|
||||
expect(
|
||||
await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: resolveScopedApiRequestId(idempotencyKey, procedure, userId) },
|
||||
select: { error: true },
|
||||
})
|
||||
).toEqual({ error: expect.stringContaining('forced security transport read-model journal failure') });
|
||||
}
|
||||
);
|
||||
|
||||
it('commits an owned general reservation once with an authenticated actor and durable journal', async () => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}general-success`;
|
||||
const accessToken = await createAccessToken('matrix-general-success', {});
|
||||
@@ -1409,8 +1720,15 @@ integration('game API security over HTTP transport', () => {
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
}, 15_000);
|
||||
|
||||
it('commits an owned officer nation reservation and rejects duplicate idempotency replay without a second queue mutation', async () => {
|
||||
it('commits an owned officer nation reservation and replays the durable response without a second queue mutation', async () => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-success`;
|
||||
const inputPayload = {
|
||||
generalId,
|
||||
turnIndex: 0,
|
||||
action: '휴식',
|
||||
args: {},
|
||||
expectedRevision: 0,
|
||||
};
|
||||
const accessToken = await createAccessToken('matrix-nation-success', {});
|
||||
const databaseBefore = await readReservedMutationState();
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
@@ -1420,6 +1738,7 @@ integration('game API security over HTTP transport', () => {
|
||||
const first = await requestReservedNation(accessToken, idempotencyKey, generalId);
|
||||
expect(first.response.status).toBe(200);
|
||||
expect(first.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||
const firstResult = (first.body as { result: { data: unknown } }).result.data;
|
||||
expect(
|
||||
await db.nationTurn.findMany({
|
||||
where: { nationId: ownerNationId, officerLevel: 12 },
|
||||
@@ -1443,6 +1762,8 @@ integration('game API security over HTTP transport', () => {
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||
actorUserId: userId,
|
||||
status: 'SUCCEEDED',
|
||||
payload: inputPayload,
|
||||
result: firstResult,
|
||||
});
|
||||
|
||||
const committed = await readReservedMutationState();
|
||||
@@ -1551,10 +1872,14 @@ integration('game API security over HTTP transport', () => {
|
||||
where: { requestId: replayRequestId },
|
||||
});
|
||||
const replay = await requestReservedNation(accessToken, idempotencyKey, generalId);
|
||||
expect(replay.response.status).toBe(409);
|
||||
expect(replay.body).toMatchObject({ error: { data: { code: 'CONFLICT' } } });
|
||||
expect(await readReservedMutationState()).toEqual(committed);
|
||||
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(replayDurableBefore);
|
||||
expect(replay.response.status).toBe(200);
|
||||
expect(replay.body).toEqual(first.body);
|
||||
const replayState = await readReservedMutationState();
|
||||
expect({ ...replayState, generalAccessLogs: committed.generalAccessLogs }).toEqual(committed);
|
||||
expectSingleActorActivity(replayState.generalAccessLogs);
|
||||
expect(
|
||||
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), ['general_access_log'])
|
||||
).toEqual(withoutDurableTables(replayDurableBefore, ['general_access_log']));
|
||||
expect(await readRealtimeRedisState()).toEqual(replayRedisBefore);
|
||||
expect(
|
||||
await db.inputEvent.findUniqueOrThrow({
|
||||
@@ -1565,6 +1890,8 @@ integration('game API security over HTTP transport', () => {
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||
actorUserId: userId,
|
||||
status: 'SUCCEEDED',
|
||||
payload: inputPayload,
|
||||
result: firstResult,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user