Files
core2026/app/game-api/test/directMutationJournalInventory.test.ts
T

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']);
});
});