182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { appRouter } from '../src/router.js';
|
|
|
|
const classifications = {
|
|
durableJournal: [
|
|
'betting.bet',
|
|
'diplomacy.destroyLetter',
|
|
'diplomacy.respondLetter',
|
|
'diplomacy.rollbackLetter',
|
|
'diplomacy.sendLetter',
|
|
'inherit.checkOwner',
|
|
'messages.delete',
|
|
'messages.respond',
|
|
'messages.send',
|
|
'turns.reserved.repeatGeneral',
|
|
'turns.reserved.setGeneral',
|
|
'turns.reserved.setGeneralBulk',
|
|
'turns.reserved.setNation',
|
|
'turns.reserved.setNationBulk',
|
|
'turns.reserved.shiftGeneral',
|
|
'vote.closePoll',
|
|
'vote.createPoll',
|
|
'vote.submitVote',
|
|
'vote.updatePoll',
|
|
],
|
|
separateAccessJournal: ['public.recordAccess'],
|
|
explicitNoRealtimeConsumer: [
|
|
'board.writeArticle',
|
|
'board.writeComment',
|
|
'join.listPossessCandidates',
|
|
'messages.readLatest',
|
|
'turns.reserved.repeatNation',
|
|
'turns.reserved.shiftNation',
|
|
'vote.addComment',
|
|
],
|
|
engineOwned: [
|
|
'auction.bidBuyRice',
|
|
'auction.bidSellRice',
|
|
'auction.bidUnique',
|
|
'auction.openBuyRice',
|
|
'auction.openSellRice',
|
|
'auction.openUnique',
|
|
'general.adjustIcon',
|
|
'general.buildNationCandidate',
|
|
'general.dieOnPrestart',
|
|
'general.dropItem',
|
|
'general.ensureDieOnPrestartStatus',
|
|
'general.instantRetreat',
|
|
'general.setMySetting',
|
|
'general.vacation',
|
|
'inherit.openUniqueAuction',
|
|
'join.createGeneral',
|
|
'join.getSelectionPool',
|
|
'join.possessGeneral',
|
|
'join.reselectPoolGeneral',
|
|
'join.selectPoolGeneral',
|
|
'nation.appoint',
|
|
'nation.changePermission',
|
|
'nation.kick',
|
|
'nation.setBill',
|
|
'nation.setBlockScout',
|
|
'nation.setBlockWar',
|
|
'nation.setNotice',
|
|
'nation.setRate',
|
|
'nation.setScoutMsg',
|
|
'nation.setSecretLimit',
|
|
'npc.setGeneralPriority',
|
|
'npc.setNationPolicy',
|
|
'npc.setNationPriority',
|
|
'troop.create',
|
|
'troop.exit',
|
|
'troop.join',
|
|
'troop.kick',
|
|
'troop.rename',
|
|
],
|
|
mixedSaga: [
|
|
'inherit.buyHiddenBuff',
|
|
'inherit.buyRandomUnique',
|
|
'inherit.resetSpecialWar',
|
|
'inherit.resetStat',
|
|
'inherit.resetTurnTime',
|
|
'inherit.setNextSpecialWar',
|
|
'tournament.cancel',
|
|
'tournament.join',
|
|
'tournament.placeBet',
|
|
],
|
|
redisProjection: [
|
|
'tournament.patchState',
|
|
'tournament.seedParticipants',
|
|
'tournament.setBettingEntries',
|
|
'tournament.setMatches',
|
|
'tournament.setParticipants',
|
|
'tournament.setState',
|
|
],
|
|
operational: ['turnDaemon.pause', 'turnDaemon.resume', 'turnDaemon.run'],
|
|
externalUpload: ['board.uploadImage'],
|
|
readOnlyMutationTransport: ['battle.prepareSimulation', 'battle.simulate'],
|
|
sessionOnly: ['auth.exchangeGatewayToken'],
|
|
} as const;
|
|
|
|
const routerRoot = fileURLToPath(new URL('../src/router/', import.meta.url));
|
|
|
|
const listTypeScriptFiles = (directory: string): string[] =>
|
|
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const target = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) return listTypeScriptFiles(target);
|
|
return entry.isFile() && entry.name.endsWith('.ts') ? [target] : [];
|
|
});
|
|
|
|
const countDeclaredMutations = (file: string): number =>
|
|
[...readFileSync(file, 'utf8').matchAll(/\.mutation\s*\(/gu)].length;
|
|
|
|
interface RuntimeProcedureDef {
|
|
type: string;
|
|
middlewares: readonly unknown[];
|
|
}
|
|
|
|
const readRuntimeProcedureDef = (procedure: unknown): RuntimeProcedureDef => {
|
|
if (typeof procedure !== 'function') {
|
|
throw new Error('Mounted tRPC procedure is not callable.');
|
|
}
|
|
const definition: unknown = Reflect.get(procedure, '_def');
|
|
if (typeof definition !== 'object' || definition === null) {
|
|
throw new Error('Mounted tRPC procedure has no runtime definition.');
|
|
}
|
|
const type: unknown = Reflect.get(definition, 'type');
|
|
const middlewares: unknown = Reflect.get(definition, 'middlewares');
|
|
if (typeof type !== 'string' || !Array.isArray(middlewares)) {
|
|
throw new Error('Mounted tRPC procedure has an unexpected runtime definition.');
|
|
}
|
|
return { type, middlewares };
|
|
};
|
|
|
|
const mountedProcedureDefs = new Map(
|
|
Object.entries(appRouter._def.procedures).map(
|
|
([name, procedure]) => [name, readRuntimeProcedureDef(procedure)] as const
|
|
)
|
|
);
|
|
|
|
const mountedMutationNames = (): string[] =>
|
|
[...mountedProcedureDefs]
|
|
.filter(([, definition]) => definition.type === 'mutation')
|
|
.map(([name]) => name)
|
|
.sort();
|
|
|
|
describe('game-api direct mutation journal inventory', () => {
|
|
it('requires every router mutation to retain an explicit ownership and realtime classification', () => {
|
|
const actual = mountedMutationNames();
|
|
const declaredCount = listTypeScriptFiles(routerRoot).reduce(
|
|
(total, file) => total + countDeclaredMutations(file),
|
|
0
|
|
);
|
|
const classified = Object.values(classifications).flat().sort();
|
|
|
|
// Runtime router shape is authoritative for the public path. The raw declaration
|
|
// count independently catches mutations that were added to a router but never mounted.
|
|
expect(declaredCount).toBe(actual.length);
|
|
expect(new Set(classified).size).toBe(classified.length);
|
|
expect(classified).toHaveLength(87);
|
|
expect(actual).toEqual(classified);
|
|
});
|
|
|
|
it('keeps every mounted mutation authenticated except the two explicit session bootstrap paths', () => {
|
|
// auth.status is the smallest mounted procedure that carries the shared
|
|
// requireAuthMiddleware. Composed procedures retain the same middleware identity.
|
|
const authMiddleware = mountedProcedureDefs.get('auth.status')?.middlewares[0];
|
|
expect(authMiddleware).toBeDefined();
|
|
|
|
const unauthenticated = [...mountedProcedureDefs]
|
|
.filter(([, definition]) => definition.type === 'mutation')
|
|
.filter(([, definition]) => !definition.middlewares.includes(authMiddleware))
|
|
.map(([name]) => name)
|
|
.sort();
|
|
|
|
expect(unauthenticated).toEqual(['auth.exchangeGatewayToken', 'public.recordAccess']);
|
|
});
|
|
});
|