feat: validate and describe reserved command arguments

This commit is contained in:
2026-07-26 09:04:48 +00:00
parent b9d22c75f5
commit f84efafc9a
8 changed files with 503 additions and 20 deletions
+57
View File
@@ -0,0 +1,57 @@
import {
GENERAL_TURN_COMMAND_KEYS,
NATION_TURN_COMMAND_KEYS,
loadGeneralTurnCommandSpecs,
loadNationTurnCommandSpecs,
} from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest';
import {
buildTurnCommandInputFields,
parseReservedTurnArgs,
} from '../src/turns/commandInput.js';
describe('turn command argument input', () => {
it('builds supported fields for every argument-bearing command module', async () => {
const [general, nation] = await Promise.all([
loadGeneralTurnCommandSpecs([...GENERAL_TURN_COMMAND_KEYS]),
loadNationTurnCommandSpecs([...NATION_TURN_COMMAND_KEYS]),
]);
const argumentSpecs = [...general, ...nation].filter((spec) => spec.reqArg);
const fields = argumentSpecs.map((spec) => ({
key: spec.key,
fields: buildTurnCommandInputFields(spec),
}));
expect(argumentSpecs).toHaveLength(44);
expect(fields.every((entry) => entry.fields.length > 0)).toBe(true);
expect(fields.find((entry) => entry.key === 'che_화계')?.fields).toMatchObject([
{ key: 'destCityId', kind: 'select', optionSource: 'cities' },
]);
expect(fields.find((entry) => entry.key === 'che_물자원조')?.fields).toMatchObject([
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
{ key: 'amountList', kind: 'numberTuple' },
]);
});
it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => {
await expect(parseReservedTurnArgs('general', 'che_화계', { destCityId: 7 })).resolves.toEqual({
destCityId: 7,
});
await expect(parseReservedTurnArgs('general', 'che_화계', { destCityId: '7' })).rejects.toBeDefined();
await expect(
parseReservedTurnArgs('nation', 'che_포상', {
isGold: true,
amount: 200,
destGeneralId: 7,
})
).resolves.toEqual({
isGold: true,
amount: 200,
destGeneralId: 7,
});
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow(
'Unknown general turn command'
);
});
});
+63 -2
View File
@@ -77,6 +77,8 @@ const buildContext = (options?: {
general?: GeneralRow | null;
generalTurns?: GeneralTurnRow[];
nationTurns?: NationTurnRow[];
generalTurnWrites?: unknown[];
nationTurnWrites?: unknown[];
auth?: GameSessionTokenPayload | null;
}): GameApiContext => {
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
@@ -105,7 +107,10 @@ const buildContext = (options?: {
findMany: async ({ where }: { where: { generalId: number } }) =>
generalTurns.filter((row) => row.generalId === where.generalId),
deleteMany: async () => ({}),
createMany: async () => ({}),
createMany: async (args: unknown) => {
options?.generalTurnWrites?.push(args);
return {};
},
},
nationTurn: {
findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) =>
@@ -113,7 +118,10 @@ const buildContext = (options?: {
(row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel
),
deleteMany: async () => ({}),
createMany: async () => ({}),
createMany: async (args: unknown) => {
options?.nationTurnWrites?.push(args);
return {};
},
},
};
const accessTokenStore = new RedisAccessTokenStore(
@@ -281,6 +289,59 @@ describe('appRouter', () => {
expect(response[0]?.index).toBe(0);
});
it('validates and persists general command arguments from the authenticated owner', async () => {
const general = buildGeneralRow({ id: 13 });
const writes: unknown[] = [];
const caller = appRouter.createCaller(buildContext({ general, generalTurnWrites: writes }));
const response = await caller.turns.reserved.setGeneral({
generalId: 13,
turnIndex: 0,
action: 'che_화계',
args: { destCityId: 7 },
});
expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } });
expect(writes).toHaveLength(1);
const written = writes[0] as { data: unknown[] };
expect(written.data).toHaveLength(30);
expect(written.data[0]).toMatchObject({
generalId: 13,
turnIdx: 0,
actionCode: 'che_화계',
arg: { destCityId: 7 },
});
});
it('rejects malformed and cross-scope arguments without writing turns', async () => {
const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 });
const generalWrites: unknown[] = [];
const nationWrites: unknown[] = [];
const caller = appRouter.createCaller(
buildContext({ general, generalTurnWrites: generalWrites, nationTurnWrites: nationWrites })
);
await expect(
caller.turns.reserved.setGeneral({
generalId: 14,
turnIndex: 0,
action: 'che_화계',
args: { destCityId: '7' },
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
caller.turns.reserved.setGeneral({
generalId: 14,
turnIndex: 0,
action: 'che_포상',
args: { isGold: true, amount: 1, destGeneralId: 7 },
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(generalWrites).toHaveLength(0);
expect(nationWrites).toHaveLength(0);
});
it('rejects another user general across actor-owned routers', async () => {
const general = buildGeneralRow({ id: 15, userId: 'user-2' });
const caller = appRouter.createCaller(buildContext({ general }));