feat: validate and describe reserved command arguments
This commit is contained in:
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user