diff --git a/app/game-api/test/messageTombstone.integration.test.ts b/app/game-api/test/messageTombstone.integration.test.ts index 62576435..1436134f 100644 --- a/app/game-api/test/messageTombstone.integration.test.ts +++ b/app/game-api/test/messageTombstone.integration.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { createGamePostgresConnector, persistMessageEnvelope, type GamePrismaClient } from '@sammo-ts/infra'; import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js'; @@ -23,6 +23,51 @@ integration('message deletion tombstone persistence', () => { afterAll(async () => close?.()); + it('persists an ordinary wall-time envelope without creating a game action', async () => { + const rollback = new Error('rollback ordinary message envelope fixture'); + await expect( + db.$transaction(async (transaction) => { + const target = { + generalId: 7, + generalName: '보낸이', + nationId: 0, + nationName: '재야', + color: '#000000', + icon: '', + }; + const id = await persistMessageEnvelope( + transaction, + { + mailbox: 9999, + msgType: 'public', + srcId: target.generalId, + destId: 9999, + time: new Date('0200-01-01T00:00:00.000Z'), + validUntil: new Date('9999-12-31T00:00:00.000Z'), + payload: { + src: target, + dest: target, + text: '일반 메시지는 WALL_TIME envelope만 저장한다.', + option: {}, + }, + }, + null + ); + + const message = await transaction.message.findUniqueOrThrow({ + where: { id }, + include: { action: true }, + }); + expect(message.createdAtWall).toBeInstanceOf(Date); + expect(message.deleteUntilWall.getTime() - message.createdAtWall.getTime()).toBe(5 * 60_000); + expect(message.occurredGameTick).toBeNull(); + expect(message.action).toBeNull(); + + throw rollback; + }) + ).rejects.toBe(rollback); + }); + it('keeps sender and receiver rows readable while replacing their bodies', async () => { const rollback = new Error('rollback message tombstone fixture'); await expect( diff --git a/packages/infra/src/messageEnvelope.ts b/packages/infra/src/messageEnvelope.ts index dcde04ff..671d75c3 100644 --- a/packages/infra/src/messageEnvelope.ts +++ b/packages/infra/src/messageEnvelope.ts @@ -87,7 +87,7 @@ export const persistMessageEnvelope = async ( ${gameContext?.clockRevision ?? 0n}, ${gameContext?.deadlineGeneration ?? 0n} FROM inserted - WHERE ${actionType} IS NOT NULL + WHERE CAST(${actionType} AS text) IS NOT NULL RETURNING message_id ) SELECT id FROM inserted diff --git a/tools/integration-tests/scripts/live-ten-user-lifecycle.ts b/tools/integration-tests/scripts/live-ten-user-lifecycle.ts index 462ab873..0b61e1bb 100644 --- a/tools/integration-tests/scripts/live-ten-user-lifecycle.ts +++ b/tools/integration-tests/scripts/live-ten-user-lifecycle.ts @@ -217,15 +217,15 @@ const status = async (): Promise => { const prepareUsers = async (): Promise => { const state = await readState(); - if (state.users?.length) throw new Error('Lifecycle users are already recorded.'); + if ((state.users?.length ?? 0) > 10) throw new Error('Lifecycle state contains more than ten users.'); const browser = await chromium.launch({ headless: true }); const runSlug = state.runId.replaceAll('-', '').slice(0, 8); const password = `Live-${state.runId}`; - const users: NonNullable = []; + const users: NonNullable = [...(state.users ?? [])]; let browserErrors = 0; let applicationHttpErrors = 0; try { - for (let index = 1; index <= 10; index += 1) { + for (let index = users.length + 1; index <= 10; index += 1) { const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); page.setDefaultTimeout(30_000); @@ -239,7 +239,7 @@ const prepareUsers = async (): Promise => { }); const suffix = String(index).padStart(2, '0'); const username = `live${runSlug}${suffix}`; - const displayName = `통합사용자${suffix}`; + const displayName = `통합${runSlug.slice(0, 4)}${suffix}`; const generalName = `통합장수${suffix}`; await page.goto(`${webOrigin}/gateway/signup`, { waitUntil: 'networkidle' }); await page.locator('#signup-username').fill(username); @@ -249,12 +249,32 @@ const prepareUsers = async (): Promise => { await page.locator('#signup-form input[type="checkbox"]').nth(0).check(); await page.locator('#signup-form input[type="checkbox"]').nth(1).check(); await page.getByRole('button', { name: '가입', exact: true }).click(); - await page.waitForURL(/\/gateway\/lobby/u); + const registrationResult = await Promise.race([ + page.waitForURL(/\/gateway\/lobby/u).then(() => 'registered' as const), + page + .locator('.signup-error') + .waitFor({ state: 'visible' }) + .then(() => 'rejected' as const), + ]); + if (registrationResult === 'rejected') { + const registrationError = (await page.locator('.signup-error').innerText()).trim(); + if (!registrationError.includes('이미 사용')) { + throw new Error(`Gateway registration failed for viewer ${index}: ${registrationError}`); + } + await page.goto(`${webOrigin}/gateway/`, { waitUntil: 'networkidle' }); + await page.locator('#username').fill(username); + await page.locator('#password').fill(password); + await page.getByRole('button', { name: '로그인', exact: true }).click(); + await page.waitForURL(/\/gateway\/lobby/u); + } const gatewayToken = await page.evaluate(() => window.localStorage.getItem('sammo-session-token')); if (!gatewayToken) throw new Error(`Gateway session was not persisted for viewer ${index}.`); const createButton = page.getByRole('button', { name: '장수생성', exact: true }); - await createButton.waitFor({ state: 'visible' }); + await createButton.waitFor({ state: 'visible' }).catch(async () => { + await page.reload({ waitUntil: 'networkidle' }); + await createButton.waitFor({ state: 'visible', timeout: 60_000 }); + }); await createButton.click(); await page.waitForURL(/\/hwe\/join/u); await page.getByLabel('장수명').fill(generalName); @@ -274,6 +294,8 @@ const prepareUsers = async (): Promise => { await page.screenshot({ path: path.join(artifactDir, 'preopen-user-01-main.png'), fullPage: true }); } users.push({ username, displayName, gatewayToken, gameToken, generalName }); + state.users = users; + await writeState(state); log('user-created', { index, username, displayName, generalName }); await context.close(); }