test: 실제 Game API 명령 전수 근거를 고정한다

This commit is contained in:
2026-08-24 16:30:48 +00:00
parent cca2b3d925
commit a9e2431115
17 changed files with 1138 additions and 48 deletions
+123
View File
@@ -82,10 +82,14 @@ const buildContext = (options: {
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
clockTick?: number;
daemonResult?: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const requestCommand = vi.fn(async (command: { type: string }) => {
if (options.daemonResult !== undefined) {
return options.daemonResult;
}
if (command.type === 'auctionOpen') {
return {
type: 'auctionOpen' as const,
@@ -279,6 +283,38 @@ describe('auction router actor and permission boundaries', () => {
});
});
it('opens a sell-rice auction with only the authenticated actor and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({ requestId: 'http-auction-open-sell', transaction });
const input = {
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
userId: 'forged-user',
generalId: 999,
};
await expect(appRouter.createCaller(fixture.context).auction.openSellRice(input)).resolves.toMatchObject({
auctionId: 91,
});
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionOpen',
requestId: 'http-auction-open-sell:auction.openSellRice:engine:0:auctionOpen',
auctionType: 'SELL_RICE',
userId: 'user-1',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
});
});
it('rejects auction mutations after unification before sending a daemon command', async () => {
const fixture = buildContext({ isUnited: 0, isunited: 2 });
const caller = appRouter.createCaller(fixture.context);
@@ -426,4 +462,91 @@ describe('auction router actor and permission boundaries', () => {
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
expect(rejected.requestCommand).not.toHaveBeenCalled();
});
it('bids rice through the authenticated actor and preserves the sell-rice ENGINE request identity', async () => {
const queryRaw = async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
return [
{
id: 31,
type: 'SELL_RICE',
targetCode: '100',
hostGeneralId: 88,
detail: { title: '금 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00.000Z'),
closeTick: 200n,
},
];
}
if (text.includes('FROM auction_bid')) {
return [];
}
return [];
};
const fixture = buildContext({
general: buildGeneral({ id: 7, userId: 'user-1', rice: 1_500 }),
queryRaw,
requestId: 'http-auction-bid-sell',
clockTick: 100,
});
const input = { auctionId: 31, amount: 500, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).auction.bidSellRice(input)).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionBid',
requestId: 'http-auction-bid-sell:auction.bidSellRice:engine:0:auctionBid',
userId: 'user-1',
auctionId: 31,
generalId: 7,
amount: 500,
acceptedGameTick: 100,
tryExtendCloseDate: true,
});
});
it('maps a rejected sell-rice bid without trusting client actor fields', async () => {
const queryRaw = async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
return [
{
id: 31,
type: 'SELL_RICE',
targetCode: '100',
hostGeneralId: 88,
detail: { amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00.000Z'),
closeTick: 200n,
},
];
}
if (text.includes('FROM auction_bid')) {
return [];
}
return [];
};
const fixture = buildContext({
general: buildGeneral({ rice: 1_500 }),
queryRaw,
clockTick: 100,
daemonResult: {
type: 'auctionBid',
ok: false,
auctionId: 31,
reason: '입찰이 취소되었습니다.',
},
});
const input = { auctionId: 31, amount: 500, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).auction.bidSellRice(input)).rejects.toMatchObject({
code: 'CONFLICT',
message: '입찰이 취소되었습니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1', generalId: 7, auctionId: 31 })
);
});
});
@@ -3,17 +3,25 @@ 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.repeatGeneral',
'turns.setGeneral',
'turns.setGeneralBulk',
'turns.shiftGeneral',
'turns.reserved.repeatGeneral',
'turns.reserved.setGeneral',
'turns.reserved.setGeneralBulk',
'turns.reserved.setNation',
'turns.reserved.setNationBulk',
'turns.reserved.shiftGeneral',
'vote.closePoll',
'vote.createPoll',
'vote.submitVote',
@@ -23,16 +31,10 @@ const classifications = {
explicitNoRealtimeConsumer: [
'board.writeArticle',
'board.writeComment',
'diplomacy.destroyLetter',
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.sendLetter',
'join.listPossessCandidates',
'messages.readLatest',
'turns.repeatNation',
'turns.setNation',
'turns.setNationBulk',
'turns.shiftNation',
'turns.reserved.repeatNation',
'turns.reserved.shiftNation',
'vote.addComment',
],
engineOwned: [
@@ -109,40 +111,71 @@ const listTypeScriptFiles = (directory: string): string[] =>
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);
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.');
}
return names;
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 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;
};
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 = listTypeScriptFiles(routerRoot)
.flatMap((file) => extractMutationNames(file).map((name) => `${routePrefix(file)}.${name}`))
.sort();
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']);
});
});
@@ -773,6 +773,49 @@ describe('in-game my information ownership', () => {
});
});
it('dispatches vacation for the session-owned general with a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const requestCommand = vi.fn(async () => ({
type: 'vacation' as const,
ok: true as const,
generalId: 17,
}));
const fixture = createContext({
me: buildGeneral({ id: 17, userId: 'user-7' }),
requestCommand,
requestId: 'http-general-vacation',
transaction,
});
await expect(appRouter.createCaller(fixture.context).general.vacation()).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(requestCommand).toHaveBeenCalledWith({
type: 'vacation',
requestId: 'http-general-vacation:general.vacation:engine:0:vacation',
userId: 'user-7',
generalId: 17,
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-7' } });
});
it('maps the authoritative vacation rejection without an API-side mutation', async () => {
const requestCommand = vi.fn(async () => ({
type: 'vacation' as const,
ok: false as const,
generalId: 7,
reason: '자동 턴 사용 중에는 휴가할 수 없습니다.',
}));
const fixture = createContext({ requestCommand });
await expect(appRouter.createCaller(fixture.context).general.vacation()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '자동 턴 사용 중에는 휴가할 수 없습니다.',
});
expect(fixture.db.general.update).not.toHaveBeenCalled();
});
it('gets the server-owned pre-start deletion status without accepting a general id', async () => {
const requestCommand = vi.fn(async () => ({
type: 'ensureDieOnPrestartStatus' as const,
+53
View File
@@ -112,6 +112,8 @@ const buildContext = (options: {
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
daemonResult?: TurnDaemonCommandResult;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -171,6 +173,7 @@ const buildContext = (options: {
throw new Error(`Unexpected raw query in inherit router fixture: ${sql}`);
});
const db = {
...(options.transaction ? { $transaction: options.transaction } : {}),
$queryRaw: queryRaw,
worldState: {
findFirst: vi.fn(async () => activeWorldState),
@@ -228,6 +231,7 @@ const buildContext = (options: {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -640,6 +644,55 @@ describe('inherit router actor and permission boundaries', () => {
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('buys a random unique only for the authenticated owner with a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ id: 17, userId: 'user-2' }),
requestId: 'http-inherit-random-unique',
transaction,
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'buyRandomUnique',
generalId: 17,
remainPoint: 9_000,
},
});
await expect(appRouter.createCaller(fixture.context).inherit.buyRandomUnique()).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
requestId: 'http-inherit-random-unique:inherit.buyRandomUnique:engine:0:inheritanceAction',
userId: 'user-2',
input: { action: 'buyRandomUnique' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('maps a random-unique daemon rejection without applying API-side inheritance changes', async () => {
const fixture = buildContext({
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'buyRandomUnique',
code: 'BAD_REQUEST',
reason: '충분한 유산 포인트를 가지고 있지 않습니다.',
},
});
await expect(appRouter.createCaller(fixture.context).inherit.buyRandomUnique()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '충분한 유산 포인트를 가지고 있지 않습니다.',
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({
inheritancePoint: 1500,
@@ -0,0 +1,147 @@
import { existsSync, 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 workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url));
const manifestPath = path.join(workspaceRoot, 'docs/architecture/game-api-mutation-evidence.tsv');
const columns = [
'route',
'owner_boundary',
'ref_basis',
'actor_source',
'strongest_evidence',
'evidence_path',
'remaining_gap',
] as const;
type Column = (typeof columns)[number];
type ManifestRow = Record<Column, string>;
const allowedOwnerBoundaries = new Set([
'durable-journal',
'engine-owned',
'explicit-no-realtime-consumer',
'external-upload',
'mixed-saga',
'operational',
'read-only-mutation-transport',
'redis-projection',
'separate-access-journal',
'session-only',
]);
const allowedRefBases = new Set(['direct-endpoint', 'domain-command', 'core-only', 'read-only-transport']);
const allowedActorSources = new Set([
'gateway-token-user',
'optional-session-db-general',
'session-admin-role',
'session-user',
'session-user-db-general',
'session-user-engine-general',
]);
const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'endpoint-unit', 'source-only']);
const expectedOwnerCounts: Record<string, number> = {
'durable-journal': 19,
'engine-owned': 38,
'explicit-no-realtime-consumer': 7,
'external-upload': 1,
'mixed-saga': 9,
operational: 3,
'read-only-mutation-transport': 2,
'redis-projection': 6,
'separate-access-journal': 1,
'session-only': 1,
};
const parseManifest = (): ManifestRow[] => {
const [header, ...lines] = readFileSync(manifestPath, 'utf8').trimEnd().split(/\r?\n/u);
if (header !== columns.join('\t')) {
throw new Error(`Unexpected mutation evidence manifest header: ${header ?? '<empty>'}`);
}
return lines.map((line, index) => {
const values = line.split('\t');
if (values.length !== columns.length || values.some((value) => value.length === 0)) {
throw new Error(`Invalid mutation evidence row at line ${index + 2}.`);
}
return Object.fromEntries(columns.map((column, valueIndex) => [column, values[valueIndex]])) as ManifestRow;
});
};
interface RuntimeProcedureDef {
type: string;
}
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');
if (typeof type !== 'string') {
throw new Error('Mounted tRPC procedure has an unexpected runtime definition.');
}
return { type };
};
const mountedMutationNames = (): string[] =>
Object.entries(appRouter._def.procedures)
.filter(([, procedure]) => readRuntimeProcedureDef(procedure).type === 'mutation')
.map(([name]) => name)
.sort();
describe('game-api mutation evidence manifest', () => {
it('lists every mounted mutation exactly once', () => {
const rows = parseManifest();
const manifestRoutes = rows.map(({ route }) => route);
expect(rows).toHaveLength(87);
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
expect(manifestRoutes).toEqual(mountedMutationNames());
});
it('retains the bounded ownership taxonomy and allowed evidence vocabulary', () => {
const rows = parseManifest();
const ownerCounts = Object.fromEntries(
[...allowedOwnerBoundaries].map((owner) => [
owner,
rows.filter(({ owner_boundary }) => owner_boundary === owner).length,
])
);
expect(ownerCounts).toEqual(expectedOwnerCounts);
for (const row of rows) {
expect(allowedOwnerBoundaries.has(row.owner_boundary), row.route).toBe(true);
expect(allowedRefBases.has(row.ref_basis), row.route).toBe(true);
expect(allowedActorSources.has(row.actor_source), row.route).toBe(true);
expect(allowedEvidenceLevels.has(row.strongest_evidence), row.route).toBe(true);
expect(row.remaining_gap, row.route).toMatch(/^[a-z0-9-]+$/u);
expect(row.evidence_path.startsWith('app/') || row.evidence_path.startsWith('tools/')).toBe(true);
expect(existsSync(path.join(workspaceRoot, row.evidence_path)), row.route).toBe(true);
}
});
it('records the only unauthenticated mutation boundaries explicitly', () => {
const rowsByRoute = new Map(parseManifest().map((row) => [row.route, row]));
expect(rowsByRoute.get('auth.exchangeGatewayToken')?.actor_source).toBe('gateway-token-user');
expect(rowsByRoute.get('public.recordAccess')?.actor_source).toBe('optional-session-db-general');
const otherPublicActors = [...rowsByRoute]
.filter(([route]) => route !== 'auth.exchangeGatewayToken' && route !== 'public.recordAccess')
.filter(([, row]) => !row.actor_source.startsWith('session-'))
.map(([route]) => route);
expect(otherPublicActors).toEqual([]);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import type { GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
const context = {
auth: null,
generalAccessTracking: true,
db: {},
profile: { id: 'che', name: 'che:default', scenario: 'default' },
profileStatusSource: { get: async () => 'RUNNING' as const },
} as unknown as GameApiContext;
describe('public.recordAccess endpoint', () => {
it('keeps anonymous page telemetry as an accepted no-op outside input_event', async () => {
await expect(appRouter.createCaller(context).public.recordAccess({ page: 'traffic' })).resolves.toEqual({
recorded: false,
});
});
it('rejects page names outside the server-owned Ref access inventory', async () => {
await expect(
appRouter.createCaller(context).public.recordAccess({ page: 'forged-page' } as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
});
+105
View File
@@ -397,6 +397,111 @@ describe('tournament router permissions and mutations', () => {
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true });
});
it('applies the admin role boundary to every tournament mutation', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1');
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-1', roles: ['user'] })
);
const state = {
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
};
await expect(caller.tournament.setState(state)).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.patchState({ phase: 1 })).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setParticipants([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setMatches([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setBettingEntries([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id] })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.tournament.cancel()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('validates and executes every profile-scoped tournament admin mutation', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1');
const rival = buildGeneral(2, 'user-2');
const caller = appRouter.createCaller(
buildContext({
redis,
transport,
generals: [general, rival],
userId: 'user-1',
roles: ['admin.tournament:che:default'],
})
);
const state = {
stage: 6,
phase: 0,
type: 0,
auto: false,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
};
await expect(caller.tournament.setState(state)).resolves.toEqual({ ok: true });
await expect(caller.tournament.patchState({ phase: 2 })).resolves.toEqual({ ok: true });
await expect(
caller.tournament.setParticipants([
{
id: general.id,
name: general.name,
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
level: 5,
groupId: 0,
},
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(
caller.tournament.setMatches([
{
id: 1,
stage: 7,
roundIndex: 0,
attackerId: general.id,
defenderId: rival.id,
},
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(
caller.tournament.setBettingEntries([
{ generalId: general.id, targetId: rival.id, amount: 100 },
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
ok: true,
count: 2,
});
await expect(caller.tournament.cancel()).resolves.toEqual({ ok: true });
expect(transport.commands).toContainEqual({
type: 'tournamentRefund',
refunds: [{ generalId: general.id, amount: 100 }],
reason: 'cancel',
});
await expect(caller.tournament.getState()).resolves.toMatchObject({ stage: 0, phase: 0, auto: false });
await expect(caller.tournament.getSnapshot()).resolves.toMatchObject({
participants: [],
matches: [],
betCount: 0,
});
});
it('returns the legacy tournament rank ordering only to a user who owns a general', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
+91
View File
@@ -324,6 +324,97 @@ describe('troop router permissions and mutations', () => {
});
});
it('joins a troop with the session-owned general and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
requestId: 'http-troop-join',
transaction,
result: { type: 'troopJoin', ok: true, generalId: 17, troopId: 9 },
});
const input = { troopId: 9, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).troop.join(input)).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopJoin',
requestId: 'http-troop-join:troop.join:engine:0:troopJoin',
userId: 'user-1',
generalId: 17,
troopId: 9,
});
});
it('maps an authoritative troop-join rejection without trusting client actor fields', async () => {
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
result: {
type: 'troopJoin',
ok: false,
generalId: 17,
troopId: 9,
reason: '다른 국가의 부대입니다.',
},
});
const input = { troopId: 9, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).troop.join(input)).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '다른 국가의 부대입니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1', generalId: 17, troopId: 9 })
);
});
it('exits a troop with the session-owned general and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 9 }),
requestId: 'http-troop-exit',
transaction,
result: { type: 'troopExit', ok: true, generalId: 17, wasLeader: false },
});
await expect(appRouter.createCaller(fixture.context).troop.exit()).resolves.toEqual({
ok: true,
wasLeader: false,
});
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopExit',
requestId: 'http-troop-exit:troop.exit:engine:0:troopExit',
userId: 'user-1',
generalId: 17,
});
});
it('maps an authoritative troop-exit rejection for the session-owned general', async () => {
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
result: {
type: 'troopExit',
ok: false,
generalId: 17,
reason: '부대에 소속되어 있지 않습니다.',
},
});
await expect(appRouter.createCaller(fixture.context).troop.exit()).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '부대에 소속되어 있지 않습니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopExit',
userId: 'user-1',
generalId: 17,
});
});
it('maps an ENGINE actor-binding rejection to a forbidden API response', async () => {
const fixture = buildContext({
result: {
@@ -2,9 +2,10 @@
## 범위와 판정 규칙
`app/game-api/src/router/**`에서 `.mutation()`으로 선언한 86개 route를 2026-08-16
기준으로 전수 분류한다. 이 목록은 “mutation transport를 사용한다”와 “game DB를
변경한다”를 구분한다. 신규 route가 추가되
`app/game-api/src/router/**`에서 `.mutation()`으로 선언하고 실제 `appRouter`에 mount한
87개 route를 2026-08-24 기준으로 전수 분류한다. 이 목록은 “mutation transport를
사용한다”와 “game DB를 변경한다”를 구분한다. 신규 route가 추가되거나 선언한 router가
mount되지 않으면
`app/game-api/test/directMutationJournalInventory.test.ts`가 실패하므로 소유권과
실시간 소비자를 먼저 정해야 한다.
@@ -33,10 +34,10 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
| 분류 | 수 | route |
| --- | ---: | --- |
| durable journal | 13 | `betting.bet`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
| durable journal | 19 | `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` |
| separate access journal | 1 | `public.recordAccess` |
| explicit no realtime consumer | 14 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` |
| engine owned | 37 | `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`, `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` |
| explicit no realtime consumer | 7 | `board.writeArticle`, `board.writeComment`; `join.listPossessCandidates`; `messages.readLatest`; `turns.reserved.repeatNation`, `turns.reserved.shiftNation`; `vote.addComment` |
| engine owned | 38 | `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` |
| mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` |
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
@@ -52,10 +53,12 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
| --- | --- | --- | --- |
| `betting.bet` | `general.content:<actor>`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. |
| `inherit.checkOwner` | 확인자·확인 대상의 `messages.mailbox:<general>` | 두 장수 mailbox viewer에게 ID 없는 `messagesInvalidated` | Ref처럼 확인 결과와 피확인 알림을 시스템 개인 메시지로 저장하며 포인트 차감·유산 로그·두 메시지·journal을 한 API input-event transaction에서 commit한다. |
| 외교 문서 4개 | 양국의 `messages.mailbox:<9000+nation>` | 양국 mailbox viewer에게 ID 없는 `messagesInvalidated` | Ref의 문서 전송·승인/거부·회수·파기 알림을 외교 메시지로 저장하고, 응답은 같은 문구의 국가 메시지도 외교 메시지 뒤에 저장한다. 문서 상태·2/4개 메시지·journal을 한 API input-event transaction에서 commit한다. |
| `messages.send` | 생성된 수신/송신 복사본의 `messages.mailbox:<mailbox>` | 해당 mailbox viewer에게 ID 없는 `messagesInvalidated` | 기존 pre-commit Redis `messageCreated`를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. |
| `messages.delete` | 실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. |
| `messages.respond` | 영향 mailbox, `records.general`, 실제 외교 변경 국가의 `nation.content`, front-state patch 도시의 `city.content`, 필요 시 `map.world`, transitive aggregate용 `dashboard.global` | mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. |
| general reserved turn 4개 | `reserved.general:<general>`, `dashboard.global:0` | 본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. global key는 troop leader 첫 예약턴에 의존하는 다른 장수 context를 위한 source-only 표식이다. nation reserved turns는 main SSE consumer가 없어 명시적 no-op이다. |
| general reserved turn 4개 | `reserved.general:<general>`, `dashboard.global:0` | 본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. global key는 troop leader 첫 예약턴에 의존하는 다른 장수 context를 위한 source-only 표식이다. |
| `turns.reserved.setNation`, `setNationBulk` | `general.content:<actor>`, `dashboard.global:0` | actor의 dashboard general slice | Ref가 성공한 사용자 수뇌 입력 때 `killturn`을 world 기본 이상으로 보충하는 side effect를 queue write와 같은 API transaction에 저장한다. repeat/shift는 이 side effect가 없어 no-op이다. |
| vote 4개 | 기존 `front.general`/`front.global` | front-status boolean | vote producer 작업에서 pre-commit publish를 journal로 이미 치환했다. 댓글은 active survey 제목을 바꾸지 않아 별도 화면 no-op이다. |
| `public.recordAccess` | `access.general:<general>` | 없음 | Ref 순서상 gameplay transaction 밖의 별도 access transaction에 저장한다. |
@@ -68,8 +71,9 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
- 게시글/댓글은 현재 게시판 화면에서 사용자 action 뒤 직접 다시 읽으며 main SSE
listener가 없다. `board.*` domain을 임의로 추가하지 않는다.
- 외교 문서(`diplomacyLetter`)는 외교 문서 화면 전용이고 현재 SSE consumer가 없다.
전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다.
- 외교 문서(`diplomacyLetter`) 자체는 외교 문서 화면 전용이고 현재 SSE consumer가 없다.
다만 Ref가 함께 쓰는 외교/국가 메시지는 양국 메시지 panel의 durable mailbox journal로
전달한다. 전쟁/불가침 상태를 실제 변경하는 `messages.respond`와도 구분한다.
- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고
있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.
- nation reserved turn, selection-pool reservation과 possession 후보는 각각 전용
@@ -0,0 +1,88 @@
route owner_boundary ref_basis actor_source strongest_evidence evidence_path remaining_gap
auction.bidBuyRice engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
auction.bidSellRice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/auctionRouter.test.ts no-route-actual-db
auction.bidUnique engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
auction.openBuyRice engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
auction.openSellRice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/auctionRouter.test.ts no-route-actual-db
auction.openUnique engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
auth.exchangeGatewayToken session-only core-only gateway-token-user redis tools/integration-tests/test/orchestrator.e2e.test.ts no-ref-counterpart
battle.prepareSimulation read-only-mutation-transport read-only-transport session-user endpoint-unit app/game-api/test/battleSimRouter.test.ts no-route-worker-e2e
battle.simulate read-only-mutation-transport read-only-transport session-user endpoint-unit app/game-api/test/battleSimRouter.test.ts no-route-worker-e2e
betting.bet durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/nationBettingRouter.integration.test.ts no-dynamic-ref
board.uploadImage external-upload direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-real-store-e2e
board.writeArticle explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-route-actual-db
board.writeComment explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-route-actual-db
diplomacy.destroyLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
diplomacy.respondLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
diplomacy.rollbackLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
diplomacy.sendLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
general.adjustIcon engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
general.buildNationCandidate engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.dieOnPrestart engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.dropItem engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.ensureDieOnPrestartStatus engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.instantRetreat engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.setMySetting engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.vacation engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
inherit.buyHiddenBuff mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.buyRandomUnique mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.checkOwner durable-journal domain-command session-user-engine-general actual-db app/game-api/test/inheritOwnerMessages.integration.test.ts no-dynamic-ref
inherit.openUniqueAuction engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
inherit.resetSpecialWar mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.resetStat mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.resetTurnTime mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.setNextSpecialWar mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
join.createGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/createGeneral.integration.test.ts no-dynamic-ref
join.getSelectionPool engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
join.listPossessCandidates explicit-no-realtime-consumer direct-endpoint session-user actual-db app/game-api/test/npcPossession.integration.test.ts no-dynamic-ref
join.possessGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/npcPossession.integration.test.ts no-dynamic-ref
join.reselectPoolGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
join.selectPoolGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
messages.delete durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
messages.readLatest explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
messages.respond durable-journal direct-endpoint session-user-db-general dynamic-ref tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts none
messages.send durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
nation.appoint engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.changePermission engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.setBill engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setBlockScout engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/scoutBlockRouter.test.ts no-route-actual-db
nation.setBlockWar engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setNotice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setRate engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setScoutMsg engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setSecretLimit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
npc.setGeneralPriority engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
npc.setNationPolicy engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
npc.setNationPriority engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
public.recordAccess separate-access-journal direct-endpoint optional-session-db-general endpoint-unit app/game-api/test/publicRecordAccessRouter.test.ts no-route-actual-db
tournament.cancel mixed-saga core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts db-redis-not-atomic
tournament.join mixed-saga direct-endpoint session-user-db-general redis tools/integration-tests/test/tournamentLifecycle.test.ts db-redis-not-atomic
tournament.patchState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.placeBet mixed-saga direct-endpoint session-user-db-general redis tools/integration-tests/test/tournamentLifecycle.test.ts db-redis-not-atomic
tournament.seedParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.setBettingEntries redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.setMatches redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.setParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.setState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
troop.create engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
troop.exit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
troop.join engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
troop.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
troop.rename engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
turnDaemon.pause operational core-only session-admin-role endpoint-unit app/game-api/test/router.test.ts no-runtime-e2e
turnDaemon.resume operational core-only session-admin-role endpoint-unit app/game-api/test/router.test.ts no-runtime-e2e
turnDaemon.run operational core-only session-admin-role actual-db tools/integration-tests/test/orchestrator.e2e.test.ts no-ref-counterpart
turns.reserved.repeatGeneral durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.repeatNation explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.setGeneral durable-journal direct-endpoint session-user-db-general actual-db tools/integration-tests/test/initialization.test.ts no-dynamic-ref
turns.reserved.setGeneralBulk durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.setNation durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.setNationBulk durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.shiftGeneral durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.shiftNation explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
vote.addComment explicit-no-realtime-consumer direct-endpoint session-user-db-general actual-db app/game-api/test/voteCommentTimestamp.integration.test.ts no-dynamic-ref
vote.closePoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
vote.createPoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
vote.submitVote durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
vote.updatePoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
1 route owner_boundary ref_basis actor_source strongest_evidence evidence_path remaining_gap
2 auction.bidBuyRice engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
3 auction.bidSellRice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/auctionRouter.test.ts no-route-actual-db
4 auction.bidUnique engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
5 auction.openBuyRice engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
6 auction.openSellRice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/auctionRouter.test.ts no-route-actual-db
7 auction.openUnique engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
8 auth.exchangeGatewayToken session-only core-only gateway-token-user redis tools/integration-tests/test/orchestrator.e2e.test.ts no-ref-counterpart
9 battle.prepareSimulation read-only-mutation-transport read-only-transport session-user endpoint-unit app/game-api/test/battleSimRouter.test.ts no-route-worker-e2e
10 battle.simulate read-only-mutation-transport read-only-transport session-user endpoint-unit app/game-api/test/battleSimRouter.test.ts no-route-worker-e2e
11 betting.bet durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/nationBettingRouter.integration.test.ts no-dynamic-ref
12 board.uploadImage external-upload direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-real-store-e2e
13 board.writeArticle explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-route-actual-db
14 board.writeComment explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-route-actual-db
15 diplomacy.destroyLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
16 diplomacy.respondLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
17 diplomacy.rollbackLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
18 diplomacy.sendLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
19 general.adjustIcon engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
20 general.buildNationCandidate engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
21 general.dieOnPrestart engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
22 general.dropItem engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
23 general.ensureDieOnPrestartStatus engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
24 general.instantRetreat engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
25 general.setMySetting engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
26 general.vacation engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
27 inherit.buyHiddenBuff mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
28 inherit.buyRandomUnique mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
29 inherit.checkOwner durable-journal domain-command session-user-engine-general actual-db app/game-api/test/inheritOwnerMessages.integration.test.ts no-dynamic-ref
30 inherit.openUniqueAuction engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
31 inherit.resetSpecialWar mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
32 inherit.resetStat mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
33 inherit.resetTurnTime mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
34 inherit.setNextSpecialWar mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
35 join.createGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/createGeneral.integration.test.ts no-dynamic-ref
36 join.getSelectionPool engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
37 join.listPossessCandidates explicit-no-realtime-consumer direct-endpoint session-user actual-db app/game-api/test/npcPossession.integration.test.ts no-dynamic-ref
38 join.possessGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/npcPossession.integration.test.ts no-dynamic-ref
39 join.reselectPoolGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
40 join.selectPoolGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
41 messages.delete durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
42 messages.readLatest explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
43 messages.respond durable-journal direct-endpoint session-user-db-general dynamic-ref tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts none
44 messages.send durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
45 nation.appoint engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
46 nation.changePermission engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
47 nation.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
48 nation.setBill engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
49 nation.setBlockScout engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/scoutBlockRouter.test.ts no-route-actual-db
50 nation.setBlockWar engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
51 nation.setNotice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
52 nation.setRate engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
53 nation.setScoutMsg engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
54 nation.setSecretLimit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
55 npc.setGeneralPriority engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
56 npc.setNationPolicy engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
57 npc.setNationPriority engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
58 public.recordAccess separate-access-journal direct-endpoint optional-session-db-general endpoint-unit app/game-api/test/publicRecordAccessRouter.test.ts no-route-actual-db
59 tournament.cancel mixed-saga core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts db-redis-not-atomic
60 tournament.join mixed-saga direct-endpoint session-user-db-general redis tools/integration-tests/test/tournamentLifecycle.test.ts db-redis-not-atomic
61 tournament.patchState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
62 tournament.placeBet mixed-saga direct-endpoint session-user-db-general redis tools/integration-tests/test/tournamentLifecycle.test.ts db-redis-not-atomic
63 tournament.seedParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
64 tournament.setBettingEntries redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
65 tournament.setMatches redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
66 tournament.setParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
67 tournament.setState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
68 troop.create engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
69 troop.exit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
70 troop.join engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
71 troop.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
72 troop.rename engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
73 turnDaemon.pause operational core-only session-admin-role endpoint-unit app/game-api/test/router.test.ts no-runtime-e2e
74 turnDaemon.resume operational core-only session-admin-role endpoint-unit app/game-api/test/router.test.ts no-runtime-e2e
75 turnDaemon.run operational core-only session-admin-role actual-db tools/integration-tests/test/orchestrator.e2e.test.ts no-ref-counterpart
76 turns.reserved.repeatGeneral durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
77 turns.reserved.repeatNation explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
78 turns.reserved.setGeneral durable-journal direct-endpoint session-user-db-general actual-db tools/integration-tests/test/initialization.test.ts no-dynamic-ref
79 turns.reserved.setGeneralBulk durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
80 turns.reserved.setNation durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
81 turns.reserved.setNationBulk durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
82 turns.reserved.shiftGeneral durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
83 turns.reserved.shiftNation explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
84 vote.addComment explicit-no-realtime-consumer direct-endpoint session-user-db-general actual-db app/game-api/test/voteCommentTimestamp.integration.test.ts no-dynamic-ref
85 vote.closePoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
86 vote.createPoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
87 vote.submitVote durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
88 vote.updatePoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
+1 -1
View File
@@ -254,7 +254,7 @@ transaction commit 뒤에는 dispatcher wake-up만 시도한다.
설문의 pre-commit Redis publish는 actor/global front-status journal mark로 바꿨다.
메시지도 pre-commit `messageCreated`를 제거하고 mailbox outbox 전달 뒤 viewer-safe
`messagesInvalidated`만 공개한다. 국가 설정, 베팅, 외교 응답과 장수 예약명령 direct writer는
86개 mutation inventory test가 등록/명시적 비대상 분류를 고정한다.
실제 `appRouter`에 mount된 87개 mutation inventory test가 등록/명시적 비대상 분류를 고정한다.
현재 일부 `authedProcedure`/`accessAuthedProcedure` mutation은 API interactive
transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transaction 완료를
+8
View File
@@ -84,6 +84,14 @@ runner는 test 시작 전에 실패합니다. 지원 mode에 marker가 하나도
실행 group의 marker 정규식이 비어도 전체 파일로 선택 범위를 넓히지 않고
실패합니다.
DB marker가 없는 Ref 조건부 suite와 저장 trace 비교 suite는
`tools/conditional-integration-file-registry.tsv`에서 파일별 환경 요구를 관리합니다.
Runner는 이 registry를 실제 `describe.skipIf()` gate와 exact-set으로 대조합니다.
`TURN_DIFFERENTIAL_REFERENCE=1`일 때만 Ref runtime 그룹을 실행하고, 저장 trace 비교는
`TURN_REFERENCE_TRACE``TURN_CORE_TRACE`가 모두 있을 때만 실행합니다. 한 trace만
주입한 경우에는 suite를 조용히 skip하지 않고 설정 오류로 실패합니다. DB/Redis marker가
있는 파일을 이 registry에 중복 등록할 수도 없습니다.
`external_fixture` mode는 격리 빈 schema로 만들 수 없는 명시적 예외입니다.
현재 `CURRENT_SEASON_FIXTURE_DATABASE_URL`은 별도 Ref 현 시즌 importer가 만든
read-only snapshot을 요구하므로 조건부 runner의 pass/skip 집계에 포함하지
+1
View File
@@ -13,6 +13,7 @@
"test:prepare": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && pnpm --filter @sammo-ts/gateway-api build",
"test:integration": "pnpm --filter @sammo-ts/integration-tests test:integration",
"test:integration:conditional": "./tools/run-conditional-integration.sh",
"test:conditional-integration-registry": "node --test tools/check-conditional-integration-files.test.mjs",
"build": "turbo build",
"typecheck": "turbo typecheck",
"tsc7": "node_modules/@typescript/native/bin/tsc",
@@ -0,0 +1,178 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const defaultWorkspaceRoot = path.resolve(scriptDirectory, '..');
const defaultRegistryPath = path.join(scriptDirectory, 'conditional-integration-file-registry.tsv');
export const supportedRequirements = new Set([
'reference_command',
'reference_full_lifecycle',
'reference_instant_diplomacy',
'reference_monthly',
'reference_snapshot',
'saved_trace_pair',
]);
const databaseMarkerPattern = /process\.env\.[A-Z0-9_]+_DATABASE_URL/u;
const redisMarkerPattern = /process\.env\.REDIS_URL/u;
const integrationFilePattern = /^test\/[A-Za-z0-9._/-]+\.integration\.test\.ts$/u;
export const parseConditionalIntegrationFileRegistry = (source) => {
const entries = [];
const seen = new Set();
for (const [index, rawLine] of source.split(/\r?\n/u).entries()) {
const line = rawLine.trim();
if (line === '' || line.startsWith('#')) continue;
const fields = rawLine.split('\t');
if (fields.length !== 2) {
throw new Error(`line ${index + 1} must contain exactly one tab-separated file and requirement`);
}
const [file, requirement] = fields.map((field) => field.trim());
if (!integrationFilePattern.test(file)) {
throw new Error(`line ${index + 1} has an invalid integration test path: ${file}`);
}
if (!supportedRequirements.has(requirement)) {
throw new Error(`line ${index + 1} has an unsupported environment requirement: ${requirement}`);
}
if (seen.has(file)) {
throw new Error(`line ${index + 1} duplicates integration test path: ${file}`);
}
seen.add(file);
entries.push({ file, requirement });
}
return entries;
};
const listIntegrationSources = async (testDirectory) => {
const sources = [];
const visit = async (directory) => {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) {
await visit(absolute);
} else if (entry.isFile() && entry.name.endsWith('.integration.test.ts')) {
sources.push({
file: path.relative(path.dirname(testDirectory), absolute).split(path.sep).join('/'),
source: await fs.readFile(absolute, 'utf8'),
});
}
}
};
await visit(testDirectory);
return sources.sort((left, right) => left.file.localeCompare(right.file));
};
const isNonDatabaseConditionalReference = (source) => {
if (!source.includes('describe.skipIf')) return false;
if (databaseMarkerPattern.test(source) || redisMarkerPattern.test(source)) return false;
return (
source.includes('TURN_DIFFERENTIAL_REFERENCE') ||
(source.includes('TURN_REFERENCE_TRACE') && source.includes('TURN_CORE_TRACE'))
);
};
const validateRequirementEvidence = ({ file, requirement, source }) => {
const errors = [];
const requireToken = (token) => {
if (!source.includes(token)) errors.push(`${file}: ${requirement} requires source token ${token}`);
};
if (requirement.startsWith('reference_')) {
requireToken('TURN_DIFFERENTIAL_REFERENCE');
}
switch (requirement) {
case 'reference_command':
requireToken('runReferenceTurnCommandTrace');
break;
case 'reference_full_lifecycle':
requireToken('turn_full_lifecycle_trace.php');
break;
case 'reference_instant_diplomacy':
requireToken('instant_diplomacy_response_trace.php');
break;
case 'reference_monthly':
requireToken('monthly_event_trace.php');
break;
case 'reference_snapshot':
requireToken('readReferenceDatabaseSnapshot');
break;
case 'saved_trace_pair':
requireToken('TURN_REFERENCE_TRACE');
requireToken('TURN_CORE_TRACE');
break;
}
return errors;
};
export const validateConditionalIntegrationFileRegistry = async ({
workspaceRoot = defaultWorkspaceRoot,
registryPath = defaultRegistryPath,
} = {}) => {
const packageRoot = path.join(workspaceRoot, 'tools/integration-tests');
const testDirectory = path.join(packageRoot, 'test');
const registrySource = await fs.readFile(registryPath, 'utf8');
const entries = parseConditionalIntegrationFileRegistry(registrySource);
const sources = await listIntegrationSources(testDirectory);
const sourceByFile = new Map(sources.map((entry) => [entry.file, entry.source]));
const discovered = sources
.filter(({ source }) => isNonDatabaseConditionalReference(source))
.map(({ file }) => file);
const registered = entries.map(({ file }) => file);
const errors = [];
const missing = discovered.filter((file) => !registered.includes(file));
const stale = registered.filter((file) => !discovered.includes(file));
if (missing.length > 0) errors.push(`unregistered non-database conditional suite(s): ${missing.join(', ')}`);
if (stale.length > 0) errors.push(`stale non-database conditional suite(s): ${stale.join(', ')}`);
for (const entry of entries) {
const source = sourceByFile.get(entry.file);
if (source === undefined) continue;
if (databaseMarkerPattern.test(source) || redisMarkerPattern.test(source)) {
errors.push(`${entry.file}: file registry overlaps a database/Redis marker suite`);
}
errors.push(...validateRequirementEvidence({ ...entry, source }));
}
if (errors.length > 0) {
throw new Error(
`invalid conditional integration file registry:\n${errors.map((error) => ` ${error}`).join('\n')}`
);
}
return entries;
};
export const selectEnabledConditionalIntegrationFiles = (entries, environment = process.env) => {
const hasReferenceTrace = Boolean(environment.TURN_REFERENCE_TRACE);
const hasCoreTrace = Boolean(environment.TURN_CORE_TRACE);
if (hasReferenceTrace !== hasCoreTrace) {
throw new Error('TURN_REFERENCE_TRACE and TURN_CORE_TRACE must be provided together');
}
return {
referenceFiles:
environment.TURN_DIFFERENTIAL_REFERENCE === '1'
? entries.filter(({ requirement }) => requirement.startsWith('reference_')).map(({ file }) => file)
: [],
savedTraceFiles:
hasReferenceTrace && hasCoreTrace
? entries.filter(({ requirement }) => requirement === 'saved_trace_pair').map(({ file }) => file)
: [],
};
};
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
try {
const entries = await validateConditionalIntegrationFileRegistry();
selectEnabledConditionalIntegrationFiles(entries);
process.stdout.write(`conditional integration file registry is valid (${entries.length} files)\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseConditionalIntegrationFileRegistry,
selectEnabledConditionalIntegrationFiles,
validateConditionalIntegrationFileRegistry,
} from './check-conditional-integration-files.mjs';
test('keeps every non-database conditional Ref suite in one requirement group', async () => {
const entries = await validateConditionalIntegrationFileRegistry();
const counts = Object.fromEntries(
[...new Set(entries.map(({ requirement }) => requirement))]
.sort()
.map((requirement) => [requirement, entries.filter((entry) => entry.requirement === requirement).length])
);
assert.deepEqual(counts, {
reference_command: 5,
reference_full_lifecycle: 1,
reference_instant_diplomacy: 1,
reference_monthly: 1,
reference_snapshot: 1,
saved_trace_pair: 1,
});
});
test('rejects duplicate files and unsupported requirements', () => {
assert.throws(
() =>
parseConditionalIntegrationFileRegistry(
'test/example.integration.test.ts\treference_command\n' +
'test/example.integration.test.ts\treference_command\n'
),
/duplicates integration test path/u
);
assert.throws(
() => parseConditionalIntegrationFileRegistry('test/example.integration.test.ts\tunknown\n'),
/unsupported environment requirement/u
);
});
test('enables Ref suites only with the Ref runtime and saved traces only as a pair', async () => {
const entries = await validateConditionalIntegrationFileRegistry();
assert.deepEqual(selectEnabledConditionalIntegrationFiles(entries, {}), {
referenceFiles: [],
savedTraceFiles: [],
});
assert.equal(
selectEnabledConditionalIntegrationFiles(entries, { TURN_DIFFERENTIAL_REFERENCE: '1' }).referenceFiles.length,
9
);
assert.deepEqual(
selectEnabledConditionalIntegrationFiles(entries, {
TURN_REFERENCE_TRACE: '/tmp/ref.json',
TURN_CORE_TRACE: '/tmp/core.json',
}).savedTraceFiles,
['test/turnTraceFiles.integration.test.ts']
);
assert.throws(
() => selectEnabledConditionalIntegrationFiles(entries, { TURN_REFERENCE_TRACE: '/tmp/ref.json' }),
/must be provided together/u
);
});
@@ -0,0 +1,11 @@
# Test file Environment requirement
test/instantDiplomacyCoreReference.integration.test.ts reference_instant_diplomacy
test/instantDiplomacyReference.integration.test.ts reference_command
test/monthlyDisasterCoreReference.integration.test.ts reference_monthly
test/turnCommandCoreReference.integration.test.ts reference_command
test/turnCommandFullLifecycle.integration.test.ts reference_full_lifecycle
test/turnCommandGeneralMatrix.integration.test.ts reference_command
test/turnCommandNationMatrix.integration.test.ts reference_command
test/turnCommandReference.integration.test.ts reference_command
test/turnSnapshotReference.integration.test.ts reference_snapshot
test/turnTraceFiles.integration.test.ts saved_trace_pair
1 # Test file Environment requirement
2 test/instantDiplomacyCoreReference.integration.test.ts reference_instant_diplomacy
3 test/instantDiplomacyReference.integration.test.ts reference_command
4 test/monthlyDisasterCoreReference.integration.test.ts reference_monthly
5 test/turnCommandCoreReference.integration.test.ts reference_command
6 test/turnCommandFullLifecycle.integration.test.ts reference_full_lifecycle
7 test/turnCommandGeneralMatrix.integration.test.ts reference_command
8 test/turnCommandNationMatrix.integration.test.ts reference_command
9 test/turnCommandReference.integration.test.ts reference_command
10 test/turnSnapshotReference.integration.test.ts reference_snapshot
11 test/turnTraceFiles.integration.test.ts saved_trace_pair
+116 -2
View File
@@ -11,6 +11,7 @@ usage() {
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
workspace_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
registry_file="$script_dir/conditional-integration-registry.tsv"
file_registry="$script_dir/conditional-integration-file-registry.tsv"
env_file=${1:-"$workspace_root/.env.ci"}
if [ ! -f "$env_file" ]; then
@@ -21,6 +22,10 @@ if [ ! -f "$registry_file" ]; then
echo "missing integration marker registry: $registry_file" >&2
exit 66
fi
if [ ! -f "$file_registry" ]; then
echo "missing integration file registry: $file_registry" >&2
exit 66
fi
env_file=$(CDPATH= cd -- "$(dirname -- "$env_file")" && pwd)/$(basename -- "$env_file")
# .env.ci is generated by the sam_rebuild development Compose helper and is a
@@ -369,6 +374,102 @@ markers_for_mode() {
paste -sd '|' -
}
registered_reference_files() {
awk -F '\t' '$2 ~ /^reference_/ { print $1 }' "$file_registry"
}
files_for_requirement() {
requirement=$1
awk -F '\t' -v requirement="$requirement" '$2 == requirement { print $1 }' "$file_registry"
}
resolve_stack_relative_path() {
candidate_path=$1
case "$candidate_path" in
/*) printf '%s\n' "$candidate_path" ;;
*) printf '%s/%s\n' "$reference_stack" "$candidate_path" ;;
esac
}
require_reference_file() {
required_path=$1
requirement=$2
if [ ! -f "$required_path" ]; then
echo "$requirement requires a reference file that is not available: $required_path" >&2
exit 69
fi
}
validate_reference_file_requirements() {
if [ -n "${REF_COMPARE_SOURCE_ROOT:-}" ]; then
reference_source_root=$(
cd "$workspace_root/tools/integration-tests"
CDPATH= cd -- "$REF_COMPARE_SOURCE_ROOT"
pwd
)
else
reference_source_root="$reference_workspace_root/ref/sam"
fi
requirements=$(awk -F '\t' '$2 ~ /^reference_/ { print $2 }' "$file_registry" | sort -u)
for requirement in $requirements; do
case "$requirement" in
reference_command)
command_runner=${TURN_DIFFERENTIAL_RUNNER_SCRIPT:-"$reference_source_root/hwe/compare/turn_command_trace.php"}
require_reference_file "$(resolve_stack_relative_path "$command_runner")" "$requirement"
;;
reference_full_lifecycle)
require_reference_file \
"$reference_source_root/hwe/compare/turn_full_lifecycle_trace.php" \
"$requirement"
;;
reference_instant_diplomacy)
require_reference_file \
"$reference_source_root/hwe/compare/instant_diplomacy_response_trace.php" \
"$requirement"
;;
reference_monthly)
monthly_runner=${MONTHLY_DIFFERENTIAL_RUNNER_SCRIPT:-"$reference_workspace_root/ref/sam/hwe/compare/monthly_event_trace.php"}
require_reference_file "$(resolve_stack_relative_path "$monthly_runner")" "$requirement"
;;
reference_snapshot)
require_reference_file \
"$reference_workspace_root/ref/sam/hwe/compare/turn_state_snapshot.php" \
"$requirement"
;;
*)
echo "unsupported reference integration requirement: $requirement" >&2
exit 65
;;
esac
done
}
run_saved_trace_pair_tests() {
reference_trace=${TURN_REFERENCE_TRACE:-}
core_trace=${TURN_CORE_TRACE:-}
if [ -z "$reference_trace" ] && [ -z "$core_trace" ]; then
return
fi
if [ -z "$reference_trace" ] || [ -z "$core_trace" ]; then
echo "TURN_REFERENCE_TRACE and TURN_CORE_TRACE must be provided together" >&2
exit 64
fi
if ! (cd "$workspace_root/tools/integration-tests" && [ -f "$reference_trace" ] && [ -f "$core_trace" ]); then
echo "TURN_REFERENCE_TRACE and TURN_CORE_TRACE must both name readable trace files" >&2
exit 66
fi
trace_files=$(files_for_requirement saved_trace_pair)
if [ -z "$trace_files" ]; then
echo "saved_trace_pair has no registered integration test" >&2
exit 65
fi
# Registry paths cannot contain whitespace.
# shellcheck disable=SC2086
run_vitest tools/integration-tests "saved_trace_pair" $trace_files
}
record_vitest_result() {
label=$1
result_file=$2
@@ -469,6 +570,7 @@ run_redis_only_tests() {
export PATH
cd "$workspace_root"
node "$script_dir/check-conditional-integration-files.mjs"
validate_marker_registry
pnpm install --frozen-lockfile
@@ -680,6 +782,18 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
exit 69
fi
export TURN_DIFFERENTIAL_WORKSPACE_ROOT=$reference_workspace_root
export TURN_DIFFERENTIAL_STACK_DIR=$reference_stack
validate_reference_file_requirements
reference_files=$(registered_reference_files)
if [ -z "$reference_files" ]; then
echo "no non-database reference integration files are registered" >&2
exit 65
fi
# Registry paths cannot contain whitespace.
# shellcheck disable=SC2086
run_vitest tools/integration-tests "reference_runtime" $reference_files
create_owned_schema "$npc_possession_differential_schema"
npc_possession_differential_database_url=$(build_database_url "$npc_possession_differential_schema")
(
@@ -689,8 +803,6 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
pnpm --filter @sammo-ts/infra prisma:db:push:game
)
export NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL=$npc_possession_differential_database_url
export TURN_DIFFERENTIAL_WORKSPACE_ROOT=$reference_workspace_root
export TURN_DIFFERENTIAL_STACK_DIR=$reference_stack
run_marked_tests tools/integration-tests \
"$(markers_for_mode reference_npc_possession)" \
"npc_possession_reference"
@@ -740,6 +852,8 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
export DATABASE_URL=$database_url
fi
run_saved_trace_pair_tests
all_database_markers=$(cut -f1 "$validated_registry_file" | paste -sd '|' -)
run_redis_only_tests "$all_database_markers"