import { describe, expect, it } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; import { appRouter } from '../src/router.js'; const profile: GameProfile = { id: 'che', scenario: 'default', name: 'che:default', }; const buildContext = (auth: GameSessionTokenPayload | null = null): GameApiContext => { const generalRows = [ { id: 10, name: '관우', picture: '10.jpg', imageServer: 0, npcState: 1, age: 42, officerLevel: 5, nationId: 1, leadership: 90, strength: 95, intel: 75, experience: 800, dedication: 700, personalCode: 'None', specialCode: 'None', special2Code: 'None', meta: { owner_name: '악령 관우', explevel: 4, killturn: 7 }, }, { id: 20, name: '조운', picture: '20.jpg', imageServer: 1, npcState: 2, age: 35, officerLevel: 0, nationId: 0, leadership: 90, strength: 95, intel: 75, experience: 900, dedication: 600, personalCode: 'None', specialCode: 'None', special2Code: 'None', meta: { owner_name: '노출 금지', explevel: 5 }, }, { id: 30, name: '유비', picture: '30.jpg', imageServer: 0, npcState: 0, age: 44, officerLevel: 12, nationId: 1, leadership: 90, strength: 95, intel: 75, experience: 16_000, dedication: 10_000, personalCode: 'None', specialCode: 'None', special2Code: 'None', meta: { owner_name: '노출 금지', explevel: 9 }, }, ]; const db = { general: { findMany: async (args: { where?: { OR: [{ npcState: number }, { npcState: number; id: { in: number[] } }] }; }) => { if (args.where) { expect(args.where).toEqual({ OR: [{ npcState: 1 }, { npcState: 0, id: { in: [30] } }], }); return generalRows.filter((general) => general.npcState === 1 || general.id === 30); } return generalRows; }, }, selectPoolEntry: { findMany: async () => [{ generalId: 30 }], }, nation: { findMany: async () => [{ id: 1, name: '촉', level: 5 }], }, npcSelectionToken: { findMany: async (args: { where: { validUntil: { gte: Date } } }) => { expect(args.where.validUntil.gte).toBeInstanceOf(Date); return [ { pickResult: { 10: { keepCount: 2 }, invalid: { keepCount: 'bad' }, }, }, { pickResult: { 20: { keepCount: -1 }, }, }, ]; }, }, worldState: { findFirst: async () => ({ config: { const: { maxLevel: 255, maxDedLevel: 30 } }, }), }, }; const redis = { get: async () => null, set: async () => null, } as unknown as RedisConnector['client']; return { db: db as unknown as DatabaseClient, turnDaemon: new InMemoryTurnDaemonTransport(), battleSim: new InMemoryBattleSimTransport(), profile, auth, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, redis, accessTokenStore: new RedisAccessTokenStore(redis, profile.name), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; }; describe('public.getNpcList', () => { it('returns only the legacy-compatible public DTO without user identifiers', async () => { const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 1 }); expect(result.generals).toEqual([ { id: 10, name: '관우', picture: '10.jpg', imageServer: 0, npcState: 1, ownerName: '악령 관우', age: 42, level: 4, officerLevel: 5, killturn: 7, nationId: 1, nationName: '촉', nationLevel: 5, personality: null, specialDomestic: null, specialWar: null, statTotal: 260, leadership: 90, strength: 95, intelligence: 75, experience: 800, experienceText: '무명', dedication: 700, dedicationText: '28품관', }, expect.objectContaining({ id: 30, name: '유비', npcState: 0, ownerName: '' }), ]); expect(result.tokenKeepCounts).toEqual({}); expect(JSON.stringify(result)).not.toContain('노출 금지'); expect(JSON.stringify(result)).not.toContain('userId'); }); it('returns the full id-ordered list and every active reservation only to an authenticated caller', async () => { const auth: GameSessionTokenPayload = { version: 1, profile: 'che:default', issuedAt: '2026-07-31T00:00:00.000Z', expiresAt: '2026-08-31T00:00:00.000Z', sessionId: 'npc-list-owner', user: { id: 'owner-user', username: 'owner-user', displayName: '소유자', roles: ['user'], legacyMemberNo: 101, }, sanctions: {}, }; const result = await appRouter .createCaller(buildContext(auth)) .public.getNpcList({ sort: 1, includeAllWithToken: true }); expect(result.tokenKeepCounts).toEqual({ 10: 2, 20: 0 }); expect(result.generals.map((general) => general.id)).toEqual([10, 20, 30]); expect(result.generals[2]).toMatchObject({ npcState: 0, level: 40, experienceText: '지역적', dedicationText: '21품관', }); }); it('rejects the token-aware full list without an authenticated session', async () => { await expect( appRouter.createCaller(buildContext()).public.getNpcList({ sort: 1, includeAllWithToken: true }) ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); it('keeps pool rows before possessed NPCs when the selected value is tied', async () => { const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 3 }); expect(result.generals.map((general) => general.id)).toEqual([30, 10]); }); it('falls an invalid legacy sort value back to name order', async () => { const caller = appRouter.createCaller(buildContext()); const result = await caller.public.getNpcList({ sort: 99 } as unknown as { sort: 1 }); expect(result.sort).toBe(1); expect(result.generals.map((general) => general.name)).toEqual(['관우', '유비']); }); });