import { describe, expect, it, vi } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { appRouter } from '../src/router.js'; import type { GameApiContext, GeneralRow } from '../src/context.js'; const general = { id: 7, userId: 'user-7', name: '보낸이', nationId: 1, officerLevel: 5, npcState: 0, meta: {}, penalty: {}, } as GeneralRow; const auth: GameSessionTokenPayload = { version: 1, profile: 'che:default', issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2027-01-01T00:00:00.000Z', sessionId: 'session-7', user: { id: 'user-7', username: 'tester', displayName: 'Tester', roles: ['user'], }, sanctions: {}, }; const buildContext = (overrides: Record = {}, contextOverrides: Record = {}) => { const executeRaw = vi.fn(async () => 1); const updateMany = vi.fn(async () => ({ count: 1 })); const db = { general: { findUnique: vi.fn(async () => general), findMany: vi.fn(async () => []), }, nation: { findMany: vi.fn(async () => []), findUnique: vi.fn(async () => null), }, messageReadState: { findUnique: vi.fn(async () => ({ generalId: general.id, latestPrivateMessage: 11, latestDiplomacyMessage: 13, updatedAt: new Date(), })), }, message: { updateMany }, $queryRaw: vi.fn(async () => []), $executeRaw: executeRaw, ...overrides, }; const redis = { set: vi.fn(async () => 'OK'), publish: vi.fn(async () => 1), }; const context = { db, auth, profile: { id: 'che', scenario: 'default', name: 'che:default' }, redis, turnDaemon: {}, battleSim: {}, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, accessTokenStore: {}, flushStore: {}, gameTokenSecret: 'test-secret', ...contextOverrides, } as unknown as GameApiContext; return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis }; }; describe('messages router missing-flow compatibility', () => { it('returns persisted latest-read positions with recent messages', async () => { const { caller } = buildContext(); const result = await caller.messages.getRecent({ generalId: general.id }); expect(result.latestRead).toEqual({ private: 11, diplomacy: 13 }); expect(result.canRespondDiplomacy).toBe(false); }); it('marks the current ruler as able to answer diplomacy prompts', async () => { const ruler = { ...general, officerLevel: 12 }; const { caller } = buildContext({ general: { findUnique: vi.fn(async () => ruler), findMany: vi.fn(async () => []), }, nation: { findMany: vi.fn(async () => []), findUnique: vi.fn(async () => ({ meta: {} })), }, }); const result = await caller.messages.getRecent({ generalId: ruler.id }); expect(result.canRespondDiplomacy).toBe(true); }); it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => { const ambassador = { ...general, officerLevel: 1, meta: { permission: 'ambassador' }, } as GeneralRow; const { caller } = buildContext({ general: { findUnique: vi.fn(async () => ambassador), findMany: vi.fn(async () => []), }, nation: { findMany: vi.fn(async () => []), findUnique: vi.fn(async () => ({ meta: {} })), }, }); const result = await caller.messages.getRecent({ generalId: ambassador.id }); expect(result.permission).toBe(4); expect(result.canRespondDiplomacy).toBe(false); }); it('redacts recent and old diplomacy content below secret permission 3', async () => { const diplomacyRow = { id: 19, mailbox: 9001, type: 'diplomacy', src: 9002, dest: 9001, time: new Date(), valid_until: new Date('9999-12-31T00:00:00Z'), message: { src: { generalId: 8, generalName: '외교관', nationId: 2, nationName: '촉', color: '#000000', icon: '', }, dest: { generalId: 0, generalName: '', nationId: 1, nationName: '위', color: '#ffffff', icon: '', }, text: '보이면 안 되는 외교 본문', option: { action: 'noAggression' }, }, }; const queryRaw = vi.fn(async () => [diplomacyRow]); const { caller } = buildContext({ $queryRaw: queryRaw, nation: { findMany: vi.fn(async () => []), findUnique: vi.fn(async () => ({ meta: {} })), }, }); const recent = await caller.messages.getRecent({ generalId: general.id }); const old = await caller.messages.getOld({ generalId: general.id, type: 'diplomacy', to: 20, }); expect(recent.permission).toBe(2); expect(recent.diplomacy[0]).toMatchObject({ text: '(외교 메시지입니다)', option: { action: 'noAggression', invalid: true }, }); expect(old.diplomacy[0]).toMatchObject({ text: '(외교 메시지입니다)', option: { action: 'noAggression', invalid: true }, }); }); it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { const queryRaw = vi.fn(async () => [{ id: 51 }]); const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({ id: where.id, name: where.id === 1 ? '위' : '촉', color: '#112233', meta: {}, })); const { caller } = buildContext({ $queryRaw: queryRaw, nation: { findMany: vi.fn(async () => []), findUnique: findNation, }, }); const result = await caller.messages.send({ generalId: general.id, mailbox: 9002, text: '국가 메시지', }); expect(result.msgType).toBe('national'); expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); }); it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => { const ambassador = { ...general, officerLevel: 1, meta: { permission: 'ambassador' }, } as GeneralRow; const queryRaw = vi.fn(async () => [{ id: 52 }]); const { caller } = buildContext({ $queryRaw: queryRaw, general: { findUnique: vi.fn(async () => ambassador), findMany: vi.fn(async () => []), }, nation: { findMany: vi.fn(async () => []), findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({ id: where.id, name: where.id === 1 ? '위' : '촉', color: '#112233', meta: {}, })), }, }); const result = await caller.messages.send({ generalId: ambassador.id, mailbox: 9002, text: '외교 메시지', }); expect(result.msgType).toBe('diplomacy'); expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); }); it('blocks private messages between foreign ambassadors', async () => { const ambassador = { ...general, officerLevel: 1, meta: { permission: 'ambassador' }, } as GeneralRow; const foreignAmbassador = { ...ambassador, id: 8, userId: 'user-8', name: '상대 외교관', nationId: 2, } as GeneralRow; const { caller } = buildContext({ general: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => where.id === ambassador.id ? ambassador : foreignAmbassador ), findMany: vi.fn(async () => []), }, nation: { findMany: vi.fn(async () => []), findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({ id: where.id, name: where.id === 1 ? '위' : '촉', color: '#112233', meta: {}, })), }, }); await expect( caller.messages.send({ generalId: ambassador.id, mailbox: foreignAmbassador.id, text: '개인 메시지', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', message: '외교권자끼리는 메시지를 보낼 수 없습니다.', }); }); it.each([ ['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'], ['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'], ])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => { const penalized = { ...general, penalty } as GeneralRow; const { caller } = buildContext({ general: { findUnique: vi.fn(async () => penalized), findMany: vi.fn(async () => []), }, nation: { findMany: vi.fn(async () => []), findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })), }, }); await expect( caller.messages.send({ generalId: penalized.id, mailbox, text: '차단 메시지', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', message }); }); it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => { const redis = { set: vi.fn(async () => null), publish: vi.fn(async () => 1), }; const { caller } = buildContext( { nation: { findMany: vi.fn(async () => []), findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })), }, }, { redis } ); await expect( caller.messages.send({ generalId: general.id, mailbox: 8, text: '너무 빠른 메시지', }) ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS', message: '개인메세지는 2초당 1건만 보낼 수 있습니다!', }); }); it('blocks sends for a muted authenticated user independently of general permission', async () => { const mutedAuth = { ...auth, sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' }, }; const { caller } = buildContext({}, { auth: mutedAuth }); await expect( caller.messages.send({ generalId: general.id, mailbox: 9999, text: '사용자 mute', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', message: '메시지 전송이 제한된 계정입니다.', }); }); it.each(['message', 'messages'])('blocks sends for the profile feature alias %s', async (feature) => { const restrictedAuth = { ...auth, sanctions: { serverRestrictions: { 'che:default': { blockedFeatures: [feature], }, }, }, }; const { caller } = buildContext({}, { auth: restrictedAuth }); await expect( caller.messages.send({ generalId: general.id, mailbox: 9999, text: 'profile restriction', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', message: '메시지 전송이 제한된 계정입니다.', }); }); it('rejects every remaining general-scoped message mutation for another user general', async () => { const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow; const { caller } = buildContext({ general: { findUnique: vi.fn(async () => foreignGeneral), findMany: vi.fn(async () => []), }, }); await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.messages.readLatest({ generalId: foreignGeneral.id, type: 'private', messageId: 1, }) ).rejects.toMatchObject({ code: 'FORBIDDEN' }); await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.messages.respond({ generalId: foreignGeneral.id, messageId: 1, response: true, }) ).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); it('persists latest-read updates through the monotonic upsert', async () => { const { caller, executeRaw } = buildContext(); await caller.messages.readLatest({ generalId: general.id, type: 'private', messageId: 17, }); expect(executeRaw).toHaveBeenCalledOnce(); }); it('invalidates a recent owned message and its receiver copy', async () => { const queryRaw = vi.fn(async () => [ { id: 21, mailbox: general.id, type: 'private', src: general.id, dest: 8, time: new Date(), valid_until: new Date('9999-12-31T00:00:00Z'), message: { src: { generalId: general.id, generalName: general.name, nationId: 1, nationName: '위', color: '#fff', icon: '', }, dest: { generalId: 8, generalName: '받는이', nationId: 2, nationName: '촉', color: '#000', icon: '', }, text: '삭제할 메시지', option: { receiverMessageID: 22 }, }, }, ]); const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }); 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) }, }); }); it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => { const queryRaw = vi.fn(async () => [ { id: 25, mailbox: 9001, type: 'diplomacy', src: 9001, dest: 9002, time: new Date(), valid_until: new Date('9999-12-31T00:00:00Z'), message: { src: { generalId: general.id, generalName: general.name, nationId: 1, nationName: '위', color: '#fff', icon: '', }, dest: { generalId: 0, generalName: '', nationId: 2, nationName: '촉', color: '#000', icon: '', }, text: '일반 외교 메시지', option: { receiverMessageID: 26 }, }, }, ]); const { caller, 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) }, }); }); it('rejects deleting another general message', async () => { const queryRaw = vi.fn(async () => [ { id: 23, mailbox: general.id, type: 'private', src: 99, dest: general.id, time: new Date(), valid_until: new Date('9999-12-31T00:00:00Z'), message: { src: { generalId: 99, generalName: '타인', nationId: 1, nationName: '위', color: '#fff', icon: '' }, dest: { generalId: general.id, generalName: general.name, nationId: 1, nationName: '위', color: '#fff', icon: '', }, text: '타인 메시지', option: {}, }, }, ]); const { caller } = buildContext({ $queryRaw: queryRaw }); await expect(caller.messages.delete({ generalId: general.id, messageId: 23 })).rejects.toMatchObject({ code: 'FORBIDDEN', }); }); const buildDiplomaticContext = (options?: { action?: 'noAggression' | 'cancelNA' | 'stopWar'; actorNationId?: number; mailboxNationId?: number; proposerNationId?: number; proposerCurrentNationId?: number; diplomacyState?: number; response?: boolean; cities?: Array<{ id: number; nationId: number; frontState: number }>; }) => { const action = options?.action ?? 'noAggression'; const actorNationId = options?.actorNationId ?? 1; const mailboxNationId = options?.mailboxNationId ?? actorNationId; const proposerNationId = options?.proposerNationId ?? 2; const actor = { ...general, cityId: 10, nationId: actorNationId, officerLevel: 12, meta: {}, penalty: {}, } as GeneralRow; const proposer = { ...general, id: 8, userId: 'user-8', name: '제안자', cityId: 20, nationId: options?.proposerCurrentNationId ?? proposerNationId, officerLevel: 12, meta: {}, penalty: {}, } as GeneralRow; const messageRow = { id: 31, mailbox: 9000 + mailboxNationId, type: 'diplomacy', src: 9000 + proposerNationId, dest: 9000 + actorNationId, time: new Date(), valid_until: new Date('9999-12-31T00:00:00Z'), message: { src: { generalId: proposer.id, generalName: proposer.name, nationId: proposerNationId, nationName: '촉', color: '#000', icon: '', }, dest: { generalId: actor.id, generalName: actor.name, nationId: actorNationId, nationName: '위', color: '#fff', icon: '', }, text: '외교 제안', option: { action, ...(action === 'noAggression' ? { year: 201, month: 2 } : {}), }, }, }; let rawCall = 0; let insertedId = 100; const queryRaw = vi.fn(async () => { rawCall += 1; if (rawCall === 1) { return [messageRow]; } if (rawCall >= 4) { insertedId += 1; return [{ id: insertedId }]; } return []; }); const diplomacyState = options?.diplomacyState ?? (action === 'cancelNA' ? 7 : action === 'stopWar' ? 0 : 2); const diplomacyRows = [ { id: 1, srcNationId: actorNationId, destNationId: proposerNationId, stateCode: diplomacyState, term: 6, isDead: false, isShowing: true, meta: {}, createdAt: new Date(), }, { id: 2, srcNationId: proposerNationId, destNationId: actorNationId, stateCode: diplomacyState, term: 6, isDead: false, isShowing: true, meta: {}, createdAt: new Date(), }, ]; const diplomacyUpdate = vi.fn( async ({ where, data, }: { where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } }; data: { stateCode?: number; term?: number }; }) => { const row = diplomacyRows.find( (entry) => entry.srcNationId === where.srcNationId_destNationId.srcNationId && entry.destNationId === where.srcNationId_destNationId.destNationId ); if (row) { if (data.stateCode !== undefined) row.stateCode = data.stateCode; if (data.term !== undefined) row.term = data.term; } return {}; } ); const nationUpdate = vi.fn(async () => ({})); const logCreateMany = vi.fn(async () => ({ count: 1 })); const messageUpdateMany = vi.fn(async () => ({ count: 1 })); const cityUpdate = vi.fn(async () => ({})); const { caller } = buildContext({ general: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => where.id === actor.id ? actor : where.id === proposer.id ? proposer : null ), findMany: vi.fn(async () => []), }, nation: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => where.id === actorNationId ? { id: actorNationId, name: '위', color: '#fff', capitalCityId: 10, chiefGeneralId: actor.id, gold: 1000, rice: 1000, tech: 0, level: 1, typeCode: 'test', meta: {}, } : where.id === proposerNationId ? { id: proposerNationId, name: '촉', color: '#000', capitalCityId: 20, chiefGeneralId: proposer.id, gold: 1000, rice: 1000, tech: 0, level: 1, typeCode: 'test', meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } }, } : null ), findMany: vi.fn(async () => []), update: nationUpdate, }, city: { findUnique: vi.fn(async () => ({ id: 10, nationId: actorNationId, supplyState: 1, })), findMany: vi.fn(async () => options?.cities ?? []), update: cityUpdate, }, diplomacy: { findUnique: vi.fn( async ({ where, }: { where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } }; }) => diplomacyRows.find( (row) => row.srcNationId === where.srcNationId_destNationId.srcNationId && row.destNationId === where.srcNationId_destNationId.destNationId ) ?? null ), findMany: vi.fn(async () => diplomacyRows), update: diplomacyUpdate, }, worldState: { findFirst: vi.fn(async () => ({ currentYear: 200, currentMonth: 3, config: { environment: { mapName: 'che' } }, })), }, logEntry: { createMany: logCreateMany }, message: { updateMany: messageUpdateMany }, $queryRaw: queryRaw, }); return { caller, actor, proposer, queryRaw, diplomacyUpdate, nationUpdate, logCreateMany, messageUpdateMany, cityUpdate, }; }; it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => { const setup = buildDiplomaticContext(); const result = await setup.caller.messages.respond({ generalId: setup.actor.id, messageId: 31, response: true, }); expect(result).toEqual({ result: true, reason: 'success' }); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); expect(setup.nationUpdate).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 2 }, data: { meta: { recv_assist: { n1: [1, 50] }, resp_assist: { n1: [1, 50] }, }, }, }) ); expect(setup.messageUpdateMany).toHaveBeenCalledWith({ where: { id: { in: [31] } }, data: { validUntil: expect.any(Date) }, }); expect(setup.queryRaw).toHaveBeenCalledTimes(8); }); it('declines a diplomatic prompt without changing diplomacy', async () => { const setup = buildDiplomaticContext({ action: 'stopWar' }); const result = await setup.caller.messages.respond({ generalId: setup.actor.id, messageId: 31, response: false, }); expect(result).toEqual({ result: true, reason: 'success' }); expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); expect(setup.messageUpdateMany).toHaveBeenCalledOnce(); expect(setup.logCreateMany).toHaveBeenCalledOnce(); }); it.each([ ['cancelNA' as const, 7], ['stopWar' as const, 0], ])('accepts the %s prompt through the same runtime path', async (action, diplomacyState) => { const setup = buildDiplomaticContext({ action, diplomacyState, ...(action === 'stopWar' ? { cities: [ { id: 1, nationId: 1, frontState: 3 }, { id: 9, nationId: 2, frontState: 3 }, ], } : {}), }); const result = await setup.caller.messages.respond({ generalId: setup.actor.id, messageId: 31, response: true, }); expect(result).toEqual({ result: true, reason: 'success' }); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); if (action === 'stopWar') { expect(setup.cityUpdate).toHaveBeenCalledTimes(2); expect(setup.cityUpdate).toHaveBeenCalledWith({ where: { id: 1 }, data: { frontState: 0 }, }); expect(setup.cityUpdate).toHaveBeenCalledWith({ where: { id: 9 }, data: { frontState: 0 }, }); } }); it('rejects a prompt when the proposer has left the proposing nation', async () => { const setup = buildDiplomaticContext({ proposerCurrentNationId: 3 }); const result = await setup.caller.messages.respond({ generalId: setup.actor.id, messageId: 31, response: true, }); expect(result).toEqual({ result: false, reason: '제의 장수가 국가 소속이 아닙니다' }); expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); expect(setup.messageUpdateMany).not.toHaveBeenCalled(); expect(setup.logCreateMany).toHaveBeenCalledOnce(); }); it('does not let another nation process the diplomatic inbox row', async () => { const setup = buildDiplomaticContext({ mailboxNationId: 2 }); const result = await setup.caller.messages.respond({ generalId: setup.actor.id, messageId: 31, response: true, }); expect(result).toEqual({ result: false, reason: '송신자가 외교서신을 처리할 수 없습니다.', }); expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); expect(setup.messageUpdateMany).not.toHaveBeenCalled(); }); });