diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index 2563cf06..cdf664c4 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -1,8 +1,8 @@ +import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { DatabaseClient } from '../context.js'; import { loadCurrentGameTime } from '../services/gameClock.js'; -import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra'; export interface MessageView { id: number; @@ -203,3 +203,26 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro }, }); }; + +export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Promise => { + const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); + if (uniqueIds.length === 0) return; + + await db.$executeRaw( + GamePrisma.sql` + UPDATE message + SET message = jsonb_set( + jsonb_set(message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true), + '{option}', + ( + CASE + WHEN jsonb_typeof(message->'option') = 'object' THEN message->'option' + ELSE '{}'::jsonb + END + ) || jsonb_build_object('invalid', true), + true + ) + WHERE id IN (${GamePrisma.join(uniqueIds)}) + ` + ); +}; diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 8baaa5a7..c023ebc0 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -19,8 +19,8 @@ import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox, fetchMessageById, - invalidateMessages, insertMessage, + tombstoneMessages, type MessageView, } from '../../messages/store.js'; import { getOwnedGeneral } from '../shared/general.js'; @@ -40,11 +40,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M } return { ...message, - text: '(외교 메시지입니다)', - option: { - ...(message.option ?? {}), - invalid: true, - }, + text: '조회 권한이 없는 외교 메시지입니다.', }; }); }; @@ -303,7 +299,7 @@ export const messagesRouter = router({ message.id, ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ]; - await invalidateMessages(ctx.db, ids); + await tombstoneMessages(ctx.db, ids); const receiverMailbox = shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private' ? message.payload.dest.generalId diff --git a/app/game-api/test/messageTombstone.integration.test.ts b/app/game-api/test/messageTombstone.integration.test.ts new file mode 100644 index 00000000..c3bf6e02 --- /dev/null +++ b/app/game-api/test/messageTombstone.integration.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { tombstoneMessages } from '../src/messages/store.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); + +integration('message deletion tombstone persistence', () => { + let db: GamePrismaClient; + let close: (() => Promise) | undefined; + + beforeAll(async () => { + const schema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + if (!schema?.endsWith('conditional_integration')) { + throw new Error(`Unsafe schema: ${schema ?? '(missing)'}`); + } + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + }); + + afterAll(async () => close?.()); + + it('keeps sender and receiver rows readable while replacing their bodies', async () => { + const rollback = new Error('rollback message tombstone fixture'); + await expect( + db.$transaction(async (transaction) => { + const validUntil = new Date('9999-12-31T00:00:00.000Z'); + const receiver = await transaction.message.create({ + data: { + mailbox: 8, + type: 'private', + src: 7, + dest: 8, + time: new Date('2026-08-24T00:00:00.000Z'), + validUntil, + message: { + src: { generalId: 7 }, + dest: { generalId: 8 }, + text: '수신 사본 원문', + option: { senderMessageID: 0 }, + }, + }, + }); + const sender = await transaction.message.create({ + data: { + mailbox: 7, + type: 'private', + src: 7, + dest: 8, + time: new Date('2026-08-24T00:00:00.000Z'), + validUntil, + message: { + src: { generalId: 7 }, + dest: { generalId: 8 }, + text: '송신 사본 원문', + option: { receiverMessageID: receiver.id }, + }, + }, + }); + + await tombstoneMessages(transaction, [sender.id, receiver.id]); + + const rows = await transaction.message.findMany({ + where: { id: { in: [sender.id, receiver.id] } }, + orderBy: { id: 'asc' }, + }); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.validUntil).toEqual(validUntil); + expect(row.message).toMatchObject({ + text: '삭제된 메시지입니다.', + option: { invalid: true }, + }); + expect(JSON.stringify(row.message)).not.toContain('사본 원문'); + } + + throw rollback; + }) + ).rejects.toBe(rollback); + }); +}); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index bb6f4562..5eebe448 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -176,13 +176,15 @@ describe('messages router missing-flow compatibility', () => { expect(recent.permission).toBe(2); expect(recent.diplomacy[0]).toMatchObject({ - text: '(외교 메시지입니다)', - option: { action: 'noAggression', invalid: true }, + text: '조회 권한이 없는 외교 메시지입니다.', + option: { action: 'noAggression' }, }); + expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(old.diplomacy[0]).toMatchObject({ - text: '(외교 메시지입니다)', - option: { action: 'noAggression', invalid: true }, + text: '조회 권한이 없는 외교 메시지입니다.', + option: { action: 'noAggression' }, }); + expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid'); }); it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { @@ -585,15 +587,13 @@ describe('messages router missing-flow compatibility', () => { }, ]); const changeJournal = new ChangeJournal(); - const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); + const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); const result = await caller.messages.delete({ generalId: general.id, messageId: 21 }); expect(result.deletedIds).toEqual([21, 22]); - expect(updateMany).toHaveBeenCalledWith({ - where: { id: { in: [21, 22] } }, - data: { validUntil: expect.any(Date) }, - }); + expect(executeRaw).toHaveBeenCalledOnce(); + expect(updateMany).not.toHaveBeenCalled(); expect(changeJournal.snapshot()).toEqual([ { domain: 'messages.mailbox', entityId: 7 }, { domain: 'messages.mailbox', entityId: 8 }, @@ -632,15 +632,13 @@ describe('messages router missing-flow compatibility', () => { }, }, ]); - const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }); + const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }); const result = await caller.messages.delete({ generalId: general.id, messageId: 25 }); expect(result.deletedIds).toEqual([25]); - expect(updateMany).toHaveBeenCalledWith({ - where: { id: { in: [25] } }, - data: { validUntil: expect.any(Date) }, - }); + expect(executeRaw).toHaveBeenCalledOnce(); + expect(updateMany).not.toHaveBeenCalled(); }); it('rejects deleting another general message', async () => { diff --git a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts index cb8be207..bf9666a0 100644 --- a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts +++ b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts @@ -91,7 +91,7 @@ const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f'); const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6'); const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19); -const buildMessages = (permission: number) => ({ +const buildMessages = (permission: number, tombstonedMessageIds: ReadonlySet = new Set()) => ({ result: true, public: [ { @@ -99,8 +99,8 @@ const buildMessages = (permission: number) => ({ msgType: 'public', src: ownTarget, dest: null, - text: '전체 메시지 본문', - option: {}, + text: tombstonedMessageIds.has(101) ? '삭제된 메시지입니다.' : '전체 메시지 본문', + option: tombstonedMessageIds.has(101) ? { invalid: true } : {}, time: messageTime, }, ], @@ -150,11 +150,11 @@ const buildMessages = (permission: number) => ({ msgType: 'diplomacy', src: foreignTarget, dest: target(0, '', 1, '테스트국', '#d32f2f'), - text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)', + text: permission >= 3 ? '외교 메시지 본문' : '조회 권한이 없는 외교 메시지입니다.', option: permission >= 3 ? { action: 'noAggression', deletable: false } - : { action: 'noAggression', deletable: false, invalid: true }, + : { action: 'noAggression', deletable: false }, time: messageTime, }, ], @@ -203,6 +203,7 @@ const installFixture = async ( options: { permission: number; sendError?: string } ): Promise> => { const mutations: Array<{ operation: string; body: unknown }> = []; + const tombstonedMessageIds = new Set(); await page.addInitScript( ({ gameToken, profile }) => { window.localStorage.setItem('sammo-game-token', gameToken); @@ -272,7 +273,9 @@ const installFixture = async ( if (operation === 'general.getRecentRecords') { return response({ global: [], general: [], history: [] }); } - if (operation === 'messages.getRecent') return response(buildMessages(options.permission)); + if (operation === 'messages.getRecent') { + return response(buildMessages(options.permission, tombstonedMessageIds)); + } if (operation === 'messages.getContacts') return response(contacts); if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true }); if (operation === 'tournament.getState') return response({ stage: 0 }); @@ -287,6 +290,10 @@ const installFixture = async ( if (operation === 'messages.send' && options.sendError) { return errorResponse(operation, options.sendError); } + if (operation === 'messages.delete') { + tombstonedMessageIds.add(101); + return response({ ok: true, deletedIds: [101] }); + } return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true }); } return errorResponse(operation, `Unhandled message fixture operation: ${operation}`); @@ -437,6 +444,16 @@ test('exposes nation targets including wanderers, reply, read, delete, and succe page.once('dialog', (dialog) => dialog.accept()); await deleteButton.click(); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1); + await expect(page.locator('.PublicTalk .msg-plate').filter({ hasText: '삭제된 메시지입니다' })).toBeVisible(); + await expect(page.locator('.PublicTalk')).not.toContainText('전체 메시지 본문'); + await expect(page.locator('.PublicTalk .delete-message')).toHaveCount(0); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.locator('.PublicTalk').screenshot({ + path: resolve(artifactRoot, 'message-delete-tombstone-500.png'), + animations: 'disabled', + }); + } await select.selectOption('9000'); await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요'); @@ -496,9 +513,17 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se const select = page.getByLabel('메시지 수신 대상'); await expect(select.locator('option[value="9000"]')).toHaveCount(0); await expect(select.locator('option[value="9002"]')).toHaveCount(0); - await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다'); + await expect(page.locator('.DiplomacyTalk')).toContainText('조회 권한이 없는 외교 메시지입니다.'); + await expect(page.locator('.DiplomacyTalk')).not.toContainText('삭제된 메시지입니다'); await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문'); await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled(); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.locator('.DiplomacyTalk').screenshot({ + path: resolve(artifactRoot, 'diplomacy-permission-redaction-500.png'), + animations: 'disabled', + }); + } await select.selectOption('9999'); await page.getByLabel('메시지 입력').fill('차단될 메시지');