import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { JosaUtil, MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; import { MESSAGE_MAILBOX_NATIONAL_BASE, type MessagePayload, type MessageTarget, type MessageType, } from '@sammo-ts/logic'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import type { GameApiContext } from '../src/context.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; import { fetchMessagesFromMailbox, invalidateMessages } from '../src/messages/store.js'; import { appRouter } from '../src/router.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const fixtureNationId = 841; const foreignNationId = fixtureNationId + 1; const fixtureGeneralId = 8_864_243; const foreignGeneralId = fixtureGeneralId + 1; const fixtureWorldStateId = -8_864_241; const fixtureUserId = 'diplomacy-document-message-src-user'; const foreignUserId = 'diplomacy-document-message-dest-user'; const requestPrefix = 'integration:diplomacy-document-message'; const fixtureMailboxes = [ MESSAGE_MAILBOX_NATIONAL_BASE + fixtureNationId, MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId, ] as const; const clockBaseTime = new Date('0208-04-05T06:07:08.000Z'); const logicalGameTime = new Date('0208-04-05T06:17:08.000Z'); const logicalGameTick = 36_000_000n; const buildAuth = (userId: string, sessionSuffix: string): GameSessionTokenPayload => ({ version: 1, profile: 'che:diplomacy-document-message', issuedAt: '2026-08-24T00:00:00.000Z', expiresAt: '2027-08-24T00:00:00.000Z', sessionId: `diplomacy-document-message-${sessionSuffix}`, user: { id: userId, username: userId, displayName: userId, roles: ['user'], }, sanctions: {}, }); const fixtureAuth = buildAuth(fixtureUserId, 'src'); const foreignAuth = buildAuth(foreignUserId, 'dest'); const isFixtureOutboxPayload = (payload: unknown): boolean => { if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false; const changes = (payload as { changes?: unknown }).changes; return ( Array.isArray(changes) && changes.some( (change) => Array.isArray(change) && change[0] === 'messages.mailbox' && fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) ) ); }; integration('diplomacy document message persistence', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; const deleteFixtureOutboxes = async (): Promise => { const rows = await db.readModelOutbox.findMany({ select: { id: true, payload: true } }); const ids = rows.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id); if (ids.length > 0) { await db.readModelOutbox.deleteMany({ where: { id: { in: ids } } }); } }; const cleanupRouteState = async (): Promise => { await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } }); await db.diplomacyLetter.deleteMany({ where: { OR: [ { srcNationId: { in: [fixtureNationId, foreignNationId] } }, { destNationId: { in: [fixtureNationId, foreignNationId] } }, ], }, }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await deleteFixtureOutboxes(); await db.readModelRevision.deleteMany({ where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, }); }; const cleanupFixture = async (): Promise => { await cleanupRouteState(); await db.general.deleteMany({ where: { id: { in: [fixtureGeneralId, foreignGeneralId] } } }); await db.nation.deleteMany({ where: { id: { in: [fixtureNationId, foreignNationId] } } }); await db.worldState.deleteMany({ where: { id: fixtureWorldStateId } }); }; const buildContext = ( requestId: string, auth: GameSessionTokenPayload, database: GameApiContext['db'] = db ): GameApiContext => { const redisClient = { get: async () => null, set: async () => null, publish: async () => 0, } as unknown as RedisConnector['client']; return { requestId: `${requestPrefix}:${requestId}`, db: database, redis: redisClient, turnDaemon: new InMemoryTurnDaemonTransport(), battleSim: new InMemoryBattleSimTransport(), profile: { id: 'che', scenario: 'diplomacy-document-message', name: 'che:diplomacy-document-message', }, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, auth, accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:diplomacy-document-message'), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'diplomacy-document-message-secret', }; }; const createLetter = async ( options: { state?: 'PROPOSED' | 'ACTIVATED'; srcNationId?: number; destNationId?: number; srcSignerId?: number; destSignerId?: number | null; } = {} ) => { const srcNationId = options.srcNationId ?? fixtureNationId; const destNationId = options.destNationId ?? foreignNationId; const srcSignerId = options.srcSignerId ?? fixtureGeneralId; const state = options.state ?? 'PROPOSED'; return db.diplomacyLetter.create({ data: { srcNationId, destNationId, state, textBrief: '통합 외교문서', textDetail: '통합 외교문서 상세', date: logicalGameTime, srcSignerId, destSignerId: options.destSignerId === undefined ? state === 'ACTIVATED' ? foreignGeneralId : null : options.destSignerId, aux: { src: { nationName: srcNationId === fixtureNationId ? '원민국' : '상대국', nationColor: srcNationId === fixtureNationId ? '#123456' : '#654321', generalId: srcSignerId, generalName: srcSignerId === fixtureGeneralId ? '원민수뇌' : '상대수뇌', }, dest: { nationName: destNationId === fixtureNationId ? '원민국' : '상대국', nationColor: destNationId === fixtureNationId ? '#123456' : '#654321', }, }, }, }); }; const expectInputEvent = async (requestId: string, route: string, actorUserId: string): Promise => { await expect( db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestPrefix}:${requestId}:diplomacy.${route}` }, }) ).resolves.toMatchObject({ target: 'API', eventType: `diplomacy.${route}`, actorUserId, status: 'SUCCEEDED', attempts: 1, }); }; const expectNoticeCopies = async (options: { text: string; types: readonly MessageType[]; src: Pick; dest: Pick; }): Promise => { const allRows = await db.message.findMany({ where: { mailbox: { in: [...fixtureMailboxes] } }, orderBy: { id: 'asc' }, }); const srcMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId; const destMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId; for (const type of options.types) { const rows = allRows.filter((row) => { const payload = row.message as unknown as MessagePayload; return row.type === type && payload.text === options.text; }); expect(rows, `${type}: ${options.text}`).toHaveLength(2); expect(rows.map(({ mailbox }) => mailbox).sort((left, right) => left - right)).toEqual( [srcMailbox, destMailbox].sort((left, right) => left - right) ); const receiver = rows.find(({ mailbox }) => mailbox === destMailbox); const sender = rows.find(({ mailbox }) => mailbox === srcMailbox); expect(receiver).toBeDefined(); expect(sender).toBeDefined(); for (const row of rows) { const payload = row.message as unknown as MessagePayload; expect(row).toMatchObject({ type, src: srcMailbox, dest: destMailbox, time: logicalGameTime, timeTick: logicalGameTick, }); expect(payload).toMatchObject({ src: options.src, dest: options.dest, text: options.text, option: { deletable: false }, }); expect(payload.option?.deletable).toBe(false); } const receiverPayload = receiver?.message as unknown as MessagePayload; const senderPayload = sender?.message as unknown as MessagePayload; expect(receiverPayload.option).toEqual({ deletable: false }); expect(senderPayload.option).toMatchObject({ deletable: false, receiverMessageID: receiver?.id, }); } }; const buildRollbackDatabase = (failure: Error): GameApiContext['db'] => { const database = db as unknown as GameApiContext['db']; let failNextTransaction = true; return new Proxy(database, { get(target, property) { if (property === '$transaction') { return async (callback: (transaction: GameApiContext['db']) => Promise) => { if (!failNextTransaction) { return db.$transaction((transaction) => callback(transaction as unknown as GameApiContext['db']) ); } failNextTransaction = false; return db.$transaction(async (transaction) => { await callback(transaction as unknown as GameApiContext['db']); throw failure; }); }; } return Reflect.get(target, property, target); }, }); }; beforeAll(async () => { const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); await cleanupFixture(); await db.worldState.create({ data: { id: fixtureWorldStateId, scenarioCode: 'diplomacy-document-message', currentYear: 208, currentMonth: 4, tickSeconds: 600, clockBaseTime, clockTick: logicalGameTick, clockMode: 'manual', clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'), config: {}, meta: {}, }, }); await db.nation.createMany({ data: [ { id: fixtureNationId, name: '원민국', color: '#123456' }, { id: foreignNationId, name: '상대국', color: '#654321' }, ], }); await db.general.createMany({ data: [ { id: fixtureGeneralId, userId: fixtureUserId, name: '원민수뇌', nationId: fixtureNationId, officerLevel: 12, picture: 'src.png', imageServer: 0, turnTime: logicalGameTime, }, { id: foreignGeneralId, userId: foreignUserId, name: '상대수뇌', nationId: foreignNationId, officerLevel: 12, picture: 'dest.png', imageServer: 0, turnTime: logicalGameTime, }, ], }); }); beforeEach(cleanupRouteState); afterEach(cleanupRouteState); afterAll(async () => { await cleanupFixture(); await closeDb?.(); }); it('stores only diplomacy receiver/sender copies for new and chained documents', async () => { const firstRequestId = 'send-first'; const first = await appRouter.createCaller(buildContext(firstRequestId, fixtureAuth)).diplomacy.sendLetter({ destNationId: foreignNationId, brief: '첫 외교문서', detail: '첫 외교문서 상세', }); const firstText = `새로운 외교 문서 #${first.id}${JosaUtil.pick(String(first.id), '이')} 준비되었습니다. 외교부에서 확인해주세요.`; await expectNoticeCopies({ text: firstText, types: ['diplomacy'], src: { generalId: fixtureGeneralId, generalName: '원민수뇌', nationId: fixtureNationId, nationName: '원민국', }, dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' }, }); await expectInputEvent(firstRequestId, 'sendLetter', fixtureUserId); const chainedRequestId = 'send-chained'; const chained = await appRouter.createCaller(buildContext(chainedRequestId, fixtureAuth)).diplomacy.sendLetter({ destNationId: foreignNationId, prevId: first.id, brief: '후속 외교문서', detail: '후속 외교문서 상세', }); const chainedText = `문서 #${first.id}의 새로운 외교 문서 #${chained.id}${JosaUtil.pick(String(chained.id), '이')} 준비되었습니다. 외교부에서 확인해주세요.`; await expectNoticeCopies({ text: chainedText, types: ['diplomacy'], src: { generalId: fixtureGeneralId, generalName: '원민수뇌', nationId: fixtureNationId, nationName: '원민국', }, dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' }, }); expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(4); await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId); }); it('keeps permanent messages readable without a clock and does not resurrect them after invalidation', async () => { const created = await appRouter .createCaller(buildContext('legacy-clock-fallback', fixtureAuth)) .diplomacy.sendLetter({ destNationId: foreignNationId, brief: '시계 이관 중 외교문서', detail: '시계 이관 중에도 보여야 합니다.', }); const receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId; const receiver = await db.message.findFirstOrThrow({ where: { mailbox: receiverMailbox, type: 'diplomacy' }, orderBy: { id: 'desc' }, }); expect(receiver.validUntilTick).toBe(BigInt(MAX_SAFE_GAME_TICK)); await db.worldState.update({ where: { id: fixtureWorldStateId }, data: { clockBaseTime: null, clockTick: null, clockWallAnchor: null }, }); try { const messages = await fetchMessagesFromMailbox({ db, mailbox: receiverMailbox, msgType: 'diplomacy', limit: 15, fromSeq: 0, }); expect(messages).toContainEqual( expect.objectContaining({ id: receiver.id, text: expect.stringContaining(`#${created.id}`), }) ); await invalidateMessages(db, [receiver.id]); await expect( db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { validUntilTick: true } }) ).resolves.toEqual({ validUntilTick: 0n }); await expect( fetchMessagesFromMailbox({ db, mailbox: receiverMailbox, msgType: 'diplomacy', limit: 15, fromSeq: 0, }) ).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id })); } finally { await db.worldState.update({ where: { id: fixtureWorldStateId }, data: { clockBaseTime, clockTick: logicalGameTick, clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'), }, }); } await expect( fetchMessagesFromMailbox({ db, mailbox: receiverMailbox, msgType: 'diplomacy', limit: 15, fromSeq: 0, }) ).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id })); }); it('stores diplomacy and national copies for both approval and rejection responses', async () => { const approved = await createLetter(); const approveRequestId = 'respond-approve'; await expect( appRouter .createCaller(buildContext(approveRequestId, foreignAuth)) .diplomacy.respondLetter({ letterId: approved.id, agree: true }) ).resolves.toEqual({ ok: true }); await expectNoticeCopies({ text: `외교 서신( #${approved.id})이 승인되었습니다.`, types: ['diplomacy', 'national'], src: { generalId: foreignGeneralId, generalName: '상대수뇌', nationId: foreignNationId, nationName: '상대국', }, dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' }, }); await expectInputEvent(approveRequestId, 'respondLetter', foreignUserId); const rejected = await createLetter(); const rejectRequestId = 'respond-reject'; await expect( appRouter.createCaller(buildContext(rejectRequestId, foreignAuth)).diplomacy.respondLetter({ letterId: rejected.id, agree: false, reason: '조건 불충족', }) ).resolves.toEqual({ ok: true }); await expectNoticeCopies({ text: `외교 서신(#${rejected.id})이 거부되었습니다. 이유 : 조건 불충족`, types: ['diplomacy', 'national'], src: { generalId: foreignGeneralId, generalName: '상대수뇌', nationId: foreignNationId, nationName: '상대국', }, dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' }, }); expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(8); await expectInputEvent(rejectRequestId, 'respondLetter', foreignUserId); }); it('stores diplomacy copies when the sender rolls a proposed document back', async () => { const letter = await createLetter(); const requestId = 'rollback'; await expect( appRouter .createCaller(buildContext(requestId, fixtureAuth)) .diplomacy.rollbackLetter({ letterId: letter.id }) ).resolves.toEqual({ ok: true }); await expectNoticeCopies({ text: `외교 서신(#${letter.id})이 회수되었습니다.`, types: ['diplomacy'], src: { generalId: fixtureGeneralId, generalName: '원민수뇌', nationId: fixtureNationId, nationName: '원민국', }, dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' }, }); expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(2); await expectInputEvent(requestId, 'rollbackLetter', fixtureUserId); }); it('stores actor-directed diplomacy copies for the first and second destroy phases', async () => { const letter = await createLetter({ state: 'ACTIVATED' }); const requestRequestId = 'destroy-request'; await expect( appRouter .createCaller(buildContext(requestRequestId, fixtureAuth)) .diplomacy.destroyLetter({ letterId: letter.id }) ).resolves.toEqual({ state: 'ACTIVATED' }); await expectNoticeCopies({ text: `외교 서신(#${letter.id})을 파기 요청합니다.`, types: ['diplomacy'], src: { generalId: fixtureGeneralId, generalName: '원민수뇌', nationId: fixtureNationId, nationName: '원민국', }, dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' }, }); await expectInputEvent(requestRequestId, 'destroyLetter', fixtureUserId); const completeRequestId = 'destroy-complete'; await expect( appRouter .createCaller(buildContext(completeRequestId, foreignAuth)) .diplomacy.destroyLetter({ letterId: letter.id }) ).resolves.toEqual({ state: 'CANCELLED' }); await expectNoticeCopies({ text: `외교 서신(#${letter.id})을 파기했습니다.`, types: ['diplomacy'], src: { generalId: foreignGeneralId, generalName: '상대수뇌', nationId: foreignNationId, nationName: '상대국', }, dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' }, }); expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(4); await expectInputEvent(completeRequestId, 'destroyLetter', foreignUserId); }); it('rolls letter and message writes back together while retaining the failed API input event', async () => { const failure = new Error('injected diplomacy message transaction rollback'); const requestId = 'send-rollback'; const rollbackDb = buildRollbackDatabase(failure); await expect( appRouter.createCaller(buildContext(requestId, fixtureAuth, rollbackDb)).diplomacy.sendLetter({ destNationId: foreignNationId, brief: 'rollback 외교문서', detail: 'rollback 외교문서 상세', }) ).rejects.toThrow(failure.message); await expect(db.diplomacyLetter.findFirst({ where: { textBrief: 'rollback 외교문서' } })).resolves.toBeNull(); await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0); await expect( db.readModelRevision.count({ where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, }) ).resolves.toBe(0); await expect( db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestPrefix}:${requestId}:diplomacy.sendLetter` }, }) ).resolves.toMatchObject({ target: 'API', eventType: 'diplomacy.sendLetter', actorUserId: fixtureUserId, status: 'FAILED', attempts: 1, error: failure.message, }); }); });