test: 실제 Game API 명령 전수 근거를 고정한다
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
@@ -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 완료를
|
||||
|
||||
@@ -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 집계에 포함하지
|
||||
|
||||
@@ -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
|
||||
|
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user