import { describe, expect, it } from 'vitest'; import type { DatabaseClient, GameApiContext, GameProfile, WorldStateRow, GeneralRow, GeneralTurnRow, NationTurnRow, } from '../src/context.js'; import type { RedisConnector } from '@sammo-ts/infra'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { appRouter } from '../src/router.js'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; const profile: GameProfile = { id: 'che', scenario: 'default', name: 'che:default', }; const buildGeneralRow = (overrides?: Partial): GeneralRow => { const base: GeneralRow = { id: 1, userId: 'user-1', name: '테스트', nationId: 0, cityId: 1, troopId: 0, npcState: 0, affinity: null, bornYear: 180, deadYear: 300, picture: null, imageServer: 0, leadership: 50, strength: 50, intel: 50, injury: 0, experience: 0, dedication: 0, officerLevel: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None', turnTime: new Date('2026-01-01T00:00:00Z'), recentWarTime: null, age: 20, startAge: 20, personalCode: 'None', specialCode: 'None', special2Code: 'None', lastTurn: {}, meta: {}, penalty: {}, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), }; return { ...base, ...(overrides ?? {}) }; }; const buildContext = (options?: { state?: WorldStateRow | null; transport?: InMemoryTurnDaemonTransport; battleSim?: InMemoryBattleSimTransport; general?: GeneralRow | null; generalTurns?: GeneralTurnRow[]; nationTurns?: NationTurnRow[]; generalTurnWrites?: unknown[]; nationTurnWrites?: unknown[]; auth?: GameSessionTokenPayload | null; worldStateReads?: { count: number }; }): GameApiContext => { const transport = options?.transport ?? new InMemoryTurnDaemonTransport(); const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport(); const generalTurns = options?.generalTurns ?? []; const nationTurns = options?.nationTurns ?? []; const db = { worldState: { findFirst: async () => { if (options?.worldStateReads) { options.worldStateReads.count += 1; } return options?.state ?? null; }, }, general: { findUnique: async ({ where }: { where: { id: number } }) => { if (!options?.general) { return null; } return options.general.id === where.id ? options.general : null; }, }, city: { findUnique: async () => null, }, nation: { findUnique: async () => null, }, generalTurn: { findMany: async ({ where }: { where: { generalId: number } }) => generalTurns.filter((row) => row.generalId === where.generalId), deleteMany: async () => ({}), createMany: async (args: unknown) => { options?.generalTurnWrites?.push(args); return {}; }, }, nationTurn: { findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) => nationTurns.filter( (row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel ), deleteMany: async () => ({}), createMany: async (args: unknown) => { options?.nationTurnWrites?.push(args); return {}; }, }, }; const accessTokenStore = new RedisAccessTokenStore( { get: async () => null, set: async () => null, }, profile.name ); const defaultAuth: GameSessionTokenPayload = { version: 1, profile: profile.name, issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), sessionId: 'session-1', user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles: ['admin'], }, sanctions: {}, }; const auth = options && 'auth' in options ? (options.auth ?? null) : defaultAuth; return { db: db as unknown as DatabaseClient, turnDaemon: transport, battleSim, profile, auth, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, redis: {} as unknown as RedisConnector['client'], accessTokenStore, flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; }; describe('appRouter', () => { it('rejects general creation before any game-state read when the signed identity gate denies it', async () => { const worldStateReads = { count: 0 }; const auth: GameSessionTokenPayload = { version: 1, profile: profile.name, issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), sessionId: 'session-local', user: { id: 'local-user', username: 'local-user', displayName: '로컬유저', roles: ['user'], }, sanctions: {}, identity: { kakaoVerified: false, canCreateGeneral: false, requiresKakaoVerification: true, graceEndsAt: new Date('2026-01-08T00:00:00Z').toISOString(), }, }; const caller = appRouter.createCaller(buildContext({ auth, worldStateReads })); await expect( caller.join.createGeneral({ name: '미인증', leadership: 55, strength: 55, intel: 55, character: 'Random', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', message: expect.stringContaining('카카오 인증'), }); expect(worldStateReads.count).toBe(0); }); it('queues turn daemon run commands', async () => { const transport = new InMemoryTurnDaemonTransport(); const caller = appRouter.createCaller(buildContext({ transport })); const response = await caller.turnDaemon.run({ reason: 'manual' }); expect(response.accepted).toBe(true); expect(transport.commands).toHaveLength(1); expect(transport.commands[0]?.command.type).toBe('run'); expect(transport.commands[0]?.requestId).toBe(response.requestId); }); it('returns world state snapshots', async () => { const state: WorldStateRow = { id: 1, scenarioCode: 'default', currentYear: 1, currentMonth: 2, tickSeconds: 600, config: { maxUserCnt: 500, hiddenSeed: 'config-secret' }, meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' }, updatedAt: new Date('2026-01-01T00:00:00Z'), }; const caller = appRouter.createCaller(buildContext({ state })); const response = await caller.world.getState(); expect(response?.scenarioCode).toBe('default'); expect(response?.currentYear).toBe(1); expect(response?.config).toEqual({ maxUserCnt: 500 }); expect(response?.meta).toEqual({ otherTextInfo: 'sample' }); expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z'); }); it('requires profile administration permission for turn daemon control', async () => { const auth: GameSessionTokenPayload = { version: 1, profile: profile.name, issuedAt: '2026-01-01T00:00:00.000Z', expiresAt: '2026-01-02T00:00:00.000Z', sessionId: 'session-user', user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles: ['user'], }, sanctions: {}, }; const caller = appRouter.createCaller(buildContext({ auth })); await expect(caller.turnDaemon.run({ reason: 'manual' })).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect(caller.turnDaemon.pause()).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect(caller.turnDaemon.resume()).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect(caller.turnDaemon.status()).rejects.toMatchObject({ code: 'FORBIDDEN', }); }); it('rejects unauthenticated turn daemon control', async () => { const caller = appRouter.createCaller(buildContext({ auth: null })); await expect(caller.turnDaemon.pause()).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); }); it('returns status from transport', async () => { const transport = new InMemoryTurnDaemonTransport({ state: 'paused', running: false, paused: true, queueDepth: 2, }); const caller = appRouter.createCaller(buildContext({ transport })); const response = await caller.turnDaemon.status(); expect(response?.state).toBe('paused'); expect(response?.queueDepth).toBe(2); }); it('returns reserved general turns', async () => { const general = buildGeneralRow({ id: 11 }); const generalTurns: GeneralTurnRow[] = [ { id: 1, generalId: 11, turnIdx: 0, actionCode: 'che_화계', arg: {}, createdAt: new Date('2026-01-01T00:00:00Z'), }, ]; const caller = appRouter.createCaller(buildContext({ general, generalTurns })); const response = await caller.turns.reserved.getGeneral({ generalId: 11 }); expect(response[0]?.action).toBe('che_화계'); expect(response[0]?.index).toBe(0); }); it('returns reserved nation turns', async () => { const general = buildGeneralRow({ id: 12, nationId: 3, officerLevel: 5 }); const nationTurns: NationTurnRow[] = [ { id: 1, nationId: 3, officerLevel: 5, turnIdx: 0, actionCode: 'che_포상', arg: {}, createdAt: new Date('2026-01-01T00:00:00Z'), }, ]; const caller = appRouter.createCaller(buildContext({ general, nationTurns })); const response = await caller.turns.reserved.getNation({ generalId: 12 }); expect(response[0]?.action).toBe('che_포상'); expect(response[0]?.index).toBe(0); }); it('validates and persists general command arguments from the authenticated owner', async () => { const general = buildGeneralRow({ id: 13 }); const writes: unknown[] = []; const caller = appRouter.createCaller(buildContext({ general, generalTurnWrites: writes })); const response = await caller.turns.reserved.setGeneral({ generalId: 13, turnIndex: 0, action: 'che_화계', args: { destCityId: 7 }, }); expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } }); expect(writes).toHaveLength(1); const written = writes[0] as { data: unknown[] }; expect(written.data).toHaveLength(30); expect(written.data[0]).toMatchObject({ generalId: 13, turnIdx: 0, actionCode: 'che_화계', arg: { destCityId: 7 }, }); }); it('rejects malformed and cross-scope arguments without writing turns', async () => { const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 }); const generalWrites: unknown[] = []; const nationWrites: unknown[] = []; const caller = appRouter.createCaller( buildContext({ general, generalTurnWrites: generalWrites, nationTurnWrites: nationWrites }) ); await expect( caller.turns.reserved.setGeneral({ generalId: 14, turnIndex: 0, action: 'che_화계', args: { destCityId: '7' }, }) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); await expect( caller.turns.reserved.setGeneral({ generalId: 14, turnIndex: 0, action: 'che_포상', args: { isGold: true, amount: 1, destGeneralId: 7 }, }) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); expect(generalWrites).toHaveLength(0); expect(nationWrites).toHaveLength(0); }); it('rejects another user general across actor-owned routers', async () => { const general = buildGeneralRow({ id: 15, userId: 'user-2' }); const caller = appRouter.createCaller(buildContext({ general })); await expect(caller.turns.getCommandTable({ generalId: general.id })).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect(caller.turns.reserved.getGeneral({ generalId: general.id })).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect(caller.turns.reserved.getNation({ generalId: general.id })).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.turns.reserved.setGeneral({ generalId: general.id, turnIndex: 0, action: '휴식', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.turns.reserved.shiftGeneral({ generalId: general.id, amount: 1, }) ).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.turns.reserved.setNation({ generalId: general.id, turnIndex: 0, action: '휴식', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.turns.reserved.shiftNation({ generalId: general.id, amount: 1, }) ).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect(caller.messages.getRecent({ generalId: general.id })).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.messages.getOld({ generalId: general.id, to: 1, type: 'private', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.messages.send({ generalId: general.id, mailbox: 0, text: 'test', }) ).rejects.toMatchObject({ code: 'FORBIDDEN', }); await expect( caller.world.getMap({ generalId: general.id, showMe: true, }) ).rejects.toMatchObject({ code: 'FORBIDDEN', }); }); it('rejects unauthenticated general-scoped map views', async () => { const general = buildGeneralRow({ id: 16 }); const caller = appRouter.createCaller(buildContext({ general, auth: null })); await expect(caller.world.getMap({ generalId: general.id })).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); }); });