test: exercise actual NPC possession timeout

This commit is contained in:
2026-07-31 04:46:45 +00:00
parent dfd5c3bc0f
commit bac385a12f
2 changed files with 42 additions and 42 deletions
@@ -15,6 +15,7 @@ const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
const databaseUrl = process.env.NPC_POSSESSION_LIVE_DATABASE_URL ?? ''; const databaseUrl = process.env.NPC_POSSESSION_LIVE_DATABASE_URL ?? '';
const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL ?? ''; const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL ?? '';
const gameSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET ?? ''; const gameSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET ?? '';
const daemonRequestTimeoutMs = Number(process.env.NPC_POSSESSION_LIVE_DAEMON_TIMEOUT_MS ?? '1000');
const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (databaseUrl && databaseSchema !== profileId) { if (databaseUrl && databaseSchema !== profileId) {
@@ -27,6 +28,9 @@ if (gameProfile !== expectedGameProfile) {
`PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}` `PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}`
); );
} }
if (!Number.isSafeInteger(daemonRequestTimeoutMs) || daemonRequestTimeoutMs < 100 || daemonRequestTimeoutMs > 60_000) {
throw new Error('NPC_POSSESSION_LIVE_DAEMON_TIMEOUT_MS must be an integer from 100 to 60000');
}
export default defineConfig({ export default defineConfig({
testDir: '.', testDir: '.',
@@ -64,6 +68,7 @@ export default defineConfig({
PROFILE: profileId, PROFILE: profileId,
SCENARIO: scenario, SCENARIO: scenario,
GAME_PROFILE_NAME: gameProfile, GAME_PROFILE_NAME: gameProfile,
DAEMON_REQUEST_TIMEOUT_MS: String(daemonRequestTimeoutMs),
}, },
}, },
{ {
+37 -42
View File
@@ -61,6 +61,20 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined; let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
let daemonLoop: Promise<void> | undefined; let daemonLoop: Promise<void> | undefined;
const startDaemon = async (): Promise<void> => {
if (runtime) {
return;
}
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: 'npc-possession-live-daemon',
});
daemonLoop = runtime.lifecycle.start();
};
test.beforeAll(async () => { test.beforeAll(async () => {
const schema = new URL(databaseUrl!).searchParams.get('schema'); const schema = new URL(databaseUrl!).searchParams.get('schema');
const profileId = profile.split(':', 1)[0] ?? ''; const profileId = profile.split(':', 1)[0] ?? '';
@@ -120,14 +134,6 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C
penalty: {}, penalty: {},
})), })),
}); });
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: 'npc-possession-live-daemon',
});
daemonLoop = runtime.lifecycle.start();
}); });
test.afterAll(async () => { test.afterAll(async () => {
@@ -139,11 +145,8 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C
await closeDb?.(); await closeDb?.();
}); });
test('replays one completed possession after the browser receives an indeterminate timeout', async ({ test('retries one pending possession after the actual daemon transport timeout', async ({ page }, testInfo) => {
page,
}, testInfo) => {
const requestIds: string[] = []; const requestIds: string[] = [];
let injectTimeout = true;
await installSession(page); await installSession(page);
await page.route('**/image/icons/**', async (route) => { await page.route('**/image/icons/**', async (route) => {
await route.fulfill({ await route.fulfill({
@@ -152,7 +155,10 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>', body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
}); });
}); });
await page.route('**/trpc/join.possessGeneral?batch=1', async (route) => { page.on('request', (request) => {
if (!request.url().includes('/trpc/join.possessGeneral?batch=1')) {
return;
}
const findClientRequestId = (value: unknown): string | undefined => { const findClientRequestId = (value: unknown): string | undefined => {
if (!value || typeof value !== 'object') return undefined; if (!value || typeof value !== 'object') return undefined;
if ('clientRequestId' in value && typeof value.clientRequestId === 'string') { if ('clientRequestId' in value && typeof value.clientRequestId === 'string') {
@@ -164,34 +170,9 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C
} }
return undefined; return undefined;
}; };
const clientRequestId = findClientRequestId(route.request().postDataJSON()); const clientRequestId = findClientRequestId(request.postDataJSON());
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/); expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/);
requestIds.push(clientRequestId!); requestIds.push(clientRequestId!);
if (!injectTimeout) {
await route.continue();
return;
}
injectTimeout = false;
const accepted = await route.fetch();
expect(accepted.ok()).toBe(true);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message:
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
code: -32008,
data: {
code: 'TIMEOUT',
httpStatus: 408,
path: 'join.possessGeneral',
},
},
},
]),
});
}); });
await page.setViewportSize({ width: 1024, height: 900 }); await page.setViewportSize({ width: 1024, height: 900 });
@@ -217,19 +198,33 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C
const possessButton = page.locator('.npc-action').first(); const possessButton = page.locator('.npc-action').first();
await possessButton.click(); await possessButton.click();
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.'); await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1); expect(await db.general.count({ where: { userId } })).toBe(0);
const pending = await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action')); const pending = await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'));
expect(pending).toContain(requestIds[0]); expect(pending).toContain(requestIds[0]);
const eventRequestId = `npc-possess:${userId}:${requestIds[0]}`;
const pendingEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: eventRequestId },
});
expect(pendingEvent).toMatchObject({
status: 'PENDING',
attempts: 0,
result: null,
error: null,
completedAt: null,
});
await expect(db.npcSelectionToken.findUnique({ where: { ownerUserId: userId } })).resolves.not.toBeNull();
await startDaemon();
await possessButton.click(); await possessButton.click();
await expect(page).toHaveURL(/\/hwe\/$/); await expect(page).toHaveURL(/\/hwe\/$/);
expect(requestIds).toHaveLength(2); expect(requestIds).toHaveLength(2);
expect(requestIds[1]).toBe(requestIds[0]); expect(requestIds[1]).toBe(requestIds[0]);
expect(await db.general.count({ where: { userId } })).toBe(1); expect(await db.general.count({ where: { userId } })).toBe(1);
await expect(db.npcSelectionToken.findUnique({ where: { ownerUserId: userId } })).resolves.toBeNull();
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull(); expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
expect(dialogs).toContain('빙의에 성공했습니다.'); expect(dialogs).toContain('빙의에 성공했습니다.');
const event = await db.inputEvent.findFirstOrThrow({ const event = await db.inputEvent.findUniqueOrThrow({
where: { actorUserId: userId, eventType: 'npcPossessGeneral' }, where: { requestId: eventRequestId },
}); });
expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 }); expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });