test: 실제 Game API 명령 전수 근거를 고정한다
This commit is contained in:
@@ -82,10 +82,14 @@ const buildContext = (options: {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
transaction?: ReturnType<typeof vi.fn>;
|
transaction?: ReturnType<typeof vi.fn>;
|
||||||
clockTick?: number;
|
clockTick?: number;
|
||||||
|
daemonResult?: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
|
||||||
}) => {
|
}) => {
|
||||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||||
const requestCommand = vi.fn(async (command: { type: string }) => {
|
const requestCommand = vi.fn(async (command: { type: string }) => {
|
||||||
|
if (options.daemonResult !== undefined) {
|
||||||
|
return options.daemonResult;
|
||||||
|
}
|
||||||
if (command.type === 'auctionOpen') {
|
if (command.type === 'auctionOpen') {
|
||||||
return {
|
return {
|
||||||
type: 'auctionOpen' as const,
|
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 () => {
|
it('rejects auction mutations after unification before sending a daemon command', async () => {
|
||||||
const fixture = buildContext({ isUnited: 0, isunited: 2 });
|
const fixture = buildContext({ isUnited: 0, isunited: 2 });
|
||||||
const caller = appRouter.createCaller(fixture.context);
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
@@ -426,4 +462,91 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
|
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
|
||||||
expect(rejected.requestCommand).not.toHaveBeenCalled();
|
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 path from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const classifications = {
|
const classifications = {
|
||||||
durableJournal: [
|
durableJournal: [
|
||||||
'betting.bet',
|
'betting.bet',
|
||||||
|
'diplomacy.destroyLetter',
|
||||||
|
'diplomacy.respondLetter',
|
||||||
|
'diplomacy.rollbackLetter',
|
||||||
|
'diplomacy.sendLetter',
|
||||||
'inherit.checkOwner',
|
'inherit.checkOwner',
|
||||||
'messages.delete',
|
'messages.delete',
|
||||||
'messages.respond',
|
'messages.respond',
|
||||||
'messages.send',
|
'messages.send',
|
||||||
'turns.repeatGeneral',
|
'turns.reserved.repeatGeneral',
|
||||||
'turns.setGeneral',
|
'turns.reserved.setGeneral',
|
||||||
'turns.setGeneralBulk',
|
'turns.reserved.setGeneralBulk',
|
||||||
'turns.shiftGeneral',
|
'turns.reserved.setNation',
|
||||||
|
'turns.reserved.setNationBulk',
|
||||||
|
'turns.reserved.shiftGeneral',
|
||||||
'vote.closePoll',
|
'vote.closePoll',
|
||||||
'vote.createPoll',
|
'vote.createPoll',
|
||||||
'vote.submitVote',
|
'vote.submitVote',
|
||||||
@@ -23,16 +31,10 @@ const classifications = {
|
|||||||
explicitNoRealtimeConsumer: [
|
explicitNoRealtimeConsumer: [
|
||||||
'board.writeArticle',
|
'board.writeArticle',
|
||||||
'board.writeComment',
|
'board.writeComment',
|
||||||
'diplomacy.destroyLetter',
|
|
||||||
'diplomacy.respondLetter',
|
|
||||||
'diplomacy.rollbackLetter',
|
|
||||||
'diplomacy.sendLetter',
|
|
||||||
'join.listPossessCandidates',
|
'join.listPossessCandidates',
|
||||||
'messages.readLatest',
|
'messages.readLatest',
|
||||||
'turns.repeatNation',
|
'turns.reserved.repeatNation',
|
||||||
'turns.setNation',
|
'turns.reserved.shiftNation',
|
||||||
'turns.setNationBulk',
|
|
||||||
'turns.shiftNation',
|
|
||||||
'vote.addComment',
|
'vote.addComment',
|
||||||
],
|
],
|
||||||
engineOwned: [
|
engineOwned: [
|
||||||
@@ -109,40 +111,71 @@ const listTypeScriptFiles = (directory: string): string[] =>
|
|||||||
return entry.isFile() && entry.name.endsWith('.ts') ? [target] : [];
|
return entry.isFile() && entry.name.endsWith('.ts') ? [target] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const extractMutationNames = (file: string): string[] => {
|
const countDeclaredMutations = (file: string): number =>
|
||||||
const source = readFileSync(file, 'utf8');
|
[...readFileSync(file, 'utf8').matchAll(/\.mutation\s*\(/gu)].length;
|
||||||
const names: string[] = [];
|
|
||||||
for (const mutation of source.matchAll(/\.mutation\s*\(/gu)) {
|
interface RuntimeProcedureDef {
|
||||||
const prefix = source.slice(0, mutation.index);
|
type: string;
|
||||||
const propertyCandidates = [...prefix.matchAll(/^ {4,8}([A-Za-z][A-Za-z0-9]*):/gmu)];
|
middlewares: readonly unknown[];
|
||||||
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 readRuntimeProcedureDef = (procedure: unknown): RuntimeProcedureDef => {
|
||||||
const propertyIndex = property?.index ?? -1;
|
if (typeof procedure !== 'function') {
|
||||||
const exportedIndex = exported?.index ?? -1;
|
throw new Error('Mounted tRPC procedure is not callable.');
|
||||||
const name = propertyIndex > exportedIndex ? property?.[1] : exported?.[1];
|
|
||||||
if (!name) throw new Error(`Could not resolve mutation name in ${file}`);
|
|
||||||
names.push(name);
|
|
||||||
}
|
}
|
||||||
return names;
|
const 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 mountedProcedureDefs = new Map(
|
||||||
const relative = path.relative(routerRoot, file);
|
Object.entries(appRouter._def.procedures).map(
|
||||||
const [top] = relative.split(path.sep);
|
([name, procedure]) => [name, readRuntimeProcedureDef(procedure)] as const
|
||||||
if (!top) throw new Error(`Could not resolve router prefix for ${file}`);
|
)
|
||||||
return top.endsWith('.ts') ? path.basename(top, '.ts') : top;
|
);
|
||||||
};
|
|
||||||
|
const mountedMutationNames = (): string[] =>
|
||||||
|
[...mountedProcedureDefs]
|
||||||
|
.filter(([, definition]) => definition.type === 'mutation')
|
||||||
|
.map(([name]) => name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
describe('game-api direct mutation journal inventory', () => {
|
describe('game-api direct mutation journal inventory', () => {
|
||||||
it('requires every router mutation to retain an explicit ownership and realtime classification', () => {
|
it('requires every router mutation to retain an explicit ownership and realtime classification', () => {
|
||||||
const actual = listTypeScriptFiles(routerRoot)
|
const actual = mountedMutationNames();
|
||||||
.flatMap((file) => extractMutationNames(file).map((name) => `${routePrefix(file)}.${name}`))
|
const declaredCount = listTypeScriptFiles(routerRoot).reduce(
|
||||||
.sort();
|
(total, file) => total + countDeclaredMutations(file),
|
||||||
|
0
|
||||||
|
);
|
||||||
const classified = Object.values(classifications).flat().sort();
|
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(new Set(classified).size).toBe(classified.length);
|
||||||
expect(classified).toHaveLength(87);
|
expect(classified).toHaveLength(87);
|
||||||
expect(actual).toEqual(classified);
|
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 () => {
|
it('gets the server-owned pre-start deletion status without accepting a general id', async () => {
|
||||||
const requestCommand = vi.fn(async () => ({
|
const requestCommand = vi.fn(async () => ({
|
||||||
type: 'ensureDieOnPrestartStatus' as const,
|
type: 'ensureDieOnPrestartStatus' as const,
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ const buildContext = (options: {
|
|||||||
configConst?: Record<string, unknown>;
|
configConst?: Record<string, unknown>;
|
||||||
configMap?: Record<string, unknown>;
|
configMap?: Record<string, unknown>;
|
||||||
daemonResult?: TurnDaemonCommandResult;
|
daemonResult?: TurnDaemonCommandResult;
|
||||||
|
requestId?: string;
|
||||||
|
transaction?: ReturnType<typeof vi.fn>;
|
||||||
}) => {
|
}) => {
|
||||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
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}`);
|
throw new Error(`Unexpected raw query in inherit router fixture: ${sql}`);
|
||||||
});
|
});
|
||||||
const db = {
|
const db = {
|
||||||
|
...(options.transaction ? { $transaction: options.transaction } : {}),
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: vi.fn(async () => activeWorldState),
|
findFirst: vi.fn(async () => activeWorldState),
|
||||||
@@ -228,6 +231,7 @@ const buildContext = (options: {
|
|||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
auth,
|
auth,
|
||||||
|
...(options.requestId ? { requestId: options.requestId } : {}),
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
@@ -640,6 +644,55 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
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 () => {
|
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
inheritancePoint: 1500,
|
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 });
|
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 () => {
|
it('returns the legacy tournament rank ordering only to a user who owns a general', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const transport = new TournamentTransport();
|
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 () => {
|
it('maps an ENGINE actor-binding rejection to a forbidden API response', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
## 범위와 판정 규칙
|
## 범위와 판정 규칙
|
||||||
|
|
||||||
`app/game-api/src/router/**`에서 `.mutation()`으로 선언한 86개 route를 2026-08-16
|
`app/game-api/src/router/**`에서 `.mutation()`으로 선언하고 실제 `appRouter`에 mount한
|
||||||
기준으로 전수 분류한다. 이 목록은 “mutation transport를 사용한다”와 “game DB를
|
87개 route를 2026-08-24 기준으로 전수 분류한다. 이 목록은 “mutation transport를
|
||||||
변경한다”를 구분한다. 신규 route가 추가되면
|
사용한다”와 “game DB를 변경한다”를 구분한다. 신규 route가 추가되거나 선언한 router가
|
||||||
|
mount되지 않으면
|
||||||
`app/game-api/test/directMutationJournalInventory.test.ts`가 실패하므로 소유권과
|
`app/game-api/test/directMutationJournalInventory.test.ts`가 실패하므로 소유권과
|
||||||
실시간 소비자를 먼저 정해야 한다.
|
실시간 소비자를 먼저 정해야 한다.
|
||||||
|
|
||||||
@@ -33,10 +34,10 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
|
|||||||
|
|
||||||
| 분류 | 수 | route |
|
| 분류 | 수 | 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` |
|
| 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` |
|
| explicit no realtime consumer | 7 | `board.writeArticle`, `board.writeComment`; `join.listPossessCandidates`; `messages.readLatest`; `turns.reserved.repeatNation`, `turns.reserved.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` |
|
| 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` |
|
| 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` |
|
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
|
||||||
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
|
| 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을 만들지 않는다. |
|
| `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한다. |
|
| `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.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.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를 표시한다. |
|
| `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이다. |
|
| 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에 저장한다. |
|
| `public.recordAccess` | `access.general:<general>` | 없음 | Ref 순서상 gameplay transaction 밖의 별도 access transaction에 저장한다. |
|
||||||
|
|
||||||
@@ -68,8 +71,9 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
|
|||||||
|
|
||||||
- 게시글/댓글은 현재 게시판 화면에서 사용자 action 뒤 직접 다시 읽으며 main SSE
|
- 게시글/댓글은 현재 게시판 화면에서 사용자 action 뒤 직접 다시 읽으며 main SSE
|
||||||
listener가 없다. `board.*` domain을 임의로 추가하지 않는다.
|
listener가 없다. `board.*` domain을 임의로 추가하지 않는다.
|
||||||
- 외교 문서(`diplomacyLetter`)는 외교 문서 화면 전용이고 현재 SSE consumer가 없다.
|
- 외교 문서(`diplomacyLetter`) 자체는 외교 문서 화면 전용이고 현재 SSE consumer가 없다.
|
||||||
전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다.
|
다만 Ref가 함께 쓰는 외교/국가 메시지는 양국 메시지 panel의 durable mailbox journal로
|
||||||
|
전달한다. 전쟁/불가침 상태를 실제 변경하는 `messages.respond`와도 구분한다.
|
||||||
- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고
|
- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고
|
||||||
있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.
|
있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.
|
||||||
- nation reserved turn, selection-pool reservation과 possession 후보는 각각 전용
|
- 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 Redis publish는 actor/global front-status journal mark로 바꿨다.
|
||||||
메시지도 pre-commit `messageCreated`를 제거하고 mailbox outbox 전달 뒤 viewer-safe
|
메시지도 pre-commit `messageCreated`를 제거하고 mailbox outbox 전달 뒤 viewer-safe
|
||||||
`messagesInvalidated`만 공개한다. 국가 설정, 베팅, 외교 응답과 장수 예약명령 direct writer는
|
`messagesInvalidated`만 공개한다. 국가 설정, 베팅, 외교 응답과 장수 예약명령 direct writer는
|
||||||
86개 mutation inventory test가 등록/명시적 비대상 분류를 고정한다.
|
실제 `appRouter`에 mount된 87개 mutation inventory test가 등록/명시적 비대상 분류를 고정한다.
|
||||||
|
|
||||||
현재 일부 `authedProcedure`/`accessAuthedProcedure` mutation은 API interactive
|
현재 일부 `authedProcedure`/`accessAuthedProcedure` mutation은 API interactive
|
||||||
transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transaction 완료를
|
transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transaction 완료를
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ runner는 test 시작 전에 실패합니다. 지원 mode에 marker가 하나도
|
|||||||
실행 group의 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로 만들 수 없는 명시적 예외입니다.
|
`external_fixture` mode는 격리 빈 schema로 만들 수 없는 명시적 예외입니다.
|
||||||
현재 `CURRENT_SEASON_FIXTURE_DATABASE_URL`은 별도 Ref 현 시즌 importer가 만든
|
현재 `CURRENT_SEASON_FIXTURE_DATABASE_URL`은 별도 Ref 현 시즌 importer가 만든
|
||||||
read-only snapshot을 요구하므로 조건부 runner의 pass/skip 집계에 포함하지
|
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: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": "pnpm --filter @sammo-ts/integration-tests test:integration",
|
||||||
"test:integration:conditional": "./tools/run-conditional-integration.sh",
|
"test:integration:conditional": "./tools/run-conditional-integration.sh",
|
||||||
|
"test:conditional-integration-registry": "node --test tools/check-conditional-integration-files.test.mjs",
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"tsc7": "node_modules/@typescript/native/bin/tsc",
|
"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)
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
workspace_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
|
workspace_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
|
||||||
registry_file="$script_dir/conditional-integration-registry.tsv"
|
registry_file="$script_dir/conditional-integration-registry.tsv"
|
||||||
|
file_registry="$script_dir/conditional-integration-file-registry.tsv"
|
||||||
env_file=${1:-"$workspace_root/.env.ci"}
|
env_file=${1:-"$workspace_root/.env.ci"}
|
||||||
|
|
||||||
if [ ! -f "$env_file" ]; then
|
if [ ! -f "$env_file" ]; then
|
||||||
@@ -21,6 +22,10 @@ if [ ! -f "$registry_file" ]; then
|
|||||||
echo "missing integration marker registry: $registry_file" >&2
|
echo "missing integration marker registry: $registry_file" >&2
|
||||||
exit 66
|
exit 66
|
||||||
fi
|
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_file=$(CDPATH= cd -- "$(dirname -- "$env_file")" && pwd)/$(basename -- "$env_file")
|
||||||
|
|
||||||
# .env.ci is generated by the sam_rebuild development Compose helper and is a
|
# .env.ci is generated by the sam_rebuild development Compose helper and is a
|
||||||
@@ -369,6 +374,102 @@ markers_for_mode() {
|
|||||||
paste -sd '|' -
|
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() {
|
record_vitest_result() {
|
||||||
label=$1
|
label=$1
|
||||||
result_file=$2
|
result_file=$2
|
||||||
@@ -469,6 +570,7 @@ run_redis_only_tests() {
|
|||||||
|
|
||||||
export PATH
|
export PATH
|
||||||
cd "$workspace_root"
|
cd "$workspace_root"
|
||||||
|
node "$script_dir/check-conditional-integration-files.mjs"
|
||||||
validate_marker_registry
|
validate_marker_registry
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
@@ -680,6 +782,18 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
|
|||||||
exit 69
|
exit 69
|
||||||
fi
|
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"
|
create_owned_schema "$npc_possession_differential_schema"
|
||||||
npc_possession_differential_database_url=$(build_database_url "$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
|
pnpm --filter @sammo-ts/infra prisma:db:push:game
|
||||||
)
|
)
|
||||||
export NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL=$npc_possession_differential_database_url
|
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 \
|
run_marked_tests tools/integration-tests \
|
||||||
"$(markers_for_mode reference_npc_possession)" \
|
"$(markers_for_mode reference_npc_possession)" \
|
||||||
"npc_possession_reference"
|
"npc_possession_reference"
|
||||||
@@ -740,6 +852,8 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
|
|||||||
export DATABASE_URL=$database_url
|
export DATABASE_URL=$database_url
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
run_saved_trace_pair_tests
|
||||||
|
|
||||||
all_database_markers=$(cut -f1 "$validated_registry_file" | paste -sd '|' -)
|
all_database_markers=$(cut -f1 "$validated_registry_file" | paste -sd '|' -)
|
||||||
run_redis_only_tests "$all_database_markers"
|
run_redis_only_tests "$all_database_markers"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user