수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
|
import type { RedisConnector } from '@sammo-ts/infra';
|
|
|
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
|
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
|
import { appRouter } from '../src/router.js';
|
|
|
|
const general: GeneralRow = {
|
|
id: 1,
|
|
userId: 'user-1',
|
|
name: '정책담당',
|
|
nationId: 1,
|
|
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: 5,
|
|
gold: 1_000,
|
|
rice: 1_000,
|
|
crew: 0,
|
|
crewTypeId: 0,
|
|
train: 0,
|
|
atmos: 0,
|
|
weaponCode: 'None',
|
|
bookCode: 'None',
|
|
horseCode: 'None',
|
|
itemCode: 'None',
|
|
turnTime: new Date('2026-01-01T00:00:00.000Z'),
|
|
recentWarTime: null,
|
|
age: 20,
|
|
startAge: 20,
|
|
personalCode: 'None',
|
|
specialCode: 'None',
|
|
special2Code: 'None',
|
|
lastTurn: {},
|
|
meta: {},
|
|
penalty: {},
|
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
};
|
|
|
|
const auth: GameSessionTokenPayload = {
|
|
version: 1,
|
|
profile: 'che:default',
|
|
issuedAt: '2026-01-01T00:00:00.000Z',
|
|
expiresAt: '2026-01-02T00:00:00.000Z',
|
|
sessionId: 'session-1',
|
|
user: {
|
|
id: 'user-1',
|
|
username: 'tester',
|
|
displayName: 'Tester',
|
|
roles: [],
|
|
},
|
|
sanctions: {},
|
|
};
|
|
|
|
const buildContext = () => {
|
|
const requestCommand = vi.fn(async () => ({
|
|
type: 'setNationSetting' as const,
|
|
ok: false as const,
|
|
code: 'FORBIDDEN' as const,
|
|
nationId: 1,
|
|
reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
|
|
}));
|
|
const db = {
|
|
general: {
|
|
findFirst: vi.fn(async () => general),
|
|
},
|
|
nation: {
|
|
findUnique: vi.fn(async () => ({ meta: {} })),
|
|
},
|
|
worldState: {
|
|
findFirst: vi.fn(async () => ({ meta: { block_change_scout: true } })),
|
|
},
|
|
};
|
|
const redisClient = {
|
|
get: async () => null,
|
|
set: async () => null,
|
|
};
|
|
const context: GameApiContext = {
|
|
db: db as unknown as DatabaseClient,
|
|
redis: {} as RedisConnector['client'],
|
|
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
|
battleSim: {} as GameApiContext['battleSim'],
|
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
|
auth,
|
|
uploadDir: 'uploads',
|
|
uploadPath: '/uploads',
|
|
uploadPublicUrl: null,
|
|
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
|
|
flushStore: new InMemoryFlushStore(),
|
|
gameTokenSecret: 'test-secret',
|
|
};
|
|
return { context, requestCommand };
|
|
};
|
|
|
|
describe('nation scout policy lock', () => {
|
|
it('lets the engine recheck the scenario lock and maps its rejection', async () => {
|
|
const fixture = buildContext();
|
|
await expect(
|
|
appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })
|
|
).rejects.toMatchObject({
|
|
code: 'FORBIDDEN',
|
|
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
|
|
});
|
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
|
type: 'setNationSetting',
|
|
userId: 'user-1',
|
|
generalId: 1,
|
|
nationId: 1,
|
|
mutation: { kind: 'blockScout', value: false },
|
|
});
|
|
expect(fixture.context.db.worldState.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
});
|