fix: 예약 명령의 Ref 인자 검증 순서를 복원한다

This commit is contained in:
2026-08-24 19:48:43 +00:00
parent 97b7c49070
commit 0e10b64fb7
5 changed files with 359 additions and 25 deletions
+42
View File
@@ -8,9 +8,11 @@ import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scena
import { describe, expect, it } from 'vitest';
import {
assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions,
buildTurnCommandInputFields,
parseReservedTurnArgs,
sanitizeReservedTurnArgs,
} from '../src/turns/commandInput.js';
const buildShopItem = (key: string, name: string) => ({
@@ -99,6 +101,46 @@ describe('turn command argument input', () => {
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
});
it('keeps Ref common validation separate from command-specific required fields', async () => {
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({})).not.toThrow();
expect(() =>
assertReservedTurnArgsPassLegacyBasicValidation({
isGold: true,
amount: '1',
destGeneralId: 7,
})
).toThrow('턴이 입력되지 않았습니다.');
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
'턴이 입력되지 않았습니다.'
);
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
});
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
expect(
sanitizeReservedTurnArgs({
nationName: ' <신-국># ',
nested: ['A/B', '0'],
})
).toEqual({
nationName: '&lt;신국&gt;',
nested: ['AB', ''],
});
await expect(
parseReservedTurnArgs('nation', 'che_국호변경', {
nationName: ' <신-국># ',
})
).resolves.toEqual({ nationName: '&lt;신국&gt;' });
await expect(
parseReservedTurnArgs('nation', 'che_피장파장', {
destNationId: 2,
commandType: 'che_-수몰',
})
).resolves.toEqual({ destNationId: 2, commandType: 'che_수몰' });
});
it('rejects internal general commands before parsing their arguments or scenario overrides', async () => {
await expect(parseReservedTurnArgs('general', 'che_NPC능동', {})).rejects.toThrow(
'Unknown general turn command: che_NPC능동'
+46 -2
View File
@@ -1112,6 +1112,30 @@ describe('appRouter', () => {
expect(nationWrites).toHaveLength(2);
});
it('stores Ref-sanitized nation command strings in the reserved queue', async () => {
const general = buildGeneralRow({ id: 19, nationId: 3, officerLevel: 12 });
const nationWrites: unknown[] = [];
const caller = appRouter.createCaller(
buildContext({ state: buildWorldState(), general, nationTurnWrites: nationWrites })
);
const response = await caller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_국호변경',
args: { nationName: ' <신-국># ' },
expectedRevision: 0,
});
expect(response.turns[0]?.args).toEqual({ nationName: '&lt;신국&gt;' });
expect(nationWrites).toHaveLength(1);
expect(nationWrites[0]).toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({ turnIdx: 0, arg: { nationName: '&lt;신국&gt;' } }),
]),
});
});
it('preserves Ref nation-turn penalty key semantics and validation priority', async () => {
const general = buildGeneralRow({
id: 22,
@@ -1159,7 +1183,8 @@ describe('appRouter', () => {
singleCaller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: '휴식',
action: 'che_포상',
args: {},
expectedRevision: 0,
})
).rejects.toMatchObject({
@@ -1184,7 +1209,7 @@ describe('appRouter', () => {
bulkCaller.turns.reserved.setNationBulk({
generalId: general.id,
entries: [
{ turnList: [0], action: '휴식' },
{ turnList: [0], action: 'che_포상', args: {} },
{ turnList: [1], action: 'not-a-command' },
],
expectedRevision: 0,
@@ -1195,6 +1220,25 @@ describe('appRouter', () => {
});
expect(bulkWrites).toHaveLength(0);
expect(bulkUpdates).toHaveLength(0);
const allowedCaller = appRouter.createCaller(
buildContext({
state: buildWorldState('full', 12),
general: buildGeneralRow({
...general,
penalty: {},
}),
})
);
await expect(
allowedCaller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_포상',
args: {},
expectedRevision: 0,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('refills user killturn after a successful nation reservation and invalidates its readers', async () => {
@@ -524,26 +524,36 @@ const requestReservedGeneral = (accessToken: string | undefined, idempotencyKey:
idempotencyKey,
});
const requestReservedNation = (accessToken: string, idempotencyKey: string, targetGeneralId: number) =>
const requestReservedNation = (
accessToken: string,
idempotencyKey: string,
targetGeneralId: number,
command: { action: string; args: unknown } = { action: '휴식', args: {} }
) =>
requestTrpc('turns.reserved.setNation', {
method: 'POST',
input: {
generalId: targetGeneralId,
turnIndex: 0,
action: '휴식',
args: {},
action: command.action,
args: command.args,
expectedRevision: 0,
},
accessToken,
idempotencyKey,
});
const requestReservedNationBulk = (accessToken: string, idempotencyKey: string, targetGeneralId: number) =>
const requestReservedNationBulk = (
accessToken: string,
idempotencyKey: string,
targetGeneralId: number,
command: { action: string; args: unknown } = { action: '휴식', args: {} }
) =>
requestTrpc('turns.reserved.setNationBulk', {
method: 'POST',
input: {
generalId: targetGeneralId,
entries: [{ turnList: [0, 1], action: '휴식', args: {} }],
entries: [{ turnList: [0, 1], action: command.action, args: command.args }],
expectedRevision: 0,
},
accessToken,
@@ -559,11 +569,12 @@ const requestNationReservation = (
kind: NationReservationKind,
accessToken: string,
idempotencyKey: string,
targetGeneralId = generalId
targetGeneralId = generalId,
command: { action: string; args: unknown } = { action: '휴식', args: {} }
) =>
kind === 'single'
? requestReservedNation(accessToken, idempotencyKey, targetGeneralId)
: requestReservedNationBulk(accessToken, idempotencyKey, targetGeneralId);
? requestReservedNation(accessToken, idempotencyKey, targetGeneralId, command)
: requestReservedNationBulk(accessToken, idempotencyKey, targetGeneralId, command);
const ownershipDenialCases = [
{
@@ -680,7 +691,11 @@ integration('game API security over HTTP transport', () => {
},
],
});
if ((await db.worldState.count()) === 0) {
const existingWorlds = await db.worldState.findMany({
select: { id: true, scenarioCode: true },
orderBy: { id: 'asc' },
});
if (existingWorlds.length === 0) {
await db.worldState.create({
data: {
id: fixtureWorldId,
@@ -693,11 +708,23 @@ integration('game API security over HTTP transport', () => {
},
});
createdFixtureWorld = true;
} else if (
existingWorlds.length === 1 &&
existingWorlds[0]?.id === fixtureWorldId &&
existingWorlds[0].scenarioCode === 'security-http'
) {
// A previously interrupted run may leave our own fixture row. It
// remains owned by this suite and is removed during teardown.
createdFixtureWorld = true;
} else {
throw new Error(
`security transport fixture requires an empty schema or its owned world row, got ${JSON.stringify(existingWorlds)}`
);
}
const reservationWorlds = await db.worldState.findMany({ select: { id: true } });
if (reservationWorlds.length !== 1 || !reservationWorlds[0]) {
if (reservationWorlds.length !== 1 || reservationWorlds[0]?.id !== fixtureWorldId) {
throw new Error(
`security transport fixture requires exactly one world row, got ${reservationWorlds.length}`
`security transport fixture requires world ${fixtureWorldId}, got ${JSON.stringify(reservationWorlds)}`
);
}
reservationWorldId = reservationWorlds[0].id;
@@ -1136,7 +1163,10 @@ integration('game API security over HTTP transport', () => {
data: { meta: { killturn: 12 } },
});
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
const result = await requestNationReservation(kind, accessToken, idempotencyKey, generalId, {
action: 'che_포상',
args: {},
});
expect(result.response.status).toBe(412);
expect(result.body).toMatchObject({