Files
core2026/app/game-api/test/directMutationJournalInventory.test.ts
T
Hide_D dd4645e4d0 feat: 직접 writer를 내구성 변화 저널에 연결
86개 API mutation을 분류하고 메시지 mailbox, 베팅, 국가 설정, 예약 명령의 revision/outbox 표식을 소유 transaction에 연결한다. 공개 SSE는 식별자 없는 invalidation만 노출한다.
2026-08-16 18:38:08 +00:00

149 lines
5.1 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
const classifications = {
durableJournal: [
'betting.bet',
'messages.delete',
'messages.respond',
'messages.send',
'nation.setBill',
'nation.setBlockScout',
'nation.setBlockWar',
'nation.setNotice',
'nation.setRate',
'nation.setScoutMsg',
'nation.setSecretLimit',
'npc.setGeneralPriority',
'npc.setNationPolicy',
'npc.setNationPriority',
'turns.repeatGeneral',
'turns.setGeneral',
'turns.setGeneralBulk',
'turns.shiftGeneral',
'vote.closePoll',
'vote.createPoll',
'vote.submitVote',
'vote.updatePoll',
],
separateAccessJournal: ['public.recordAccess'],
explicitNoRealtimeConsumer: [
'board.writeArticle',
'board.writeComment',
'diplomacy.destroyLetter',
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.sendLetter',
'inherit.checkOwner',
'join.getSelectionPool',
'join.listPossessCandidates',
'messages.readLatest',
'turns.repeatNation',
'turns.setNation',
'turns.setNationBulk',
'turns.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.possessGeneral',
'join.reselectPoolGeneral',
'join.selectPoolGeneral',
'nation.appoint',
'nation.changePermission',
'nation.kick',
'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.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 extractMutationNames = (file: string): string[] => {
const source = readFileSync(file, 'utf8');
const names: string[] = [];
for (const mutation of source.matchAll(/\.mutation\s*\(/gu)) {
const prefix = source.slice(0, mutation.index);
const propertyCandidates = [...prefix.matchAll(/^ {4,8}([A-Za-z][A-Za-z0-9]*):/gmu)];
const exportedCandidates = [...prefix.matchAll(/^export const ([A-Za-z][A-Za-z0-9]*)\s*=/gmu)];
const property = propertyCandidates.at(-1);
const exported = exportedCandidates.at(-1);
const propertyIndex = property?.index ?? -1;
const exportedIndex = exported?.index ?? -1;
const name = propertyIndex > exportedIndex ? property?.[1] : exported?.[1];
if (!name) throw new Error(`Could not resolve mutation name in ${file}`);
names.push(name);
}
return names;
};
const routePrefix = (file: string): string => {
const relative = path.relative(routerRoot, file);
const [top] = relative.split(path.sep);
if (!top) throw new Error(`Could not resolve router prefix for ${file}`);
return top.endsWith('.ts') ? path.basename(top, '.ts') : top;
};
describe('game-api direct mutation journal inventory', () => {
it('requires every router mutation to retain an explicit ownership and realtime classification', () => {
const actual = listTypeScriptFiles(routerRoot)
.flatMap((file) => extractMutationNames(file).map((name) => `${routePrefix(file)}.${name}`))
.sort();
const classified = Object.values(classifications).flat().sort();
expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(86);
expect(actual).toEqual(classified);
});
});