169 lines
6.5 KiB
TypeScript
169 lines
6.5 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, 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 archiveServerId = 'hwe_260725_archive';
|
|
const archiveRows = [
|
|
{
|
|
id: 1,
|
|
profileName: archiveServerId,
|
|
sourceId: 101,
|
|
year: 200,
|
|
month: 1,
|
|
map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] },
|
|
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }],
|
|
globalHistory: ['<C>●</>1월: 기록 없음'],
|
|
globalAction: ['<C>●</>1월: 기록 없음'],
|
|
hash: 'archive-1',
|
|
createdAt: new Date('2026-07-25T00:00:00.000Z'),
|
|
},
|
|
{
|
|
id: 2,
|
|
profileName: archiveServerId,
|
|
sourceId: 102,
|
|
year: 200,
|
|
month: 2,
|
|
map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] },
|
|
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }],
|
|
globalHistory: ['<C>●</>2월: 기록 없음'],
|
|
globalAction: ['<C>●</>2월: 기록 없음'],
|
|
hash: 'archive-2',
|
|
createdAt: new Date('2026-07-25T01:00:00.000Z'),
|
|
},
|
|
];
|
|
|
|
const authFor = (userId: string): GameSessionTokenPayload => ({
|
|
version: 1,
|
|
profile: profile.name,
|
|
issuedAt: '2026-07-25T00:00:00.000Z',
|
|
expiresAt: '2026-07-26T00:00:00.000Z',
|
|
sessionId: `session-${userId}`,
|
|
user: {
|
|
id: userId,
|
|
username: userId,
|
|
displayName: userId,
|
|
roles: [],
|
|
},
|
|
sanctions: {},
|
|
});
|
|
|
|
const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGeneral?: boolean } = {}): GameApiContext => {
|
|
const db = {
|
|
general: {
|
|
findFirst: async ({ where }: { where: { userId: string } }) =>
|
|
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
|
|
},
|
|
worldState: {
|
|
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
|
},
|
|
yearbookHistory: {
|
|
findFirst: async (args: {
|
|
where: { profileName: string; year?: number; month?: number };
|
|
orderBy?: Array<{ year?: 'asc' | 'desc'; month?: 'asc' | 'desc' }>;
|
|
}) => {
|
|
const candidates = archiveRows.filter(
|
|
(row) =>
|
|
row.profileName === args.where.profileName &&
|
|
(args.where.year === undefined || row.year === args.where.year) &&
|
|
(args.where.month === undefined || row.month === args.where.month)
|
|
);
|
|
if (!args.orderBy) {
|
|
return candidates[0] ?? null;
|
|
}
|
|
const descending = args.orderBy[0]?.year === 'desc';
|
|
return (descending ? candidates.at(-1) : candidates[0]) ?? null;
|
|
},
|
|
},
|
|
};
|
|
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('historical yearbook access from dynasty', () => {
|
|
it('selects the archived server range without treating it as the live month', async () => {
|
|
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
|
const result = await caller.yearbook.getRange({ serverID: archiveServerId });
|
|
|
|
expect(result).toEqual({
|
|
firstYearMonth: 200 * 12,
|
|
lastYearMonth: 200 * 12 + 1,
|
|
currentYearMonth: 200 * 12 + 1,
|
|
});
|
|
});
|
|
|
|
it('returns the same archived public history for distinct general owners', async () => {
|
|
const ownerA = appRouter.createCaller(buildContext(authFor('owner-a')));
|
|
const ownerB = appRouter.createCaller(buildContext(authFor('owner-b')));
|
|
|
|
const [resultA, resultB] = await Promise.all([
|
|
ownerA.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
|
|
ownerB.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
|
|
]);
|
|
|
|
expect(resultB).toEqual(resultA);
|
|
expect(resultA).toMatchObject({
|
|
notModified: false,
|
|
data: {
|
|
year: 200,
|
|
month: 2,
|
|
nations: [{ id: 1, name: '촉', cities: ['성도'] }],
|
|
globalHistory: ['<C>●</>2월: 기록 없음'],
|
|
globalAction: ['<C>●</>2월: 기록 없음'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('requires both an authenticated user and a user-owned general like legacy v_history.php', async () => {
|
|
const anonymous = appRouter.createCaller(buildContext(null));
|
|
const noGeneral = appRouter.createCaller(buildContext(authFor('owner-a'), { hasGeneral: false }));
|
|
|
|
await expect(anonymous.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
|
|
code: 'UNAUTHORIZED',
|
|
});
|
|
await expect(noGeneral.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
|
|
code: 'NOT_FOUND',
|
|
message: 'General not found',
|
|
});
|
|
});
|
|
|
|
it('rejects unknown archived server IDs instead of falling back to the live profile', async () => {
|
|
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
|
|
|
await expect(caller.yearbook.getRange({ serverID: 'missing-server' })).rejects.toMatchObject({
|
|
code: 'NOT_FOUND',
|
|
message: '연감 범위를 찾을 수 없습니다.',
|
|
});
|
|
});
|
|
});
|