From a9e2431115051add3599014839ec34326e2e6eea Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 16:30:48 +0000 Subject: [PATCH] =?UTF-8?q?test:=20=EC=8B=A4=EC=A0=9C=20Game=20API=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20=EC=A0=84=EC=88=98=20=EA=B7=BC=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/test/auctionRouter.test.ts | 123 ++++++++++++ .../directMutationJournalInventory.test.ts | 105 +++++++---- .../test/inGameMenuPermissions.test.ts | 43 +++++ app/game-api/test/inheritRouter.test.ts | 53 ++++++ .../test/mutationEvidenceManifest.test.ts | 147 +++++++++++++++ .../test/publicRecordAccessRouter.test.ts | 26 +++ app/game-api/test/tournamentRouter.test.ts | 105 +++++++++++ app/game-api/test/troopRouter.test.ts | 91 +++++++++ ...e-api-direct-mutation-journal-inventory.md | 22 ++- .../game-api-mutation-evidence.tsv | 88 +++++++++ docs/architecture/realtime-change-journal.md | 2 +- docs/integration-tests.md | 8 + package.json | 1 + tools/check-conditional-integration-files.mjs | 178 ++++++++++++++++++ ...eck-conditional-integration-files.test.mjs | 65 +++++++ .../conditional-integration-file-registry.tsv | 11 ++ tools/run-conditional-integration.sh | 118 +++++++++++- 17 files changed, 1138 insertions(+), 48 deletions(-) create mode 100644 app/game-api/test/mutationEvidenceManifest.test.ts create mode 100644 app/game-api/test/publicRecordAccessRouter.test.ts create mode 100644 docs/architecture/game-api-mutation-evidence.tsv create mode 100644 tools/check-conditional-integration-files.mjs create mode 100644 tools/check-conditional-integration-files.test.mjs create mode 100644 tools/conditional-integration-file-registry.tsv diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts index 748a689b..8735dbb9 100644 --- a/app/game-api/test/auctionRouter.test.ts +++ b/app/game-api/test/auctionRouter.test.ts @@ -82,10 +82,14 @@ const buildContext = (options: { requestId?: string; transaction?: ReturnType; clockTick?: number; + daemonResult?: Awaited>; }) => { 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 }) + ); + }); }); diff --git a/app/game-api/test/directMutationJournalInventory.test.ts b/app/game-api/test/directMutationJournalInventory.test.ts index dd05d617..c295a8a5 100644 --- a/app/game-api/test/directMutationJournalInventory.test.ts +++ b/app/game-api/test/directMutationJournalInventory.test.ts @@ -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']); + }); }); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index f2edd74a..d4320144 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -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, diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 118e7062..ae9af24c 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -112,6 +112,8 @@ const buildContext = (options: { configConst?: Record; configMap?: Record; daemonResult?: TurnDaemonCommandResult; + requestId?: string; + transaction?: ReturnType; }) => { 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, diff --git a/app/game-api/test/mutationEvidenceManifest.test.ts b/app/game-api/test/mutationEvidenceManifest.test.ts new file mode 100644 index 00000000..b678614e --- /dev/null +++ b/app/game-api/test/mutationEvidenceManifest.test.ts @@ -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; + +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 = { + '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 ?? ''}`); + } + + 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([]); + }); +}); diff --git a/app/game-api/test/publicRecordAccessRouter.test.ts b/app/game-api/test/publicRecordAccessRouter.test.ts new file mode 100644 index 00000000..487a6ce7 --- /dev/null +++ b/app/game-api/test/publicRecordAccessRouter.test.ts @@ -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' }); + }); +}); diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index d0b08e2a..b4635eec 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -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(); diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts index 3516194c..c0258395 100644 --- a/app/game-api/test/troopRouter.test.ts +++ b/app/game-api/test/troopRouter.test.ts @@ -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: { diff --git a/docs/architecture/game-api-direct-mutation-journal-inventory.md b/docs/architecture/game-api-direct-mutation-journal-inventory.md index fd2f478a..34e2d467 100644 --- a/docs/architecture/game-api-direct-mutation-journal-inventory.md +++ b/docs/architecture/game-api-direct-mutation-journal-inventory.md @@ -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:`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. | | `inherit.checkOwner` | 확인자·확인 대상의 `messages.mailbox:` | 두 장수 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 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:`, `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:`, `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:`, `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:` | 없음 | 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 후보는 각각 전용 diff --git a/docs/architecture/game-api-mutation-evidence.tsv b/docs/architecture/game-api-mutation-evidence.tsv new file mode 100644 index 00000000..454b4cfb --- /dev/null +++ b/docs/architecture/game-api-mutation-evidence.tsv @@ -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 diff --git a/docs/architecture/realtime-change-journal.md b/docs/architecture/realtime-change-journal.md index 103e5924..9132b87a 100644 --- a/docs/architecture/realtime-change-journal.md +++ b/docs/architecture/realtime-change-journal.md @@ -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 완료를 diff --git a/docs/integration-tests.md b/docs/integration-tests.md index f1c458e2..5ae7f5ac 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -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 집계에 포함하지 diff --git a/package.json b/package.json index f652364e..c249a016 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/check-conditional-integration-files.mjs b/tools/check-conditional-integration-files.mjs new file mode 100644 index 00000000..6a63fe42 --- /dev/null +++ b/tools/check-conditional-integration-files.mjs @@ -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; + } +} diff --git a/tools/check-conditional-integration-files.test.mjs b/tools/check-conditional-integration-files.test.mjs new file mode 100644 index 00000000..91c3619e --- /dev/null +++ b/tools/check-conditional-integration-files.test.mjs @@ -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 + ); +}); diff --git a/tools/conditional-integration-file-registry.tsv b/tools/conditional-integration-file-registry.tsv new file mode 100644 index 00000000..9414b8c1 --- /dev/null +++ b/tools/conditional-integration-file-registry.tsv @@ -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 diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 7439da19..82045d24 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -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"