Files
core2026/app/game-api/test/archiveRouter.test.ts
T
Hide_D d6a25c63b0 feat: 이전 서버 기록 조회 화면을 통합
지난 플레이를 현재 장수 카드와 전투·숙련도·기록 컴포넌트로 표시하고, 중앙 archive의 소유자 기반 조회를 연결한다. 명예의 전당과 왕조에 현재/이전 서버 source 분리를 추가한다.
2026-08-17 15:55:43 +00:00

393 lines
16 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { DatabaseClient, GameApiContext } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-07-27T00:00:00.000Z',
expiresAt: '2026-07-28T00:00:00.000Z',
sessionId: 'archive-session',
user: {
id: 'user-1',
username: 'user-1',
displayName: '테스터',
roles: ['user'],
},
sanctions: {},
};
const context = (session: GameSessionTokenPayload | null, includeLegacy = false): GameApiContext => {
const db = {
$queryRaw: async (query: { strings?: readonly string[] }) => {
if (!includeLegacy) return [];
const sql = query.strings?.join(' ') ?? '';
if (sql.includes('legacy_archive"."general')) {
return [
{
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
generalNo: 21,
legacyId: 21,
owner: 'user-1',
name: '이전서버장수',
lastYearMonth: 21012,
turnTime: new Date('2020-01-02T00:00:00.000Z'),
schemaVersion: 1,
sourceFormat: 'legacy-flat-v0',
data: {
schemaVersion: 1,
identity: {
name: '이전서버장수',
picture: null,
imageServer: 0,
npcState: 0,
nationId: 2,
cityId: 1,
officerLevel: 7,
officerCity: 1,
},
stats: {
leadership: 91,
strength: 81,
intelligence: 71,
leadershipExperience: 3,
strengthExperience: 4,
intelligenceExperience: 5,
},
progression: {
experience: 900,
experienceLevel: 3,
dedication: 800,
dedicationLevel: 2,
age: 30,
startAge: 20,
bornYear: 180,
deadYear: 250,
},
traits: { personality: null, specialDomestic: null, specialWar: null },
resources: {
gold: 100,
rice: 200,
crew: 300,
crewType: '1',
train: 90,
morale: 80,
injury: 0,
},
items: { horse: null, weapon: null, book: null, item: null },
mastery: { infantry: 1000, archery: 2000, cavalry: 3000, special: 4000, siege: 5000 },
battle: {
battles: 10,
wins: 6,
losses: 4,
fireSuccesses: 2,
kills: 6,
deaths: 4,
killedCrew: 1000,
lostCrew: 500,
winRate: 60,
killRate: 200,
recentWar: null,
tactics: {
total: { wins: null, draws: null, losses: null },
leadership: { wins: null, draws: null, losses: null },
intelligence: { wins: null, draws: null, losses: null },
},
},
history: ['이전 서버 열전'],
availability: {
mastery: true,
battleAggregates: true,
tactics: false,
history: true,
battleDetailLogs: false,
battleResultLogs: false,
},
},
},
];
}
if (sql.includes('legacy_archive"."game_history')) {
return [
{
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
legacyId: 1,
openedAt: new Date('2019-09-21T00:00:00.000Z'),
completedAt: null,
legacyDate: new Date('2019-09-21T00:00:00.000Z'),
winnerNation: 2,
map: 'legacy',
season: 1,
scenario: 7,
scenarioName: '이전 시나리오',
rawEnv: {},
},
];
}
if (sql.includes('legacy_archive"."nation')) {
return [
{
sourceProfile: 'hwe',
legacyId: 1,
serverId: 'hwe_archive_1',
nation: 2,
data: { name: '이전국', color: '#0000ff', level: 7 },
archivedAt: new Date('2020-01-02T00:00:00.000Z'),
},
];
}
if (sql.includes('legacy_archive"."emperor')) {
return [{ id: 99n, sourceProfile: 'hwe', legacyId: 1, serverId: 'hwe_archive_1', data: {} }];
}
return [];
},
oldGeneral: {
findMany: async ({ where }: { where: { owner: string } }) =>
where.owner === 'user-1'
? [
{
id: 1,
serverId: 'che_legacy_1',
generalNo: 10,
owner: 'user-1',
name: '과거장수',
lastYearMonth: 22012,
turnTime: new Date('2025-01-01T00:00:00.000Z'),
data: {
nation: 3,
leader: 80,
power: 70,
intel: 60,
officer_level: 8,
personal: 3,
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
},
},
{
id: 2,
serverId: 'che_legacy_1',
generalNo: 11,
owner: 'user-1',
name: '현재형장수',
lastYearMonth: 22012,
turnTime: new Date('2025-01-01T00:00:00.000Z'),
data: {
nationId: 3,
stats: { leadership: 81, strength: 71, intelligence: 61 },
officerLevel: 7,
role: {
personality: 'che_의리',
specialDomestic: 'che_상재',
specialWar: 'che_신산',
},
history: ['<C>●</>현재형 기록'],
},
},
]
: [],
findFirst: async ({ where }: { where: { owner: string; serverId: string; generalNo: number } }) =>
where.owner === 'user-1' && where.serverId === 'che_legacy_1' && where.generalNo === 10
? {
id: 1,
serverId: 'che_legacy_1',
generalNo: 10,
owner: 'user-1',
name: '과거장수',
lastYearMonth: 22012,
turnTime: new Date('2025-01-01T00:00:00.000Z'),
data: {
nation: 3,
leader: 80,
power: 70,
intel: 60,
officer_level: 8,
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
},
}
: null,
},
gameHistory: {
findMany: async () => [
{
id: 1,
serverId: 'che_legacy_1',
date: new Date('2025-01-02T00:00:00.000Z'),
winnerNation: 3,
map: 'scenario',
season: 1,
scenario: 100,
scenarioName: '테스트',
env: {},
},
],
},
oldNation: {
findMany: async () => [
{
id: 1,
serverId: 'che_legacy_1',
nation: 3,
sourceId: 5,
data: { name: '촉', color: '#ff0000' },
date: new Date('2025-01-02T00:00:00.000Z'),
},
],
},
emperor: {
findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }],
findFirst: async () => ({ id: 7, serverId: 'che_legacy_1' }),
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
redis,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile: { id: 'che', scenario: 'default', name: 'che:default' },
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
auth: session,
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('archive.myPastPlays', () => {
it('requires authentication and returns only the authenticated owner archive', async () => {
await expect(appRouter.createCaller(context(null)).archive.myPastPlays()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
expect(result.seasons).toEqual([
expect.objectContaining({
source: 'current',
sourceProfile: 'che',
serverId: 'che_legacy_1',
scenarioName: '테스트',
dynastyId: 7,
generals: [
expect.objectContaining({
name: '과거장수',
nationName: '촉',
leadership: 80,
strength: 70,
officerLevel: 8,
officerLevelText: '제2장군',
personal: '-',
historyCount: 2,
}),
expect.objectContaining({
name: '현재형장수',
nationName: '촉',
leadership: 81,
strength: 71,
intel: 61,
officerLevel: 7,
officerLevelText: '제2모사',
personal: '의리',
special: '상재',
special2: '신산',
historyCount: 1,
}),
],
}),
]);
});
it('loads archived history only for a general owned by the authenticated session', async () => {
const input = { serverId: 'che_legacy_1', generalNo: 10 };
await expect(appRouter.createCaller(context(null)).archive.myPastPlayDetail(input)).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input);
expect(result).toMatchObject({
source: 'current',
sourceProfile: 'che',
serverId: 'che_legacy_1',
generalNo: 10,
dynastyPath: '/dynasty/7',
nation: { id: 3, name: '촉', color: '#ff0000' },
general: expect.objectContaining({ id: 10, name: '과거장수' }),
battle: expect.objectContaining({ available: false }),
logs: {
generalHistory: {
available: true,
entries: [
{ id: 1, text: '<C>●</>첫 기록' },
{ id: 2, text: '<Y>●</>둘째 기록' },
],
},
battleDetail: { available: false, entries: [] },
battleResult: { available: false, entries: [] },
generalAction: { available: false, entries: [] },
},
});
const otherUser = {
...auth,
sessionId: 'other-session',
user: { ...auth.user, id: 'user-2', username: 'user-2' },
};
await expect(appRouter.createCaller(context(otherUser)).archive.myPastPlayDetail(input)).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
const caller = appRouter.createCaller(context(auth, true));
const list = await caller.archive.myPastPlays();
expect(list.seasons).toContainEqual(
expect.objectContaining({
source: 'legacy',
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
openedAt: '2019-09-21T00:00:00.000Z',
dynastyId: 99,
generals: [expect.objectContaining({ name: '이전서버장수', nationName: '이전국' })],
})
);
const detail = await caller.archive.myPastPlayDetail({
source: 'legacy',
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
generalNo: 21,
});
expect(detail).toMatchObject({
source: 'legacy',
sourceProfile: 'hwe',
dynastyPath: '/dynasty/99?source=legacy',
nation: { id: 2, name: '이전국', color: '#0000ff' },
general: expect.objectContaining({
id: 21,
name: '이전서버장수',
stats: { leadership: 91, strength: 81, intelligence: 71 },
progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }),
}),
battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }),
logs: expect.objectContaining({
generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] },
battleDetail: { available: false, entries: [] },
}),
});
expect(JSON.stringify(detail)).not.toContain('raw_data');
});
});