test: 실제 transport 권한과 커맨드 내구 행렬을 검증한다
실제 Fastify HTTP, PostgreSQL과 Redis에서 예약 mutation의 인증·sanction·소유권·수뇌 경계, 감사 원장과 무부작용을 검증한다. 장수 55개와 수뇌 35개를 R1/R2/R3으로 전수 분류하고 대표 9개를 Ref/Core 의미 비교부터 lease, 단일 flush와 fresh reload까지 실행한다. 비행위자 revision/lease와 관직 5 수뇌 partition도 exact 보존한다.
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createServer, type Server as HttpServer } from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
enqueueWebPushOutboxEvents,
|
||||
resolveRedisConfigFromEnv,
|
||||
type GamePrismaClient,
|
||||
type RedisConnector,
|
||||
@@ -15,17 +17,34 @@ import {
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { createGameApiServer } from '../src/server.js';
|
||||
import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_HOST || !process.env.REDIS_PORT);
|
||||
const profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration';
|
||||
const databaseUrl = process.env.SECURITY_TRANSPORT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL);
|
||||
const dedicatedSuffix = 'security_transport';
|
||||
let profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration';
|
||||
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? String(process.pid);
|
||||
const profileName = `che:security-http-${runId}`;
|
||||
const userId = `security-http-user-${process.pid}`;
|
||||
const noGeneralUserId = `security-http-no-general-${process.pid}`;
|
||||
const sameNationUserId = `security-http-same-nation-${process.pid}`;
|
||||
const foreignUserId = `security-http-foreign-${process.pid}`;
|
||||
const ordinaryUserId = `security-http-ordinary-${process.pid}`;
|
||||
const generalId = 990_001;
|
||||
const sameNationGeneralId = 990_002;
|
||||
const foreignGeneralId = 990_003;
|
||||
const npcGeneralId = 990_004;
|
||||
const ordinaryGeneralId = 990_005;
|
||||
const fixtureGeneralIds = [generalId, sameNationGeneralId, foreignGeneralId, npcGeneralId, ordinaryGeneralId];
|
||||
const ownerNationId = 99_001;
|
||||
const foreignNationId = 99_002;
|
||||
const fixtureNationIds = [ownerNationId, foreignNationId];
|
||||
const fixtureWorldId = 990_001;
|
||||
const mutationRequestPrefix = `security-http-matrix-${process.pid}-`;
|
||||
const secret = 'security-http-e2e-secret';
|
||||
const redisPrefix = `sammo:security-http:${process.pid}`;
|
||||
const envKeys = [
|
||||
'DATABASE_URL',
|
||||
'PROFILE',
|
||||
'SCENARIO',
|
||||
'GAME_PROFILE_NAME',
|
||||
@@ -33,6 +52,7 @@ const envKeys = [
|
||||
'GAME_API_PORT',
|
||||
'GAME_TOKEN_SECRET',
|
||||
'GATEWAY_REDIS_PREFIX',
|
||||
'GATEWAY_INTERNAL_API_URL',
|
||||
'GAME_UPLOAD_DIR',
|
||||
] as const;
|
||||
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||
@@ -46,6 +66,27 @@ let db: GamePrismaClient;
|
||||
let disconnectDb: (() => Promise<void>) | null = null;
|
||||
let redis: RedisConnector | null = null;
|
||||
let accessTokenStore: RedisAccessTokenStore;
|
||||
let createdFixtureWorld = false;
|
||||
let gatewayStatusServer: HttpServer | null = null;
|
||||
let receivedGatewayWebPushEvents: Array<{ internalToken: string | null; body: unknown }> = [];
|
||||
|
||||
export const assertDedicatedSecurityTransportDatabase = (rawUrl: string): void => {
|
||||
resolveDedicatedSecurityTransportTarget(rawUrl);
|
||||
};
|
||||
|
||||
const resolveDedicatedSecurityTransportTarget = (rawUrl: string): { databaseUrl: string; schema: string } => {
|
||||
const url = new URL(rawUrl);
|
||||
const schema = url.searchParams.get('schema');
|
||||
const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
||||
if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) {
|
||||
throw new Error(
|
||||
`Refusing to mutate non-dedicated security transport database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}`
|
||||
);
|
||||
}
|
||||
const effectiveSchema = schema?.trim() || 'public';
|
||||
url.searchParams.set('schema', effectiveSchema);
|
||||
return { databaseUrl: url.href, schema: effectiveSchema };
|
||||
};
|
||||
|
||||
const restoreEnv = (): void => {
|
||||
for (const [key, value] of originalEnv) {
|
||||
@@ -57,28 +98,90 @@ const restoreEnv = (): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const listenGatewayStatusStub = async (): Promise<string> => {
|
||||
gatewayStatusServer = createServer((request, response) => {
|
||||
if (request.method === 'GET' && request.url === `/internal/profile-status/${encodeURIComponent(profileName)}`) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ profileName, status: 'RUNNING' }));
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/internal/account-icon-resets') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ resets: [] }));
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/internal/web-push-events') {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
try {
|
||||
receivedGatewayWebPushEvents.push({
|
||||
internalToken:
|
||||
typeof request.headers['x-sammo-internal-token'] === 'string'
|
||||
? request.headers['x-sammo-internal-token']
|
||||
: null,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown,
|
||||
});
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end('{}');
|
||||
} catch {
|
||||
response.writeHead(400);
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
gatewayStatusServer!.once('error', reject);
|
||||
gatewayStatusServer!.listen(0, '127.0.0.1', () => {
|
||||
gatewayStatusServer!.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = gatewayStatusServer.address();
|
||||
if (!address || typeof address === 'string') throw new Error('gateway status stub did not bind a TCP port');
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
};
|
||||
|
||||
const closeGatewayStatusStub = async (): Promise<void> => {
|
||||
if (!gatewayStatusServer) return;
|
||||
const current = gatewayStatusServer;
|
||||
gatewayStatusServer = null;
|
||||
await new Promise<void>((resolve, reject) => current.close((error) => (error ? reject(error) : resolve())));
|
||||
};
|
||||
|
||||
const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||
for await (const keys of redis.client.scanIterator({
|
||||
MATCH: `sammo:game:*:${profileName}:*`,
|
||||
MATCH: pattern,
|
||||
COUNT: 100,
|
||||
})) {
|
||||
if (keys.length > 0) {
|
||||
await redis.client.del(keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildPayload = (suffix: string, sanctions: GameSessionTokenPayload['sanctions']): GameSessionTokenPayload => ({
|
||||
const buildPayload = (
|
||||
suffix: string,
|
||||
sanctions: GameSessionTokenPayload['sanctions'],
|
||||
actorUserId = userId,
|
||||
actorProfile = profileName
|
||||
): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: profileName,
|
||||
profile: actorProfile,
|
||||
issuedAt: new Date(Date.now() - 1_000).toISOString(),
|
||||
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
|
||||
sessionId: `security-http-session-${process.pid}-${suffix}`,
|
||||
user: {
|
||||
id: userId,
|
||||
id: actorUserId,
|
||||
username: 'security-http-user',
|
||||
displayName: 'Security HTTP User',
|
||||
roles: ['user'],
|
||||
@@ -87,8 +190,12 @@ const buildPayload = (suffix: string, sanctions: GameSessionTokenPayload['sancti
|
||||
sanctions,
|
||||
});
|
||||
|
||||
const createAccessToken = async (suffix: string, sanctions: GameSessionTokenPayload['sanctions']): Promise<string> => {
|
||||
const created = await accessTokenStore.create(buildPayload(suffix, sanctions));
|
||||
const createAccessToken = async (
|
||||
suffix: string,
|
||||
sanctions: GameSessionTokenPayload['sanctions'],
|
||||
actorUserId = userId
|
||||
): Promise<string> => {
|
||||
const created = await accessTokenStore.create(buildPayload(suffix, sanctions, actorUserId));
|
||||
if (!created) {
|
||||
throw new Error('failed to seed the game access token');
|
||||
}
|
||||
@@ -101,6 +208,7 @@ const requestTrpc = async (
|
||||
method?: 'GET' | 'POST';
|
||||
input?: unknown;
|
||||
accessToken?: string;
|
||||
idempotencyKey?: string;
|
||||
} = {}
|
||||
): Promise<{ response: Response; body: unknown }> => {
|
||||
const method = options.method ?? 'GET';
|
||||
@@ -109,6 +217,7 @@ const requestTrpc = async (
|
||||
headers: {
|
||||
...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
|
||||
...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
|
||||
},
|
||||
...(method === 'POST' ? { body: JSON.stringify(options.input) } : {}),
|
||||
});
|
||||
@@ -118,31 +227,402 @@ const requestTrpc = async (
|
||||
};
|
||||
};
|
||||
|
||||
const readReservedMutationState = async () => ({
|
||||
generals: await db.general.findMany({
|
||||
where: { id: { in: fixtureGeneralIds } },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
nationId: true,
|
||||
officerLevel: true,
|
||||
lastTurn: true,
|
||||
meta: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
generalTurns: await db.generalTurn.findMany({
|
||||
where: { generalId: { in: fixtureGeneralIds } },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
}),
|
||||
generalTurnRevisions: await db.generalTurnRevision.findMany({
|
||||
where: { generalId: { in: fixtureGeneralIds } },
|
||||
select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||
orderBy: { generalId: 'asc' },
|
||||
}),
|
||||
generalAccessLogs: await db.generalAccessLog.findMany({
|
||||
where: { generalId: { in: fixtureGeneralIds } },
|
||||
select: {
|
||||
generalId: true,
|
||||
userId: true,
|
||||
lastRefresh: true,
|
||||
refresh: true,
|
||||
refreshTotal: true,
|
||||
refreshScore: true,
|
||||
refreshScoreTotal: true,
|
||||
lastActionAt: true,
|
||||
},
|
||||
orderBy: { generalId: 'asc' },
|
||||
}),
|
||||
nationTurns: await db.nationTurn.findMany({
|
||||
where: { nationId: { in: fixtureNationIds } },
|
||||
select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true },
|
||||
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }],
|
||||
}),
|
||||
nationTurnRevisions: await db.nationTurnRevision.findMany({
|
||||
where: { nationId: { in: fixtureNationIds } },
|
||||
select: { nationId: true, officerLevel: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }],
|
||||
}),
|
||||
readModelRevisions: await db.readModelRevision.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
],
|
||||
},
|
||||
select: { domain: true, entityId: true, revision: true },
|
||||
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||
}),
|
||||
readModelOutbox: await db.readModelOutbox.findMany({
|
||||
select: { id: true, payload: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
messages: await db.message.findMany({
|
||||
where: {
|
||||
OR: [{ src: { in: fixtureGeneralIds } }, { dest: { in: [...fixtureGeneralIds, ...fixtureNationIds] } }],
|
||||
},
|
||||
select: { id: true, mailbox: true, type: true, src: true, dest: true, message: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
logs: await db.logEntry.findMany({
|
||||
where: {
|
||||
OR: [{ generalId: { in: fixtureGeneralIds } }, { nationId: { in: fixtureNationIds } }],
|
||||
},
|
||||
select: { id: true, scope: true, category: true, generalId: true, nationId: true, text: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
engineInputEvents: await db.inputEvent.findMany({
|
||||
where: { target: 'ENGINE', requestId: { startsWith: mutationRequestPrefix } },
|
||||
select: { requestId: true, eventType: true, status: true, actorUserId: true },
|
||||
orderBy: { sequence: 'asc' },
|
||||
}),
|
||||
webPushOutboxCount: await db.webPushOutbox.count(),
|
||||
eventCount: await db.event.count(),
|
||||
auctionCount: await db.auction.count(),
|
||||
auctionBidCount: await db.auctionBid.count(),
|
||||
});
|
||||
|
||||
const quotePostgresIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`;
|
||||
|
||||
const readDurableSchemaStateExcludingMatrixApiJournal = async () => {
|
||||
const tables = await db.$queryRawUnsafe<Array<{ tableName: string }>>(
|
||||
`SELECT table_name AS "tableName"
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name`,
|
||||
profileId
|
||||
);
|
||||
return Promise.all(
|
||||
tables.map(async ({ tableName }) => {
|
||||
const qualifiedTable = `${quotePostgresIdentifier(profileId)}.${quotePostgresIdentifier(tableName)}`;
|
||||
const matrixApiFilter =
|
||||
tableName === 'input_event' ? `WHERE NOT (target = 'API' AND request_id LIKE $1)` : '';
|
||||
const rows = await db.$queryRawUnsafe<Array<{ rowJson: string }>>(
|
||||
`SELECT to_jsonb(snapshot_row)::text AS "rowJson"
|
||||
FROM ${qualifiedTable} AS snapshot_row
|
||||
${matrixApiFilter}
|
||||
ORDER BY to_jsonb(snapshot_row)::text`,
|
||||
...(matrixApiFilter ? [`${mutationRequestPrefix}%`] : [])
|
||||
);
|
||||
return { tableName, rows: rows.map(({ rowJson }) => rowJson) };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
type DurableSchemaState = Awaited<ReturnType<typeof readDurableSchemaStateExcludingMatrixApiJournal>>;
|
||||
|
||||
const withoutDurableTables = (state: DurableSchemaState, allowedTables: readonly string[]): DurableSchemaState => {
|
||||
const allowed = new Set(allowedTables);
|
||||
return state.filter(({ tableName }) => !allowed.has(tableName));
|
||||
};
|
||||
|
||||
const readSuccessAllowedTableState = async () => ({
|
||||
generalTurns: await db.generalTurn.findMany({ orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }] }),
|
||||
generalTurnRevisions: await db.generalTurnRevision.findMany({ orderBy: { generalId: 'asc' } }),
|
||||
nationTurns: await db.nationTurn.findMany({
|
||||
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }],
|
||||
}),
|
||||
nationTurnRevisions: await db.nationTurnRevision.findMany({
|
||||
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }],
|
||||
}),
|
||||
generalAccessLogs: await db.generalAccessLog.findMany({ orderBy: { generalId: 'asc' } }),
|
||||
readModelRevisions: await db.readModelRevision.findMany({
|
||||
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||
}),
|
||||
readModelOutbox: await db.readModelOutbox.findMany({ orderBy: { id: 'asc' } }),
|
||||
});
|
||||
|
||||
const readAccessTelemetryState = async () => ({
|
||||
periods: await db.trafficPeriod.findMany({ orderBy: { id: 'asc' } }),
|
||||
generals: await db.trafficPeriodGeneral.findMany({
|
||||
orderBy: [{ periodId: 'asc' }, { generalId: 'asc' }],
|
||||
}),
|
||||
accessLogs: await db.generalAccessLog.findMany({ orderBy: { generalId: 'asc' } }),
|
||||
});
|
||||
|
||||
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
||||
if (!redis) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
||||
for (const key of batch) keys.add(key);
|
||||
}
|
||||
}
|
||||
return Promise.all(
|
||||
[...keys].sort().map(async (key) => [key, await redis!.client.get(key)] as [string, string | null])
|
||||
);
|
||||
};
|
||||
|
||||
const expectApiInputEvent = async (
|
||||
idempotencyKey: string,
|
||||
procedure: string,
|
||||
expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null
|
||||
): Promise<void> => {
|
||||
const requestId = `${idempotencyKey}:${procedure}`;
|
||||
const events = await db.inputEvent.findMany({
|
||||
// beforeEach removes the whole matrix prefix. Query that complete
|
||||
// namespace so an extra/rewritten API journal row cannot hide behind
|
||||
// the full-schema snapshot's one explicitly allowed exclusion.
|
||||
where: { target: 'API', requestId: { startsWith: mutationRequestPrefix } },
|
||||
select: {
|
||||
requestId: true,
|
||||
target: true,
|
||||
eventType: true,
|
||||
payload: true,
|
||||
actorUserId: true,
|
||||
status: true,
|
||||
result: true,
|
||||
error: true,
|
||||
attempts: true,
|
||||
lockedBy: true,
|
||||
leaseUntil: true,
|
||||
processingAt: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
if (!expected) {
|
||||
expect(events).toEqual([]);
|
||||
return;
|
||||
}
|
||||
expect(events).toEqual([
|
||||
{
|
||||
requestId,
|
||||
target: 'API',
|
||||
eventType: procedure,
|
||||
payload: {},
|
||||
actorUserId: expected.actorUserId,
|
||||
status: expected.status,
|
||||
result: expected.status === 'SUCCEEDED' ? { ok: true } : null,
|
||||
error: expected.status === 'SUCCEEDED' ? null : expect.any(String),
|
||||
attempts: 1,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
processingAt: expect.any(Date),
|
||||
completedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const event = events[0];
|
||||
if (!event?.processingAt || !event.completedAt) {
|
||||
throw new Error('API input event must have processing/completion timestamps');
|
||||
}
|
||||
expect(event.completedAt.getTime()).toBeGreaterThanOrEqual(event.processingAt.getTime());
|
||||
if (expected.status === 'FAILED') {
|
||||
expect(event.error?.length).toBeGreaterThan(0);
|
||||
}
|
||||
};
|
||||
|
||||
const expectSingleActorActivity = (
|
||||
rows: Awaited<ReturnType<typeof readReservedMutationState>>['generalAccessLogs']
|
||||
) => {
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: null,
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 0,
|
||||
lastActionAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const requestReservedGeneral = (accessToken: string | undefined, idempotencyKey: string, targetGeneralId = generalId) =>
|
||||
requestTrpc('turns.reserved.setGeneral', {
|
||||
method: 'POST',
|
||||
input: {
|
||||
generalId: targetGeneralId,
|
||||
turnIndex: 0,
|
||||
action: '휴식',
|
||||
args: {},
|
||||
expectedRevision: 0,
|
||||
},
|
||||
accessToken,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
const requestReservedNation = (accessToken: string, idempotencyKey: string, targetGeneralId: number) =>
|
||||
requestTrpc('turns.reserved.setNation', {
|
||||
method: 'POST',
|
||||
input: {
|
||||
generalId: targetGeneralId,
|
||||
turnIndex: 0,
|
||||
action: '휴식',
|
||||
args: {},
|
||||
expectedRevision: 0,
|
||||
},
|
||||
accessToken,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
const ownershipDenialCases = [
|
||||
{
|
||||
label: 'authenticated user without a general',
|
||||
actorUserId: noGeneralUserId,
|
||||
targetGeneralId: generalId,
|
||||
},
|
||||
{
|
||||
label: 'same-nation foreign-owned general',
|
||||
actorUserId: userId,
|
||||
targetGeneralId: sameNationGeneralId,
|
||||
},
|
||||
{
|
||||
label: 'other-nation foreign-owned general',
|
||||
actorUserId: userId,
|
||||
targetGeneralId: foreignGeneralId,
|
||||
},
|
||||
{
|
||||
label: 'NPC general',
|
||||
actorUserId: userId,
|
||||
targetGeneralId: npcGeneralId,
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe('security transport database guard', () => {
|
||||
it('rejects a shared database and schema before connecting', () => {
|
||||
expect(() =>
|
||||
assertDedicatedSecurityTransportDatabase('postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public')
|
||||
).toThrow('Refusing to mutate non-dedicated security transport database');
|
||||
});
|
||||
|
||||
it('accepts only an explicitly dedicated schema or database name', () => {
|
||||
expect(() =>
|
||||
assertDedicatedSecurityTransportDatabase(
|
||||
'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_security_transport'
|
||||
)
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertDedicatedSecurityTransportDatabase(
|
||||
'postgresql://fixture:fixture@127.0.0.1:5432/ci_security_transport'
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
integration('game API security over HTTP transport', () => {
|
||||
beforeAll(async () => {
|
||||
const dedicatedTarget = resolveDedicatedSecurityTransportTarget(databaseUrl!);
|
||||
profileId = dedicatedTarget.schema;
|
||||
uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-game-security-http-'));
|
||||
process.env.PROFILE = profileId;
|
||||
process.env.DATABASE_URL = dedicatedTarget.databaseUrl;
|
||||
process.env.PROFILE = dedicatedTarget.schema;
|
||||
process.env.SCENARIO = 'security-http';
|
||||
process.env.GAME_PROFILE_NAME = profileName;
|
||||
process.env.GAME_API_HOST = '127.0.0.1';
|
||||
process.env.GAME_API_PORT = '0';
|
||||
process.env.GAME_TOKEN_SECRET = secret;
|
||||
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
||||
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
||||
process.env.GAME_UPLOAD_DIR = uploadDir;
|
||||
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
disconnectDb = () => connector.disconnect();
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.general.create({
|
||||
data: {
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||
await db.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||
await db.general.deleteMany({ where: { id: { in: fixtureGeneralIds } } });
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '보안HTTP',
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 12,
|
||||
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: sameNationGeneralId,
|
||||
userId: sameNationUserId,
|
||||
name: '동일국타인',
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 5,
|
||||
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: foreignGeneralId,
|
||||
userId: foreignUserId,
|
||||
name: '타국타인',
|
||||
nationId: foreignNationId,
|
||||
officerLevel: 5,
|
||||
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: npcGeneralId,
|
||||
userId: null,
|
||||
name: 'NPC장수',
|
||||
nationId: ownerNationId,
|
||||
npcState: 2,
|
||||
officerLevel: 5,
|
||||
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: ordinaryGeneralId,
|
||||
userId: ordinaryUserId,
|
||||
name: '비수뇌',
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 4,
|
||||
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
});
|
||||
if ((await db.worldState.count()) === 0) {
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
id: fixtureWorldId,
|
||||
scenarioCode: 'security-http',
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
createdFixtureWorld = true;
|
||||
}
|
||||
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||
await db.readModelOutbox.deleteMany();
|
||||
await db.webPushOutbox.deleteMany();
|
||||
|
||||
redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await redis.connect();
|
||||
@@ -157,7 +637,29 @@ integration('game API security over HTTP transport', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.app.close();
|
||||
await db?.general.deleteMany({ where: { id: generalId } });
|
||||
await closeGatewayStatusStub();
|
||||
await db?.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
|
||||
await db?.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db?.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||
await db?.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db?.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db?.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db?.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||
await db?.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||
await db?.readModelRevision.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
await db?.readModelOutbox.deleteMany();
|
||||
await db?.webPushOutbox.deleteMany();
|
||||
await db?.general.deleteMany({ where: { id: { in: fixtureGeneralIds } } });
|
||||
if (createdFixtureWorld) {
|
||||
await db?.worldState.deleteMany({ where: { id: fixtureWorldId } });
|
||||
}
|
||||
await disconnectDb?.();
|
||||
await deleteProfileRedisKeys();
|
||||
await redis?.disconnect();
|
||||
@@ -167,6 +669,31 @@ integration('game API security over HTTP transport', () => {
|
||||
restoreEnv();
|
||||
}, 30_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
|
||||
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||
await db.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.readModelOutbox.deleteMany();
|
||||
await db.webPushOutbox.deleteMany();
|
||||
receivedGatewayWebPushEvents = [];
|
||||
if (redis) {
|
||||
await redis.client.del(`sammo:${profileName}:read-model:revision`);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an authenticated query from a POST JSON body', async () => {
|
||||
const accessToken = await createAccessToken('json-query-body', {});
|
||||
const general = await requestTrpc('general.me', {
|
||||
@@ -190,30 +717,32 @@ integration('game API security over HTTP transport', () => {
|
||||
it.each([
|
||||
{
|
||||
label: 'global suspension',
|
||||
sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' },
|
||||
sanctions: () => ({ suspendedUntil: '2099-01-01T00:00:00.000Z' }),
|
||||
},
|
||||
{
|
||||
label: 'instance game restriction',
|
||||
sanctions: {
|
||||
sanctions: () => ({
|
||||
serverRestrictions: {
|
||||
[profileName]: {
|
||||
blockedFeatures: ['game'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'profile-id wildcard restriction',
|
||||
sanctions: {
|
||||
sanctions: () => ({
|
||||
serverRestrictions: {
|
||||
[profileId]: {
|
||||
blockedFeatures: ['*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
])('blocks an authenticated game API request for $label', async ({ label, sanctions }) => {
|
||||
const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions);
|
||||
const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions());
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
const blocked = await requestTrpc('general.me', { accessToken });
|
||||
|
||||
expect(blocked.response.status).toBe(403);
|
||||
@@ -224,6 +753,8 @@ integration('game API security over HTTP transport', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -243,6 +774,7 @@ integration('game API security over HTTP transport', () => {
|
||||
},
|
||||
])('allows non-message APIs but blocks message send for $label', async ({ label, sanctions }) => {
|
||||
const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions);
|
||||
const idempotencyKey = `${mutationRequestPrefix}message-${label.replaceAll(' ', '-')}`;
|
||||
const general = await requestTrpc('general.me', { accessToken });
|
||||
expect(general.response.status).toBe(200);
|
||||
expect(general.body).toMatchObject({
|
||||
@@ -254,6 +786,9 @@ integration('game API security over HTTP transport', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const telemetryBefore = await readAccessTelemetryState();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
|
||||
const message = await requestTrpc('messages.send', {
|
||||
method: 'POST',
|
||||
@@ -263,6 +798,7 @@ integration('game API security over HTTP transport', () => {
|
||||
text: '차단되어야 하는 메시지',
|
||||
},
|
||||
accessToken,
|
||||
idempotencyKey,
|
||||
});
|
||||
expect(message.response.status).toBe(403);
|
||||
expect(message.body).toMatchObject({
|
||||
@@ -272,6 +808,59 @@ integration('game API security over HTTP transport', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await expectApiInputEvent(idempotencyKey, 'messages.send', {
|
||||
actorUserId: userId,
|
||||
status: 'FAILED',
|
||||
});
|
||||
expect(telemetryBefore).toEqual({ periods: [], generals: [], accessLogs: [] });
|
||||
const telemetryAfter = await readAccessTelemetryState();
|
||||
expect(telemetryAfter.periods).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
worldStateId: fixtureWorldId,
|
||||
year: 190,
|
||||
month: 1,
|
||||
startedAt: expect.any(Date),
|
||||
lastRefresh: expect.any(Date),
|
||||
refresh: 1,
|
||||
online: 1,
|
||||
},
|
||||
]);
|
||||
const trafficPeriod = telemetryAfter.periods[0];
|
||||
if (!trafficPeriod) throw new Error('message access did not create its traffic period');
|
||||
expect(telemetryAfter.generals).toEqual([
|
||||
{
|
||||
periodId: trafficPeriod.id,
|
||||
generalId,
|
||||
userId,
|
||||
refresh: 1,
|
||||
lastRefresh: trafficPeriod.lastRefresh,
|
||||
},
|
||||
]);
|
||||
expect(telemetryAfter.accessLogs).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: trafficPeriod.lastRefresh,
|
||||
lastActionAt: null,
|
||||
refresh: 1,
|
||||
refreshTotal: 1,
|
||||
refreshScore: 1,
|
||||
refreshScoreTotal: 1,
|
||||
},
|
||||
]);
|
||||
expect(trafficPeriod.lastRefresh.getTime()).toBeGreaterThanOrEqual(trafficPeriod.startedAt.getTime());
|
||||
expect(
|
||||
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [
|
||||
'traffic_period',
|
||||
'traffic_period_general',
|
||||
'general_access_log',
|
||||
])
|
||||
).toEqual(
|
||||
withoutDurableTables(durableBefore, ['traffic_period', 'traffic_period_general', 'general_access_log'])
|
||||
);
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
});
|
||||
|
||||
it('rejects a restricted signed gateway token before issuing a game access token', async () => {
|
||||
@@ -285,6 +874,8 @@ integration('game API security over HTTP transport', () => {
|
||||
}),
|
||||
secret
|
||||
);
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
const blocked = await requestTrpc('auth.exchangeGatewayToken', {
|
||||
method: 'POST',
|
||||
input: { gatewayToken },
|
||||
@@ -298,8 +889,878 @@ integration('game API security over HTTP transport', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'missing bearer token',
|
||||
accessToken: async () => undefined,
|
||||
expectedStatus: 401,
|
||||
expectedCode: 'UNAUTHORIZED',
|
||||
},
|
||||
{
|
||||
label: 'unknown bearer token',
|
||||
accessToken: async () => 'unknown-security-http-token',
|
||||
expectedStatus: 401,
|
||||
expectedCode: 'UNAUTHORIZED',
|
||||
},
|
||||
{
|
||||
label: 'access token stored for another profile',
|
||||
accessToken: async () => {
|
||||
const otherProfileStore = new RedisAccessTokenStore(redis!.client, `${profileName}:other`);
|
||||
const created = await otherProfileStore.create(
|
||||
buildPayload('cross-profile', {}, userId, `${profileName}:other`)
|
||||
);
|
||||
if (!created) throw new Error('failed to seed the cross-profile access token');
|
||||
return created.accessToken;
|
||||
},
|
||||
expectedStatus: 401,
|
||||
expectedCode: 'UNAUTHORIZED',
|
||||
},
|
||||
{
|
||||
label: 'gameplay sanction',
|
||||
accessToken: () =>
|
||||
createAccessToken('matrix-sanction', {
|
||||
serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } },
|
||||
}),
|
||||
expectedStatus: 403,
|
||||
expectedCode: 'FORBIDDEN',
|
||||
},
|
||||
])(
|
||||
'rejects $label before creating an API input event or any durable/Redis gameplay side effect',
|
||||
async ({ label, accessToken, expectedStatus, expectedCode }) => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}auth-${label.replaceAll(' ', '-')}`;
|
||||
const token = await accessToken();
|
||||
const databaseBefore = await readReservedMutationState();
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
|
||||
const result = await requestReservedGeneral(token, idempotencyKey);
|
||||
|
||||
expect(result.response.status).toBe(expectedStatus);
|
||||
expect(result.body).toMatchObject({ error: { data: { code: expectedCode } } });
|
||||
expect(await readReservedMutationState()).toEqual(databaseBefore);
|
||||
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', null);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(ownershipDenialCases)(
|
||||
'keeps reserved queues, journal/outbox, ENGINE events, and profile Redis unchanged for $label general ownership denial',
|
||||
async ({ label, actorUserId, targetGeneralId }) => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}owner-${label.replaceAll(' ', '-')}`;
|
||||
const accessToken = await createAccessToken(`matrix-owner-${label.replaceAll(' ', '-')}`, {}, actorUserId);
|
||||
const databaseBefore = await readReservedMutationState();
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
|
||||
const result = await requestReservedGeneral(accessToken, idempotencyKey, targetGeneralId);
|
||||
|
||||
expect(result.response.status).toBe(403);
|
||||
expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } });
|
||||
expect(await readReservedMutationState()).toEqual(databaseBefore);
|
||||
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', {
|
||||
actorUserId,
|
||||
status: 'FAILED',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(ownershipDenialCases)(
|
||||
'keeps reserved queues, journal/outbox, ENGINE events, and profile Redis unchanged for $label nation ownership denial',
|
||||
async ({ label, actorUserId, targetGeneralId }) => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-owner-${label.replaceAll(' ', '-')}`;
|
||||
const accessToken = await createAccessToken(
|
||||
`matrix-nation-owner-${label.replaceAll(' ', '-')}`,
|
||||
{},
|
||||
actorUserId
|
||||
);
|
||||
const databaseBefore = await readReservedMutationState();
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
|
||||
const result = await requestReservedNation(accessToken, idempotencyKey, targetGeneralId);
|
||||
|
||||
expect(result.response.status).toBe(403);
|
||||
expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } });
|
||||
expect(await readReservedMutationState()).toEqual(databaseBefore);
|
||||
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||
actorUserId,
|
||||
status: 'FAILED',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the nation queue unchanged when an owned general is below the officer threshold', async () => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-non-officer`;
|
||||
const accessToken = await createAccessToken('matrix-nation-non-officer', {}, ordinaryUserId);
|
||||
const databaseBefore = await readReservedMutationState();
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
|
||||
const result = await requestReservedNation(accessToken, idempotencyKey, ordinaryGeneralId);
|
||||
|
||||
expect(result.response.status).toBe(403);
|
||||
expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } });
|
||||
expect(await readReservedMutationState()).toEqual(databaseBefore);
|
||||
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||
actorUserId: ordinaryUserId,
|
||||
status: 'FAILED',
|
||||
});
|
||||
});
|
||||
|
||||
it('commits an owned general reservation once with an authenticated actor and durable journal', async () => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}general-success`;
|
||||
const accessToken = await createAccessToken('matrix-general-success', {});
|
||||
const databaseBefore = await readReservedMutationState();
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const allowedTablesBefore = await readSuccessAllowedTableState();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
|
||||
const result = await requestReservedGeneral(accessToken, idempotencyKey);
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||
expect(
|
||||
await db.generalTurn.findMany({
|
||||
where: { generalId },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
|
||||
orderBy: { turnIdx: 'asc' },
|
||||
})
|
||||
).toEqual(
|
||||
Array.from({ length: 30 }, (_, turnIdx) => ({
|
||||
generalId,
|
||||
turnIdx,
|
||||
actionCode: '휴식',
|
||||
arg: {},
|
||||
}))
|
||||
);
|
||||
expect(await db.generalTurnRevision.findUnique({ where: { generalId } })).toMatchObject({
|
||||
revision: 1,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
});
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', {
|
||||
actorUserId: userId,
|
||||
status: 'SUCCEEDED',
|
||||
});
|
||||
expect(
|
||||
await db.inputEvent.count({
|
||||
where: { target: 'ENGINE', requestId: { startsWith: idempotencyKey } },
|
||||
})
|
||||
).toBe(0);
|
||||
await expect.poll(() => db.readModelOutbox.count()).toBe(1);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const row = await db.readModelOutbox.findFirst();
|
||||
return {
|
||||
delivered: row?.deliveredAt instanceof Date,
|
||||
attempts: row?.attempts ?? null,
|
||||
locked: row?.lockedAt instanceof Date,
|
||||
lastError: row?.lastError ?? null,
|
||||
};
|
||||
},
|
||||
{ timeout: 5_000, interval: 50 }
|
||||
)
|
||||
.toEqual({ delivered: true, attempts: 1, locked: false, lastError: null });
|
||||
const readModelRedisRevisionKey = `sammo:${profileName}:read-model:revision`;
|
||||
await expect
|
||||
.poll(() => redis!.client.get(readModelRedisRevisionKey), { timeout: 5_000, interval: 50 })
|
||||
.toBe('1');
|
||||
expect(
|
||||
await db.readModelRevision.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ domain: 'reserved.general', entityId: generalId },
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
],
|
||||
},
|
||||
select: { domain: true, entityId: true, revision: true },
|
||||
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||
})
|
||||
).toEqual([
|
||||
{ domain: 'dashboard.global', entityId: 0, revision: 1n },
|
||||
{ domain: 'reserved.general', entityId: generalId, revision: 1n },
|
||||
]);
|
||||
expect(await db.readModelOutbox.findMany({ select: { payload: true } })).toEqual([
|
||||
{
|
||||
payload: {
|
||||
version: 1,
|
||||
changes: [
|
||||
['dashboard.global', 0, '1'],
|
||||
['reserved.general', generalId, '1'],
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const databaseAfter = await readReservedMutationState();
|
||||
expect(databaseAfter.generals).toEqual(databaseBefore.generals);
|
||||
expect(databaseAfter.generalTurns).toEqual(
|
||||
Array.from({ length: 30 }, (_, turnIdx) => ({
|
||||
generalId,
|
||||
turnIdx,
|
||||
actionCode: '휴식',
|
||||
arg: {},
|
||||
}))
|
||||
);
|
||||
expect(databaseAfter.generalTurnRevisions).toEqual([
|
||||
{ generalId, revision: 1, leaseOwner: null, leaseExpiresAt: null },
|
||||
]);
|
||||
expect(databaseAfter.nationTurns).toEqual(databaseBefore.nationTurns);
|
||||
expect(databaseAfter.nationTurnRevisions).toEqual(databaseBefore.nationTurnRevisions);
|
||||
expect(databaseAfter.messages).toEqual(databaseBefore.messages);
|
||||
expect(databaseAfter.logs).toEqual(databaseBefore.logs);
|
||||
expect(databaseAfter.engineInputEvents).toEqual(databaseBefore.engineInputEvents);
|
||||
expect(databaseAfter.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount);
|
||||
expect(databaseAfter.eventCount).toBe(databaseBefore.eventCount);
|
||||
expect(databaseAfter.auctionCount).toBe(databaseBefore.auctionCount);
|
||||
expect(databaseAfter.auctionBidCount).toBe(databaseBefore.auctionBidCount);
|
||||
expectSingleActorActivity(databaseAfter.generalAccessLogs);
|
||||
const allowedTablesAfter = await readSuccessAllowedTableState();
|
||||
expect(allowedTablesBefore.readModelOutbox).toEqual([]);
|
||||
expect(allowedTablesAfter.generalTurns.filter((row) => row.generalId !== generalId)).toEqual(
|
||||
allowedTablesBefore.generalTurns.filter((row) => row.generalId !== generalId)
|
||||
);
|
||||
const committedGeneralTurns = allowedTablesAfter.generalTurns.filter((row) => row.generalId === generalId);
|
||||
expect(
|
||||
committedGeneralTurns.map(({ generalId: rowGeneralId, turnIdx, actionCode, arg }) => ({
|
||||
generalId: rowGeneralId,
|
||||
turnIdx,
|
||||
actionCode,
|
||||
arg,
|
||||
}))
|
||||
).toEqual(databaseAfter.generalTurns);
|
||||
expect(new Set(committedGeneralTurns.map(({ id }) => id)).size).toBe(30);
|
||||
expect(committedGeneralTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
||||
expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual(
|
||||
allowedTablesBefore.generalTurnRevisions.filter((row) => row.generalId !== generalId)
|
||||
);
|
||||
expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId === generalId)).toEqual([
|
||||
{
|
||||
generalId,
|
||||
revision: 1,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== generalId)).toEqual(
|
||||
allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== generalId)
|
||||
);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: null,
|
||||
lastActionAt: expect.any(Date),
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 0,
|
||||
},
|
||||
]);
|
||||
const expectedReadModelKeys = new Set([`dashboard.global:0`, `reserved.general:${generalId}`]);
|
||||
expect(
|
||||
allowedTablesAfter.readModelRevisions.filter(
|
||||
({ domain, entityId }) => !expectedReadModelKeys.has(`${domain}:${entityId}`)
|
||||
)
|
||||
).toEqual(
|
||||
allowedTablesBefore.readModelRevisions.filter(
|
||||
({ domain, entityId }) => !expectedReadModelKeys.has(`${domain}:${entityId}`)
|
||||
)
|
||||
);
|
||||
expect(
|
||||
allowedTablesAfter.readModelRevisions.filter(({ domain, entityId }) =>
|
||||
expectedReadModelKeys.has(`${domain}:${entityId}`)
|
||||
)
|
||||
).toEqual([
|
||||
{ domain: 'dashboard.global', entityId: 0, revision: 1n, updatedAt: expect.any(Date) },
|
||||
{ domain: 'reserved.general', entityId: generalId, revision: 1n, updatedAt: expect.any(Date) },
|
||||
]);
|
||||
expect(allowedTablesAfter.readModelOutbox).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
payload: {
|
||||
version: 1,
|
||||
changes: [
|
||||
['dashboard.global', 0, '1'],
|
||||
['reserved.general', generalId, '1'],
|
||||
],
|
||||
},
|
||||
attempts: 1,
|
||||
availableAt: expect.any(Date),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
deliveredAt: expect.any(Date),
|
||||
lastError: null,
|
||||
createdAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const deliveredOutbox = allowedTablesAfter.readModelOutbox[0];
|
||||
if (!deliveredOutbox?.deliveredAt) throw new Error('read-model outbox was not delivered');
|
||||
expect(typeof deliveredOutbox.id).toBe('bigint');
|
||||
expect(deliveredOutbox.id).toBeGreaterThan(0n);
|
||||
expect(deliveredOutbox.deliveredAt.getTime()).toBeGreaterThanOrEqual(deliveredOutbox.createdAt.getTime());
|
||||
expect(
|
||||
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [
|
||||
'general_turn',
|
||||
'general_turn_revision',
|
||||
'general_access_log',
|
||||
'read_model_revision',
|
||||
'read_model_outbox',
|
||||
])
|
||||
).toEqual(
|
||||
withoutDurableTables(durableBefore, [
|
||||
'general_turn',
|
||||
'general_turn_revision',
|
||||
'general_access_log',
|
||||
'read_model_revision',
|
||||
'read_model_outbox',
|
||||
])
|
||||
);
|
||||
expect(redisBefore.some(([key]) => key === readModelRedisRevisionKey)).toBe(false);
|
||||
const expectedRedisAfter: Array<[string, string | null]> = [...redisBefore, [readModelRedisRevisionKey, '1']];
|
||||
expectedRedisAfter.sort(([left], [right]) => left.localeCompare(right));
|
||||
expect(await readRealtimeRedisState()).toEqual(expectedRedisAfter);
|
||||
}, 15_000);
|
||||
|
||||
it('accepts the minimum officer level into its own nation queue partition over HTTP', async () => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-minimum-officer-success`;
|
||||
const accessToken = await createAccessToken('matrix-nation-minimum-officer-success', {}, sameNationUserId);
|
||||
const databaseBefore = await readReservedMutationState();
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const allowedTablesBefore = await readSuccessAllowedTableState();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
|
||||
const result = await requestReservedNation(accessToken, idempotencyKey, sameNationGeneralId);
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||
const expectedTurns = Array.from({ length: 12 }, (_, turnIdx) => ({
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 5,
|
||||
turnIdx,
|
||||
actionCode: '휴식',
|
||||
arg: {},
|
||||
}));
|
||||
expect(
|
||||
await db.nationTurn.findMany({
|
||||
where: { nationId: ownerNationId, officerLevel: 5 },
|
||||
select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true },
|
||||
orderBy: { turnIdx: 'asc' },
|
||||
})
|
||||
).toEqual(expectedTurns);
|
||||
expect(
|
||||
await db.nationTurnRevision.findUnique({
|
||||
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 5 } },
|
||||
})
|
||||
).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null });
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||
actorUserId: sameNationUserId,
|
||||
status: 'SUCCEEDED',
|
||||
});
|
||||
|
||||
const committed = await readReservedMutationState();
|
||||
expect(committed.generals).toEqual(databaseBefore.generals);
|
||||
expect(committed.generalTurns).toEqual(databaseBefore.generalTurns);
|
||||
expect(committed.generalTurnRevisions).toEqual(databaseBefore.generalTurnRevisions);
|
||||
expect(committed.nationTurns).toEqual(expectedTurns);
|
||||
expect(committed.nationTurnRevisions).toEqual([
|
||||
{
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 5,
|
||||
revision: 1,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
},
|
||||
]);
|
||||
expect(committed.generalAccessLogs).toEqual([
|
||||
{
|
||||
generalId: sameNationGeneralId,
|
||||
userId: sameNationUserId,
|
||||
lastRefresh: null,
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 0,
|
||||
lastActionAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(committed.readModelRevisions).toEqual(databaseBefore.readModelRevisions);
|
||||
expect(committed.readModelOutbox).toEqual(databaseBefore.readModelOutbox);
|
||||
expect(committed.messages).toEqual(databaseBefore.messages);
|
||||
expect(committed.logs).toEqual(databaseBefore.logs);
|
||||
expect(committed.engineInputEvents).toEqual(databaseBefore.engineInputEvents);
|
||||
expect(committed.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount);
|
||||
expect(committed.eventCount).toBe(databaseBefore.eventCount);
|
||||
expect(committed.auctionCount).toBe(databaseBefore.auctionCount);
|
||||
expect(committed.auctionBidCount).toBe(databaseBefore.auctionBidCount);
|
||||
|
||||
const allowedTablesAfter = await readSuccessAllowedTableState();
|
||||
expect(
|
||||
allowedTablesAfter.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 5)
|
||||
).toEqual(
|
||||
allowedTablesBefore.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 5)
|
||||
);
|
||||
const committedNationTurns = allowedTablesAfter.nationTurns.filter(
|
||||
(row) => row.nationId === ownerNationId && row.officerLevel === 5
|
||||
);
|
||||
expect(
|
||||
committedNationTurns.map(({ nationId, officerLevel, turnIdx, actionCode, arg }) => ({
|
||||
nationId,
|
||||
officerLevel,
|
||||
turnIdx,
|
||||
actionCode,
|
||||
arg,
|
||||
}))
|
||||
).toEqual(expectedTurns);
|
||||
expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12);
|
||||
expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
||||
expect(
|
||||
allowedTablesAfter.nationTurnRevisions.filter(
|
||||
(row) => row.nationId !== ownerNationId || row.officerLevel !== 5
|
||||
)
|
||||
).toEqual(
|
||||
allowedTablesBefore.nationTurnRevisions.filter(
|
||||
(row) => row.nationId !== ownerNationId || row.officerLevel !== 5
|
||||
)
|
||||
);
|
||||
expect(
|
||||
allowedTablesAfter.nationTurnRevisions.filter(
|
||||
(row) => row.nationId === ownerNationId && row.officerLevel === 5
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 5,
|
||||
revision: 1,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== sameNationGeneralId)).toEqual(
|
||||
allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== sameNationGeneralId)
|
||||
);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === sameNationGeneralId)).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
generalId: sameNationGeneralId,
|
||||
userId: sameNationUserId,
|
||||
lastRefresh: null,
|
||||
lastActionAt: expect.any(Date),
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 0,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [
|
||||
'nation_turn',
|
||||
'nation_turn_revision',
|
||||
'general_access_log',
|
||||
])
|
||||
).toEqual(withoutDurableTables(durableBefore, ['nation_turn', 'nation_turn_revision', 'general_access_log']));
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
}, 15_000);
|
||||
|
||||
it('commits an owned officer nation reservation and rejects duplicate idempotency replay without a second queue mutation', async () => {
|
||||
const idempotencyKey = `${mutationRequestPrefix}nation-success`;
|
||||
const accessToken = await createAccessToken('matrix-nation-success', {});
|
||||
const databaseBefore = await readReservedMutationState();
|
||||
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const allowedTablesBefore = await readSuccessAllowedTableState();
|
||||
const redisBefore = await readRealtimeRedisState();
|
||||
|
||||
const first = await requestReservedNation(accessToken, idempotencyKey, generalId);
|
||||
expect(first.response.status).toBe(200);
|
||||
expect(first.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||
expect(
|
||||
await db.nationTurn.findMany({
|
||||
where: { nationId: ownerNationId, officerLevel: 12 },
|
||||
select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true },
|
||||
orderBy: { turnIdx: 'asc' },
|
||||
})
|
||||
).toEqual(
|
||||
Array.from({ length: 12 }, (_, turnIdx) => ({
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 12,
|
||||
turnIdx,
|
||||
actionCode: '휴식',
|
||||
arg: {},
|
||||
}))
|
||||
);
|
||||
expect(
|
||||
await db.nationTurnRevision.findUnique({
|
||||
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
|
||||
})
|
||||
).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null });
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||
actorUserId: userId,
|
||||
status: 'SUCCEEDED',
|
||||
});
|
||||
|
||||
const committed = await readReservedMutationState();
|
||||
expect(committed.generals).toEqual(databaseBefore.generals);
|
||||
expect(committed.generalTurns).toEqual(databaseBefore.generalTurns);
|
||||
expect(committed.generalTurnRevisions).toEqual(databaseBefore.generalTurnRevisions);
|
||||
expect(committed.nationTurns).toEqual(
|
||||
Array.from({ length: 12 }, (_, turnIdx) => ({
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 12,
|
||||
turnIdx,
|
||||
actionCode: '휴식',
|
||||
arg: {},
|
||||
}))
|
||||
);
|
||||
expect(committed.nationTurnRevisions).toEqual([
|
||||
{
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 12,
|
||||
revision: 1,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
},
|
||||
]);
|
||||
expect(committed.readModelRevisions).toEqual(databaseBefore.readModelRevisions);
|
||||
expect(committed.readModelOutbox).toEqual(databaseBefore.readModelOutbox);
|
||||
expect(committed.messages).toEqual(databaseBefore.messages);
|
||||
expect(committed.logs).toEqual(databaseBefore.logs);
|
||||
expect(committed.engineInputEvents).toEqual(databaseBefore.engineInputEvents);
|
||||
expect(committed.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount);
|
||||
expect(committed.eventCount).toBe(databaseBefore.eventCount);
|
||||
expect(committed.auctionCount).toBe(databaseBefore.auctionCount);
|
||||
expect(committed.auctionBidCount).toBe(databaseBefore.auctionBidCount);
|
||||
expectSingleActorActivity(committed.generalAccessLogs);
|
||||
const allowedTablesAfter = await readSuccessAllowedTableState();
|
||||
expect(
|
||||
allowedTablesAfter.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 12)
|
||||
).toEqual(
|
||||
allowedTablesBefore.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 12)
|
||||
);
|
||||
const committedNationTurns = allowedTablesAfter.nationTurns.filter(
|
||||
(row) => row.nationId === ownerNationId && row.officerLevel === 12
|
||||
);
|
||||
expect(
|
||||
committedNationTurns.map(({ nationId, officerLevel, turnIdx, actionCode, arg }) => ({
|
||||
nationId,
|
||||
officerLevel,
|
||||
turnIdx,
|
||||
actionCode,
|
||||
arg,
|
||||
}))
|
||||
).toEqual(committed.nationTurns);
|
||||
expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12);
|
||||
expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
||||
expect(
|
||||
allowedTablesAfter.nationTurnRevisions.filter(
|
||||
(row) => row.nationId !== ownerNationId || row.officerLevel !== 12
|
||||
)
|
||||
).toEqual(
|
||||
allowedTablesBefore.nationTurnRevisions.filter(
|
||||
(row) => row.nationId !== ownerNationId || row.officerLevel !== 12
|
||||
)
|
||||
);
|
||||
expect(
|
||||
allowedTablesAfter.nationTurnRevisions.filter(
|
||||
(row) => row.nationId === ownerNationId && row.officerLevel === 12
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
nationId: ownerNationId,
|
||||
officerLevel: 12,
|
||||
revision: 1,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== generalId)).toEqual(
|
||||
allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== generalId)
|
||||
);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: null,
|
||||
lastActionAt: expect.any(Date),
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 0,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [
|
||||
'nation_turn',
|
||||
'nation_turn_revision',
|
||||
'general_access_log',
|
||||
])
|
||||
).toEqual(withoutDurableTables(durableBefore, ['nation_turn', 'nation_turn_revision', 'general_access_log']));
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
const replayDurableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const replayRedisBefore = await readRealtimeRedisState();
|
||||
const replayJournalBefore = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
|
||||
});
|
||||
const replay = await requestReservedNation(accessToken, idempotencyKey, generalId);
|
||||
expect(replay.response.status).toBe(409);
|
||||
expect(replay.body).toMatchObject({ error: { data: { code: 'CONFLICT' } } });
|
||||
expect(await readReservedMutationState()).toEqual(committed);
|
||||
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(replayDurableBefore);
|
||||
expect(await readRealtimeRedisState()).toEqual(replayRedisBefore);
|
||||
expect(
|
||||
await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
|
||||
})
|
||||
).toEqual(replayJournalBefore);
|
||||
expect(await db.inputEvent.count({ where: { requestId: `${idempotencyKey}:turns.reserved.setNation` } })).toBe(
|
||||
1
|
||||
);
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||
actorUserId: userId,
|
||||
status: 'SUCCEEDED',
|
||||
});
|
||||
});
|
||||
|
||||
it('delivers a non-UTC-session Web Push outbox row once with its original instant', async () => {
|
||||
const eventId = `security-http-web-push-${process.pid}`;
|
||||
const beforeInsert = Date.now();
|
||||
await db.$transaction(async (transaction) => {
|
||||
await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`;
|
||||
await expect(
|
||||
enqueueWebPushOutboxEvents(transaction, [
|
||||
{
|
||||
eventId,
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
userIds: [userId],
|
||||
},
|
||||
])
|
||||
).resolves.toBe(1);
|
||||
});
|
||||
const afterInsert = Date.now();
|
||||
|
||||
await expect.poll(() => receivedGatewayWebPushEvents.length, { timeout: 6_000, interval: 50 }).toBe(1);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const row = await db.webPushOutbox.findUniqueOrThrow({ where: { eventId } });
|
||||
return {
|
||||
attempts: row.attempts,
|
||||
locked: row.lockedAt instanceof Date,
|
||||
lockOwner: row.lockOwner,
|
||||
delivered: row.deliveredAt instanceof Date,
|
||||
lastError: row.lastError,
|
||||
};
|
||||
},
|
||||
{ timeout: 6_000, interval: 50 }
|
||||
)
|
||||
.toEqual({ attempts: 1, locked: false, lockOwner: null, delivered: true, lastError: null });
|
||||
const delivered = await db.webPushOutbox.findUniqueOrThrow({ where: { eventId } });
|
||||
expect(delivered).toMatchObject({
|
||||
attempts: 1,
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
deliveredAt: expect.any(Date),
|
||||
lastError: null,
|
||||
});
|
||||
const [storedInstant] = await db.$queryRaw<Array<{ createdMs: number }>>`
|
||||
SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision AS "createdMs"
|
||||
FROM "web_push_outbox"
|
||||
WHERE "event_id" = ${eventId}
|
||||
`;
|
||||
if (!storedInstant) throw new Error('web push outbox instant was not persisted');
|
||||
|
||||
const [received] = receivedGatewayWebPushEvents;
|
||||
expect(received?.internalToken).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(received?.body).toEqual({
|
||||
version: 1,
|
||||
eventId: `game:${profileName}:${eventId}`,
|
||||
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||
profileName,
|
||||
userIds: [userId],
|
||||
occurredAt: expect.any(String),
|
||||
});
|
||||
const occurredAt = Date.parse((received?.body as { occurredAt: string }).occurredAt);
|
||||
expect(occurredAt).toBeGreaterThanOrEqual(beforeInsert - 1_000);
|
||||
expect(occurredAt).toBeLessThanOrEqual(afterInsert + 1_000);
|
||||
expect(Math.abs(occurredAt - storedInstant.createdMs)).toBeLessThanOrEqual(1);
|
||||
}, 10_000);
|
||||
|
||||
it('keeps Web Push due, lease, and prune boundaries in UTC wall time under a KST database session', async () => {
|
||||
const eventIds = {
|
||||
future: `security-http-web-push-future-${process.pid}`,
|
||||
recentLock: `security-http-web-push-recent-lock-${process.pid}`,
|
||||
staleLock: `security-http-web-push-stale-lock-${process.pid}`,
|
||||
retainedDelivery: `security-http-web-push-retained-delivery-${process.pid}`,
|
||||
prunedDelivery: `security-http-web-push-pruned-delivery-${process.pid}`,
|
||||
} as const;
|
||||
const [databaseSession] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
||||
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||
`;
|
||||
expect(databaseSession?.timeZone).toBe('Asia/Seoul');
|
||||
|
||||
await db.$transaction(async (transaction) => {
|
||||
await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`;
|
||||
const [transactionSession] = await transaction.$queryRaw<Array<{ timeZone: string }>>`
|
||||
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||
`;
|
||||
expect(transactionSession?.timeZone).toBe('Asia/Seoul');
|
||||
await transaction.$executeRaw`
|
||||
INSERT INTO "web_push_outbox" (
|
||||
"event_id",
|
||||
"event_type",
|
||||
"user_ids",
|
||||
"attempts",
|
||||
"available_at",
|
||||
"locked_at",
|
||||
"lock_owner",
|
||||
"delivered_at",
|
||||
"created_at"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
${eventIds.future},
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
ARRAY[${userId}]::text[],
|
||||
0,
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '2 hours',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
),
|
||||
(
|
||||
${eventIds.recentLock},
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
ARRAY[${userId}]::text[],
|
||||
4,
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 minute',
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 second',
|
||||
'previous-owner',
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
),
|
||||
(
|
||||
${eventIds.staleLock},
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
ARRAY[${userId}]::text[],
|
||||
2,
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 minute',
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '31 seconds',
|
||||
'previous-owner',
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
),
|
||||
(
|
||||
${eventIds.retainedDelivery},
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
ARRAY[${userId}]::text[],
|
||||
1,
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '20 hours',
|
||||
NULL,
|
||||
NULL,
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '20 hours',
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '21 hours'
|
||||
),
|
||||
(
|
||||
${eventIds.prunedDelivery},
|
||||
'PRIVATE_MESSAGE_RECEIVED',
|
||||
ARRAY[${userId}]::text[],
|
||||
1,
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '25 hours',
|
||||
NULL,
|
||||
NULL,
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '25 hours',
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '26 hours'
|
||||
)
|
||||
`;
|
||||
});
|
||||
|
||||
const boundaryWorker = new WebPushOutboxWorker(db, process.env.GATEWAY_INTERNAL_API_URL!, secret, profileName, {
|
||||
intervalMs: 60_000,
|
||||
});
|
||||
boundaryWorker.start();
|
||||
await boundaryWorker.stop();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const staleLock = await db.webPushOutbox.findUnique({
|
||||
where: { eventId: eventIds.staleLock },
|
||||
});
|
||||
return staleLock
|
||||
? {
|
||||
attempts: staleLock.attempts,
|
||||
lockedAt: staleLock.lockedAt,
|
||||
lockOwner: staleLock.lockOwner,
|
||||
delivered: staleLock.deliveredAt instanceof Date,
|
||||
lastError: staleLock.lastError,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
{ timeout: 6_000, interval: 50 }
|
||||
)
|
||||
.toEqual({ attempts: 3, lockedAt: null, lockOwner: null, delivered: true, lastError: null });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
db.webPushOutbox.count({
|
||||
where: { eventId: eventIds.prunedDelivery },
|
||||
}),
|
||||
{ timeout: 6_000, interval: 50 }
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
const remaining = await db.webPushOutbox.findMany({
|
||||
where: { eventId: { in: Object.values(eventIds) } },
|
||||
orderBy: { eventId: 'asc' },
|
||||
});
|
||||
const byEventId = new Map(remaining.map((row) => [row.eventId, row]));
|
||||
expect(byEventId.get(eventIds.future)).toMatchObject({
|
||||
attempts: 0,
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
deliveredAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
expect(byEventId.get(eventIds.recentLock)).toMatchObject({
|
||||
attempts: 4,
|
||||
lockedAt: expect.any(Date),
|
||||
lockOwner: 'previous-owner',
|
||||
deliveredAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
expect(byEventId.get(eventIds.retainedDelivery)).toMatchObject({
|
||||
attempts: 1,
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
deliveredAt: expect.any(Date),
|
||||
lastError: null,
|
||||
});
|
||||
expect(byEventId.has(eventIds.prunedDelivery)).toBe(false);
|
||||
expect(receivedGatewayWebPushEvents).toHaveLength(1);
|
||||
expect(receivedGatewayWebPushEvents[0]?.body).toMatchObject({
|
||||
eventId: `game:${profileName}:${eventIds.staleLock}`,
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
// Flush invalidates every token issued before the user watermark. Keep it
|
||||
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||
// by the transport authorization matrix above.
|
||||
it('invalidates an existing access token after a gateway flush event', async () => {
|
||||
const accessToken = await createAccessToken('flush', {});
|
||||
expect((await requestTrpc('general.me', { accessToken })).response.status).toBe(200);
|
||||
|
||||
@@ -15,8 +15,10 @@ PROFILE_SEED_DATABASE_URL core
|
||||
PROFILE_LOCK_SECONDARY_DATABASE_URL core
|
||||
READ_MODEL_JOURNAL_DATABASE_URL read_model_journal
|
||||
RESERVED_TURN_DATABASE_URL core
|
||||
SECURITY_TRANSPORT_DATABASE_URL security_transport
|
||||
SELECT_POOL_DATABASE_URL select_pool
|
||||
TURN_DAEMON_LEASE_DATABASE_URL core
|
||||
TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL reference_command_durable_matrix
|
||||
TURN_DIFFERENTIAL_DATABASE_URL core
|
||||
TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle
|
||||
WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway
|
||||
|
||||
|
@@ -127,6 +127,11 @@ const readString = (record: Record<string, unknown>, key: string): string | null
|
||||
return typeof value === 'string' ? value : null;
|
||||
};
|
||||
|
||||
const readNullableCode = (record: Record<string, unknown>, key: string): string | null => {
|
||||
const value = readString(record, key);
|
||||
return value && value !== 'None' ? value : null;
|
||||
};
|
||||
|
||||
const readCommandInteger = (value: unknown, field: string, fallback: number | null): number | null => {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
@@ -388,13 +393,13 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
maxBelong: readNumber(meta, 'max_belong'),
|
||||
maxDomesticCritical: readNumber(meta, 'max_domestic_critical'),
|
||||
betray: row.betray,
|
||||
personality: row.personality ?? null,
|
||||
specialDomestic: row.specialDomestic ?? null,
|
||||
specialWar: row.specialWar ?? null,
|
||||
itemHorse: row.itemHorse ?? null,
|
||||
itemWeapon: row.itemWeapon ?? null,
|
||||
itemBook: row.itemBook ?? null,
|
||||
itemExtra: row.itemExtra ?? null,
|
||||
personality: readNullableCode(row, 'personality') ?? readNullableCode(row, 'personalCode'),
|
||||
specialDomestic: readNullableCode(row, 'specialDomestic') ?? readNullableCode(row, 'specialCode'),
|
||||
specialWar: readNullableCode(row, 'specialWar') ?? readNullableCode(row, 'special2Code'),
|
||||
itemHorse: readNullableCode(row, 'itemHorse') ?? readNullableCode(row, 'horseCode'),
|
||||
itemWeapon: readNullableCode(row, 'itemWeapon') ?? readNullableCode(row, 'weaponCode'),
|
||||
itemBook: readNullableCode(row, 'itemBook') ?? readNullableCode(row, 'bookCode'),
|
||||
itemExtra: readNullableCode(row, 'itemExtra') ?? readNullableCode(row, 'itemCode'),
|
||||
picture: row.picture ?? null,
|
||||
imageServer: readNumber(row, 'imageServer'),
|
||||
injury: row.injury,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { GeneralTurnCommandKey, NationTurnCommandKey } from '@sammo-ts/logic';
|
||||
|
||||
export type CommandDurabilityRisk = 'R1' | 'R2' | 'R3';
|
||||
export type CommandDurabilityScope = 'general' | 'nation';
|
||||
|
||||
/**
|
||||
* R1 mutates the actor or one aggregate, R2 crosses an actor/aggregate boundary,
|
||||
* and R3 can fan out, create/delete entities, fight, or mutate relationships.
|
||||
* The typed records deliberately fail compilation when a command is added
|
||||
* without an explicit durability classification.
|
||||
*/
|
||||
export const generalCommandDurabilityRisk = {
|
||||
che_거병: 'R3',
|
||||
che_임관: 'R3',
|
||||
che_랜덤임관: 'R3',
|
||||
che_귀환: 'R1',
|
||||
che_등용수락: 'R3',
|
||||
che_장수대상임관: 'R3',
|
||||
che_건국: 'R3',
|
||||
cr_건국: 'R3',
|
||||
che_무작위건국: 'R3',
|
||||
che_훈련: 'R1',
|
||||
cr_맹훈련: 'R1',
|
||||
che_전투태세: 'R1',
|
||||
che_단련: 'R1',
|
||||
che_숙련전환: 'R1',
|
||||
che_사기진작: 'R1',
|
||||
che_요양: 'R1',
|
||||
che_견문: 'R1',
|
||||
che_장비매매: 'R2',
|
||||
che_내정특기초기화: 'R1',
|
||||
che_전투특기초기화: 'R1',
|
||||
che_출병: 'R3',
|
||||
che_주민선정: 'R2',
|
||||
che_정착장려: 'R2',
|
||||
che_농지개간: 'R2',
|
||||
che_상업투자: 'R2',
|
||||
che_기술연구: 'R2',
|
||||
che_치안강화: 'R2',
|
||||
che_수비강화: 'R2',
|
||||
che_성벽보수: 'R2',
|
||||
che_화계: 'R3',
|
||||
che_집합: 'R3',
|
||||
che_인재탐색: 'R3',
|
||||
che_징병: 'R2',
|
||||
che_모병: 'R2',
|
||||
che_소집해제: 'R2',
|
||||
che_군량매매: 'R2',
|
||||
che_물자조달: 'R2',
|
||||
che_헌납: 'R2',
|
||||
che_이동: 'R3',
|
||||
che_접경귀환: 'R1',
|
||||
che_방랑: 'R3',
|
||||
che_하야: 'R3',
|
||||
che_은퇴: 'R3',
|
||||
che_선양: 'R3',
|
||||
che_모반시도: 'R3',
|
||||
che_증여: 'R3',
|
||||
che_해산: 'R3',
|
||||
che_등용: 'R3',
|
||||
che_첩보: 'R2',
|
||||
che_파괴: 'R3',
|
||||
che_선동: 'R3',
|
||||
che_탈취: 'R3',
|
||||
che_NPC능동: 'R1',
|
||||
che_강행: 'R3',
|
||||
휴식: 'R1',
|
||||
} as const satisfies Record<GeneralTurnCommandKey, CommandDurabilityRisk>;
|
||||
|
||||
export const nationCommandDurabilityRisk = {
|
||||
휴식: 'R1',
|
||||
che_포상: 'R3',
|
||||
che_부대탈퇴지시: 'R3',
|
||||
che_발령: 'R3',
|
||||
che_선전포고: 'R3',
|
||||
che_종전제의: 'R3',
|
||||
che_불가침제의: 'R3',
|
||||
che_불가침파기제의: 'R3',
|
||||
che_의병모집: 'R3',
|
||||
che_허보: 'R3',
|
||||
che_필사즉생: 'R3',
|
||||
che_백성동원: 'R3',
|
||||
che_이호경식: 'R3',
|
||||
che_수몰: 'R3',
|
||||
che_급습: 'R3',
|
||||
che_피장파장: 'R3',
|
||||
che_초토화: 'R3',
|
||||
che_천도: 'R2',
|
||||
che_국호변경: 'R1',
|
||||
che_무작위수도이전: 'R3',
|
||||
che_국기변경: 'R1',
|
||||
che_증축: 'R2',
|
||||
che_감축: 'R2',
|
||||
cr_인구이동: 'R3',
|
||||
che_몰수: 'R3',
|
||||
che_물자원조: 'R3',
|
||||
event_원융노병연구: 'R1',
|
||||
event_화시병연구: 'R1',
|
||||
event_음귀병연구: 'R1',
|
||||
event_대검병연구: 'R1',
|
||||
event_화륜차연구: 'R1',
|
||||
event_산저병연구: 'R1',
|
||||
event_극병연구: 'R1',
|
||||
event_상병연구: 'R1',
|
||||
event_무희연구: 'R1',
|
||||
} as const satisfies Record<NationTurnCommandKey, CommandDurabilityRisk>;
|
||||
|
||||
export type CommandDurabilityFacet =
|
||||
| 'single-actor'
|
||||
| 'local-aggregate'
|
||||
| 'cross-entity'
|
||||
| 'placement-topology'
|
||||
| 'hostile-rng-destructive'
|
||||
| 'diplomacy-strategy'
|
||||
| 'entity-creation-fanout'
|
||||
| 'retirement-archive'
|
||||
| 'multi-turn-research';
|
||||
|
||||
export interface CommandDurabilityEvidence {
|
||||
scope: CommandDurabilityScope;
|
||||
risk: CommandDurabilityRisk;
|
||||
command: GeneralTurnCommandKey | NationTurnCommandKey;
|
||||
facet: CommandDurabilityFacet;
|
||||
testFile: string;
|
||||
matrixRepresentative: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a representative durable matrix, not a claim that all 90 commands
|
||||
* execute against PostgreSQL. Every scope/risk cell runs in the dedicated
|
||||
* matrix; high-risk battle, creation, and destructive paths retain their
|
||||
* stronger dedicated rollback/reload suites.
|
||||
*/
|
||||
export const commandDurabilityEvidence = [
|
||||
{
|
||||
scope: 'general',
|
||||
risk: 'R1',
|
||||
command: 'che_훈련',
|
||||
facet: 'single-actor',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'general',
|
||||
risk: 'R2',
|
||||
command: 'che_농지개간',
|
||||
facet: 'local-aggregate',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'general',
|
||||
risk: 'R3',
|
||||
command: 'che_증여',
|
||||
facet: 'cross-entity',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'nation',
|
||||
risk: 'R1',
|
||||
command: 'che_국호변경',
|
||||
facet: 'local-aggregate',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'nation',
|
||||
risk: 'R2',
|
||||
command: 'che_증축',
|
||||
facet: 'local-aggregate',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'nation',
|
||||
risk: 'R3',
|
||||
command: 'che_선전포고',
|
||||
facet: 'diplomacy-strategy',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'general',
|
||||
risk: 'R3',
|
||||
command: 'che_이동',
|
||||
facet: 'placement-topology',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'nation',
|
||||
risk: 'R3',
|
||||
command: 'che_물자원조',
|
||||
facet: 'cross-entity',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'nation',
|
||||
risk: 'R1',
|
||||
command: 'event_원융노병연구',
|
||||
facet: 'multi-turn-research',
|
||||
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||
matrixRepresentative: true,
|
||||
},
|
||||
{
|
||||
scope: 'general',
|
||||
risk: 'R3',
|
||||
command: 'che_출병',
|
||||
facet: 'hostile-rng-destructive',
|
||||
testFile: 'liveSortiePersistence.integration.test.ts',
|
||||
matrixRepresentative: false,
|
||||
},
|
||||
{
|
||||
scope: 'nation',
|
||||
risk: 'R3',
|
||||
command: 'che_의병모집',
|
||||
facet: 'entity-creation-fanout',
|
||||
testFile: 'turnCommandFullLifecyclePersistence.integration.test.ts',
|
||||
matrixRepresentative: false,
|
||||
},
|
||||
{
|
||||
scope: 'general',
|
||||
risk: 'R3',
|
||||
command: 'che_은퇴',
|
||||
facet: 'retirement-archive',
|
||||
testFile: 'generalTurnLifecyclePersistence.integration.test.ts',
|
||||
matrixRepresentative: false,
|
||||
},
|
||||
] as const satisfies readonly CommandDurabilityEvidence[];
|
||||
+52
-7
@@ -485,11 +485,33 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence',
|
||||
}
|
||||
world.executeGeneralTurn(executableActor);
|
||||
|
||||
const createdIds = world
|
||||
.peekDirtyState()
|
||||
.createdGenerals.map((general) => general.id)
|
||||
.sort((left, right) => left - right);
|
||||
const createdGenerals = world.peekDirtyState().createdGenerals;
|
||||
const createdIds = createdGenerals.map((general) => general.id).sort((left, right) => left - right);
|
||||
expect(createdIds).toEqual([102, 103, 104]);
|
||||
const createdVolunteerIdentity = createdGenerals
|
||||
.map((general) => ({
|
||||
id: general.id,
|
||||
affinity: general.affinity,
|
||||
npcState: general.npcState,
|
||||
npcOrg: asRecord(general.meta).npc_org,
|
||||
expLevel: asRecord(general.meta).explevel,
|
||||
dedLevel: asRecord(general.meta).dedlevel,
|
||||
}))
|
||||
.sort((left, right) => left.id - right.id);
|
||||
expect(createdVolunteerIdentity).toEqual(
|
||||
createdIds.map((id) => ({
|
||||
id,
|
||||
affinity: expect.any(Number),
|
||||
npcState: 4,
|
||||
npcOrg: 4,
|
||||
expLevel: 0,
|
||||
dedLevel: 1,
|
||||
}))
|
||||
);
|
||||
for (const volunteer of createdVolunteerIdentity) {
|
||||
expect(volunteer.affinity).toBeGreaterThanOrEqual(1);
|
||||
expect(volunteer.affinity).toBeLessThanOrEqual(150);
|
||||
}
|
||||
expect(reservedTurns.peekDirtyState().generalInitializationIds.sort((left, right) => left - right)).toEqual(
|
||||
createdIds
|
||||
);
|
||||
@@ -527,6 +549,22 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence',
|
||||
).toEqual(Array.from({ length: 30 }, (_, turnIdx) => ({ turnIdx, action: '휴식', args: {} })));
|
||||
}
|
||||
|
||||
const persistedVolunteerIdentity = (
|
||||
await db.general.findMany({
|
||||
where: { id: { in: createdIds } },
|
||||
select: { id: true, affinity: true, npcState: true, meta: true },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
).map((general) => ({
|
||||
id: general.id,
|
||||
affinity: general.affinity,
|
||||
npcState: general.npcState,
|
||||
npcOrg: asRecord(general.meta).npc_org,
|
||||
expLevel: asRecord(general.meta).explevel,
|
||||
dedLevel: asRecord(general.meta).dedlevel,
|
||||
}));
|
||||
expect(persistedVolunteerIdentity).toEqual(createdVolunteerIdentity);
|
||||
|
||||
const persistedNation = await db.nation.findUnique({ where: { id: nationId }, select: { meta: true } });
|
||||
expect(persistedNation?.meta).toMatchObject({ gennum: 4 });
|
||||
|
||||
@@ -534,9 +572,16 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence',
|
||||
expect(
|
||||
reloaded.snapshot.generals
|
||||
.filter((general) => createdIds.includes(general.id))
|
||||
.map((general) => general.id)
|
||||
.sort((left, right) => left - right)
|
||||
).toEqual(createdIds);
|
||||
.map((general) => ({
|
||||
id: general.id,
|
||||
affinity: general.affinity,
|
||||
npcState: general.npcState,
|
||||
npcOrg: asRecord(general.meta).npc_org,
|
||||
expLevel: asRecord(general.meta).explevel,
|
||||
dedLevel: asRecord(general.meta).dedlevel,
|
||||
}))
|
||||
.sort((left, right) => left.id - right.id)
|
||||
).toEqual(createdVolunteerIdentity);
|
||||
expect(reloaded.snapshot.nations.find((nation) => nation.id === nationId)?.meta).toMatchObject({ gennum: 4 });
|
||||
const reloadedReservedTurns = new InMemoryReservedTurnStore(db, {
|
||||
maxGeneralTurns: 30,
|
||||
|
||||
@@ -0,0 +1,1227 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import {
|
||||
GENERAL_TURN_COMMAND_KEYS,
|
||||
NATION_TURN_COMMAND_KEYS,
|
||||
type GeneralTurnCommandKey,
|
||||
type NationTurnCommandKey,
|
||||
} from '@sammo-ts/logic';
|
||||
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js';
|
||||
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
|
||||
import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js';
|
||||
import { InMemoryReservedTurnStore } from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
|
||||
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
|
||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||
import { loadTurnWorldFromDatabase } from '@sammo-ts/game-engine/turn/worldLoader.js';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import {
|
||||
commandDurabilityEvidence,
|
||||
generalCommandDurabilityRisk,
|
||||
nationCommandDurabilityRisk,
|
||||
type CommandDurabilityRisk,
|
||||
} from '../src/turn-differential/commandDurabilityRisk.js';
|
||||
import {
|
||||
canonicalizeTurnCommandArgs,
|
||||
type CanonicalTurnSnapshot,
|
||||
type TurnSnapshotSelector,
|
||||
} from '../src/turn-differential/canonical.js';
|
||||
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
|
||||
import {
|
||||
clearCoreTurnCommandPersistenceFixture,
|
||||
seedCoreTurnCommandPersistenceFixture,
|
||||
} from '../src/turn-differential/coreCommandPersistenceFixture.js';
|
||||
import {
|
||||
buildCoreTurnCommandWorldInput,
|
||||
createCoreTurnCommandProfile,
|
||||
resolveCoreTurnCommandArgs,
|
||||
runCoreTurnCommandTrace,
|
||||
type TurnCommandFixtureRequest,
|
||||
} from '../src/turn-differential/coreCommandTrace.js';
|
||||
import { readCoreDatabaseSnapshot } from '../src/turn-differential/databaseSnapshot.js';
|
||||
import { normalizeStoredTurnLogText, orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
|
||||
import { projectSemanticTurnMessages } from '../src/turn-differential/messageProjection.js';
|
||||
import {
|
||||
findTurnDifferentialWorkspaceRoot,
|
||||
runReferenceTurnCommandTraceRequest,
|
||||
} from '../src/turn-differential/referenceSnapshot.js';
|
||||
|
||||
const databaseUrl = process.env.TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL;
|
||||
const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const referenceSourceRoot = workspaceRoot
|
||||
? path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam'))
|
||||
: null;
|
||||
const hasReferenceRunner =
|
||||
referenceSourceRoot !== null && fs.existsSync(path.join(referenceSourceRoot, 'hwe/compare/turn_command_trace.php'));
|
||||
const databaseIntegration = describe.skipIf(
|
||||
!databaseUrl || !workspaceRoot || !hasReferenceRunner || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'
|
||||
);
|
||||
const dedicatedSuffix = 'turn_command_durable_matrix';
|
||||
const leaseOwner = 'turn-command-durable-matrix-daemon';
|
||||
const siblingRulerGeneralId = 3;
|
||||
const siblingGeneralTurnRevisionSentinel = {
|
||||
generalId: siblingRulerGeneralId,
|
||||
revision: 37,
|
||||
leaseOwner,
|
||||
leaseExpiresAt: new Date('2099-08-24T12:34:56.789Z'),
|
||||
};
|
||||
|
||||
const buildSiblingNationTurnRevisionSentinel = (actorNationId: number, actorOfficerLevel: number) => ({
|
||||
nationId: actorOfficerLevel === 5 ? actorNationId : 2,
|
||||
officerLevel: 12,
|
||||
revision: 41,
|
||||
leaseOwner,
|
||||
leaseExpiresAt: new Date('2099-08-24T23:45:01.234Z'),
|
||||
});
|
||||
|
||||
const readSiblingTurnRevisionSentinels = async (
|
||||
db: GamePrismaClient,
|
||||
siblingNationSentinel: ReturnType<typeof buildSiblingNationTurnRevisionSentinel>
|
||||
) => ({
|
||||
general: await db.generalTurnRevision.findUniqueOrThrow({
|
||||
where: { generalId: siblingGeneralTurnRevisionSentinel.generalId },
|
||||
select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||
}),
|
||||
nation: await db.nationTurnRevision.findUniqueOrThrow({
|
||||
where: {
|
||||
nationId_officerLevel: {
|
||||
nationId: siblingNationSentinel.nationId,
|
||||
officerLevel: siblingNationSentinel.officerLevel,
|
||||
},
|
||||
},
|
||||
select: { nationId: true, officerLevel: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||
}),
|
||||
});
|
||||
|
||||
export const assertDedicatedTurnCommandDurableMatrixDatabase = (rawUrl: string): void => {
|
||||
const url = new URL(rawUrl);
|
||||
const schema = url.searchParams.get('schema');
|
||||
const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
||||
if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) {
|
||||
throw new Error(
|
||||
`Refusing to mutate non-dedicated turn command durable matrix database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
describe('turn command durability risk manifest', () => {
|
||||
it('classifies the exact 55 general and 35 nation command registries without duplicates', () => {
|
||||
expect(Object.keys(generalCommandDurabilityRisk).sort()).toEqual([...GENERAL_TURN_COMMAND_KEYS].sort());
|
||||
expect(Object.keys(nationCommandDurabilityRisk).sort()).toEqual([...NATION_TURN_COMMAND_KEYS].sort());
|
||||
expect(Object.keys(generalCommandDurabilityRisk)).toHaveLength(55);
|
||||
expect(Object.keys(nationCommandDurabilityRisk)).toHaveLength(35);
|
||||
});
|
||||
|
||||
it('keeps the reviewed R1/R2/R3 population stable and explicit', () => {
|
||||
const counts = (values: CommandDurabilityRisk[]) =>
|
||||
Object.fromEntries(
|
||||
(['R1', 'R2', 'R3'] as const).map((risk) => [risk, values.filter((value) => value === risk).length])
|
||||
);
|
||||
|
||||
expect(counts(Object.values(generalCommandDurabilityRisk))).toEqual({ R1: 14, R2: 16, R3: 25 });
|
||||
expect(counts(Object.values(nationCommandDurabilityRisk))).toEqual({ R1: 12, R2: 3, R3: 20 });
|
||||
});
|
||||
|
||||
it('assigns a dedicated PostgreSQL representative to every scope/risk cell and stronger R3 facet', () => {
|
||||
const matrixCells = new Set(
|
||||
commandDurabilityEvidence
|
||||
.filter((entry) => entry.matrixRepresentative)
|
||||
.map((entry) => `${entry.scope}:${entry.risk}`)
|
||||
);
|
||||
const requiredCells = (['general', 'nation'] as const).flatMap((scope) =>
|
||||
(['R1', 'R2', 'R3'] as const).map((risk) => `${scope}:${risk}`)
|
||||
);
|
||||
expect([...matrixCells].sort()).toEqual(requiredCells.sort());
|
||||
|
||||
const facets = new Set(commandDurabilityEvidence.map((entry) => entry.facet));
|
||||
expect(facets).toEqual(
|
||||
new Set([
|
||||
'single-actor',
|
||||
'local-aggregate',
|
||||
'cross-entity',
|
||||
'placement-topology',
|
||||
'hostile-rng-destructive',
|
||||
'diplomacy-strategy',
|
||||
'entity-creation-fanout',
|
||||
'retirement-archive',
|
||||
'multi-turn-research',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps every declared PostgreSQL representative synchronized with its typed risk inventory and executed case', () => {
|
||||
const evidence = commandDurabilityEvidence
|
||||
.filter((entry) => entry.matrixRepresentative)
|
||||
.map(({ scope, risk, command }) => `${scope}:${risk}:${command}`)
|
||||
.sort();
|
||||
const cases = riskMatrixCases.map(({ scope, risk, action }) => `${scope}:${risk}:${action}`).sort();
|
||||
expect(cases).toEqual(evidence);
|
||||
|
||||
for (const entry of riskMatrixCases) {
|
||||
const classifiedRisk =
|
||||
entry.scope === 'general'
|
||||
? generalCommandDurabilityRisk[entry.action]
|
||||
: nationCommandDurabilityRisk[entry.action];
|
||||
expect(entry.risk, `${entry.scope}:${entry.action}`).toBe(classifiedRisk);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('turn command durable matrix database guard', () => {
|
||||
it('rejects a shared database and schema before connecting', () => {
|
||||
expect(() =>
|
||||
assertDedicatedTurnCommandDurableMatrixDatabase(
|
||||
'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public'
|
||||
)
|
||||
).toThrow('Refusing to mutate non-dedicated turn command durable matrix database');
|
||||
});
|
||||
|
||||
it('accepts only an explicitly dedicated schema or database name', () => {
|
||||
expect(() =>
|
||||
assertDedicatedTurnCommandDurableMatrixDatabase(
|
||||
'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_turn_command_durable_matrix'
|
||||
)
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertDedicatedTurnCommandDurableMatrixDatabase(
|
||||
'postgresql://fixture:fixture@127.0.0.1:5432/ci_turn_command_durable_matrix'
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record<string, unknown> => ({
|
||||
id,
|
||||
name: `위험행렬장수${id}`,
|
||||
nationId,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
leadership: 90,
|
||||
strength: 80,
|
||||
intelligence: 70,
|
||||
leadershipExp: 0,
|
||||
strengthExp: 0,
|
||||
intelExp: 0,
|
||||
experience: 1_000,
|
||||
dedication: 1_000,
|
||||
expLevel: 0,
|
||||
officerLevel,
|
||||
officerCityId: officerLevel >= 5 ? cityId : 0,
|
||||
belong: 10,
|
||||
permission: 'normal',
|
||||
injury: 0,
|
||||
age: 30,
|
||||
gold: 100_000,
|
||||
rice: 100_000,
|
||||
crew: 1_000,
|
||||
crewTypeId: 1_100,
|
||||
train: 50,
|
||||
atmos: 50,
|
||||
killTurn: 24,
|
||||
npcState: 0,
|
||||
blockState: 0,
|
||||
personality: 'None',
|
||||
specialDomestic: 'None',
|
||||
specialWar: 'None',
|
||||
itemHorse: 'None',
|
||||
itemWeapon: 'None',
|
||||
itemBook: 'None',
|
||||
itemExtra: 'None',
|
||||
meta: {},
|
||||
});
|
||||
|
||||
interface RiskMatrixCaseBase {
|
||||
label: string;
|
||||
risk: CommandDurabilityRisk;
|
||||
args?: Record<string, unknown>;
|
||||
nationPatch?: Record<string, unknown>;
|
||||
cityPatch?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type RiskMatrixCase =
|
||||
| (RiskMatrixCaseBase & { scope: 'general'; action: GeneralTurnCommandKey })
|
||||
| (RiskMatrixCaseBase & { scope: 'nation'; action: NationTurnCommandKey });
|
||||
|
||||
const riskMatrixCases: RiskMatrixCase[] = [
|
||||
{ label: 'general actor aggregate', scope: 'general', risk: 'R1', action: 'che_훈련' },
|
||||
{ label: 'general/city cross aggregate', scope: 'general', risk: 'R2', action: 'che_농지개간' },
|
||||
{
|
||||
label: 'general multi-party resource transfer',
|
||||
scope: 'general',
|
||||
risk: 'R3',
|
||||
action: 'che_증여',
|
||||
args: { isGold: true, amount: 100, destGeneralID: 3 },
|
||||
},
|
||||
{
|
||||
label: 'general placement topology',
|
||||
scope: 'general',
|
||||
risk: 'R3',
|
||||
action: 'che_이동',
|
||||
args: { destCityID: 70 },
|
||||
},
|
||||
{
|
||||
label: 'nation aggregate',
|
||||
scope: 'nation',
|
||||
risk: 'R1',
|
||||
action: 'che_국호변경',
|
||||
args: { nationName: '위험행렬국' },
|
||||
},
|
||||
{
|
||||
label: 'nation/capital topology',
|
||||
scope: 'nation',
|
||||
risk: 'R2',
|
||||
action: 'che_증축',
|
||||
nationPatch: {
|
||||
capitalRevision: 0,
|
||||
turnLastByOfficerLevel: { 12: { command: '증축', arg: {}, term: 5, seq: 0 } },
|
||||
},
|
||||
cityPatch: { level: 7 },
|
||||
},
|
||||
{
|
||||
label: 'nation diplomacy relationship',
|
||||
scope: 'nation',
|
||||
risk: 'R3',
|
||||
action: 'che_선전포고',
|
||||
args: { destNationID: 2 },
|
||||
},
|
||||
{
|
||||
label: 'nation cross-entity material aid',
|
||||
scope: 'nation',
|
||||
risk: 'R3',
|
||||
action: 'che_물자원조',
|
||||
args: { destNationID: 2, amountList: [100, 200] },
|
||||
nationPatch: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '국호 변경', arg: { nationName: '수뇌보존국' }, term: 7, seq: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'nation multi-turn research completion',
|
||||
scope: 'nation',
|
||||
risk: 'R1',
|
||||
action: 'event_원융노병연구',
|
||||
nationPatch: {
|
||||
turnLastByOfficerLevel: { 12: { command: '원융노병 연구', term: 23 } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const buildRiskMatrixRequest = (entry: RiskMatrixCase): TurnCommandFixtureRequest => {
|
||||
const validatesMinimumChiefBoundary = entry.action === 'che_물자원조';
|
||||
const actorOfficerLevel = validatesMinimumChiefBoundary ? 5 : 12;
|
||||
const siblingRulerTurns = validatesMinimumChiefBoundary
|
||||
? Array.from({ length: 12 }, (_, turnIndex) => ({
|
||||
nationId: 1,
|
||||
officerLevel: 12,
|
||||
turnIndex,
|
||||
action: turnIndex === 0 ? 'che_국호변경' : '휴식',
|
||||
args: turnIndex === 0 ? { nationName: '수뇌보존대기국' } : {},
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
kind: entry.scope,
|
||||
actorGeneralId: 1,
|
||||
action: entry.action,
|
||||
...(entry.args ? { args: entry.args } : {}),
|
||||
includeLifecycle: true,
|
||||
setup: {
|
||||
isolateWorld: true,
|
||||
world: {
|
||||
startYear: 180,
|
||||
year: 190,
|
||||
month: 1,
|
||||
hiddenSeed: `turn-command-durable-${entry.scope}-${entry.action}`,
|
||||
freezeClock: true,
|
||||
},
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '아국',
|
||||
color: '#777777',
|
||||
capitalCityId: 3,
|
||||
gold: 1_000_000,
|
||||
rice: 1_000_000,
|
||||
tech: 1_000,
|
||||
level: 1,
|
||||
typeCode: 'che_명가',
|
||||
war: 0,
|
||||
diplomacyLimit: 0,
|
||||
strategicCommandLimit: 0,
|
||||
generalCount: 2,
|
||||
meta: { can_국호변경: 1, can_국기변경: 1, surlimit: 0 },
|
||||
...entry.nationPatch,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '타국',
|
||||
color: '#888888',
|
||||
capitalCityId: 71,
|
||||
gold: 1_000_000,
|
||||
rice: 1_000_000,
|
||||
tech: 1_000,
|
||||
level: 1,
|
||||
typeCode: 'che_명가',
|
||||
war: 0,
|
||||
diplomacyLimit: 0,
|
||||
strategicCommandLimit: 0,
|
||||
generalCount: 1,
|
||||
meta: { surlimit: 0 },
|
||||
},
|
||||
],
|
||||
cities: [
|
||||
{
|
||||
id: 3,
|
||||
nationId: 1,
|
||||
level: 5,
|
||||
population: 100_000,
|
||||
populationMax: 200_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
state: 0,
|
||||
term: 0,
|
||||
trust: 80,
|
||||
trade: 100,
|
||||
...entry.cityPatch,
|
||||
},
|
||||
{
|
||||
id: 70,
|
||||
nationId: 1,
|
||||
level: 5,
|
||||
population: 100_000,
|
||||
populationMax: 200_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 1,
|
||||
state: 0,
|
||||
term: 0,
|
||||
trust: 80,
|
||||
trade: 100,
|
||||
},
|
||||
{
|
||||
id: 71,
|
||||
nationId: 2,
|
||||
level: 5,
|
||||
population: 100_000,
|
||||
populationMax: 200_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 1,
|
||||
state: 0,
|
||||
term: 0,
|
||||
trust: 80,
|
||||
trade: 100,
|
||||
},
|
||||
],
|
||||
generals: [
|
||||
general(1, 1, 3, actorOfficerLevel),
|
||||
general(2, 2, 71, 12),
|
||||
general(siblingRulerGeneralId, 1, 3, validatesMinimumChiefBoundary ? 12 : 1),
|
||||
],
|
||||
diplomacy: [
|
||||
{ fromNationId: 1, toNationId: 2, state: 3, term: 0, dead: 0 },
|
||||
{ fromNationId: 2, toNationId: 1, state: 3, term: 0, dead: 0 },
|
||||
],
|
||||
...(entry.scope === 'general'
|
||||
? {
|
||||
generalTurns: Array.from({ length: 30 }, (_, turnIndex) => ({
|
||||
generalId: 1,
|
||||
turnIndex,
|
||||
action: turnIndex === 0 ? entry.action : '휴식',
|
||||
args: turnIndex === 0 ? (entry.args ?? {}) : {},
|
||||
})),
|
||||
}
|
||||
: {
|
||||
nationTurns: [
|
||||
...Array.from({ length: 12 }, (_, turnIndex) => ({
|
||||
nationId: 1,
|
||||
officerLevel: actorOfficerLevel,
|
||||
turnIndex,
|
||||
action: turnIndex === 0 ? entry.action : '휴식',
|
||||
args: turnIndex === 0 ? (entry.args ?? {}) : {},
|
||||
})),
|
||||
...siblingRulerTurns,
|
||||
],
|
||||
}),
|
||||
},
|
||||
observe: {
|
||||
allGenerals: true,
|
||||
allCities: true,
|
||||
allNations: true,
|
||||
allTroops: true,
|
||||
generalIds: [1, 2, 3],
|
||||
cityIds: [3, 70, 71],
|
||||
nationIds: [1, 2],
|
||||
troopIds: [],
|
||||
includeRankMirrors: true,
|
||||
logAfterId: 0,
|
||||
messageAfterId: 0,
|
||||
includeNationHistoryLogs: true,
|
||||
includeGlobalHistoryLogs: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const lifecycleIgnoredPaths = [
|
||||
/^generalTurns/,
|
||||
/^nationTurns/,
|
||||
/^logs/,
|
||||
/^messages/,
|
||||
/^world\.turnTime$/,
|
||||
/^world\.gameNow$/,
|
||||
/^world\.lastTurnTick$/,
|
||||
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet|turnTick|turnSecond|turnFraction)(?:\.|$)/,
|
||||
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
|
||||
/^nations\[[^\]]+\]\.meta\.(?:turn_last_\d+|next_execute_.+|capset|tech|gennum|power|war|surlimit|strategic_cmd_limit)(?:\.|$)/,
|
||||
];
|
||||
|
||||
const addedReferenceLogs = (
|
||||
before: { watermarks: { logId: number; historyLogId: number } },
|
||||
after: Array<Record<string, unknown>>
|
||||
): Array<Record<string, unknown>> =>
|
||||
after.filter((entry) => {
|
||||
const scope = String(entry.scope).toLowerCase();
|
||||
const category = String(entry.category).toLowerCase();
|
||||
const watermark =
|
||||
scope === 'nation' || (scope === 'system' && category === 'history')
|
||||
? before.watermarks.historyLogId
|
||||
: before.watermarks.logId;
|
||||
return Number(entry.id) > watermark;
|
||||
});
|
||||
|
||||
const isTrailingDefaultGeneralRestLog = (entry: Record<string, unknown>): boolean =>
|
||||
String(entry.scope).toLowerCase() === 'general' &&
|
||||
String(entry.category).toLowerCase() === 'action' &&
|
||||
normalizeStoredTurnLogText(entry.text) === '아무것도 실행하지 않았습니다.';
|
||||
|
||||
const withoutVolatileGameNow = ({ world, ...snapshot }: CanonicalTurnSnapshot) => {
|
||||
const { gameNow: _gameNow, ...stableWorld } = world;
|
||||
return { ...snapshot, world: stableWorld };
|
||||
};
|
||||
|
||||
const projectDatabaseIndependentTurnMessages = (
|
||||
messages: CanonicalTurnSnapshot['messages'],
|
||||
messageAfterId: number
|
||||
) => {
|
||||
const newMessages = messages.filter((message) => Number(message.id) > messageAfterId);
|
||||
const projected = projectSemanticTurnMessages(messages, messageAfterId);
|
||||
const projectedById = new Map(newMessages.map((message, index) => [Number(message.id), projected[index]!]));
|
||||
|
||||
return projected.map((message) => {
|
||||
const option =
|
||||
typeof message.option === 'object' && message.option !== null && !Array.isArray(message.option)
|
||||
? (message.option as Record<string, unknown>)
|
||||
: null;
|
||||
if (!option || option.receiverMessageID === undefined) return message;
|
||||
|
||||
if (
|
||||
typeof option.receiverMessageID !== 'number' ||
|
||||
!Number.isSafeInteger(option.receiverMessageID) ||
|
||||
option.receiverMessageID <= 0
|
||||
) {
|
||||
throw new Error(`message receiverMessageID must be a positive safe integer number`);
|
||||
}
|
||||
const receiverMessageId = option.receiverMessageID;
|
||||
const receiverCopy = projectedById.get(receiverMessageId);
|
||||
if (
|
||||
!receiverCopy ||
|
||||
receiverCopy.mailbox !== message.destinationId ||
|
||||
receiverCopy.type !== message.type ||
|
||||
receiverCopy.sourceId !== message.sourceId ||
|
||||
receiverCopy.destinationId !== message.destinationId ||
|
||||
receiverCopy.text !== message.text
|
||||
) {
|
||||
throw new Error(`message receiverMessageID ${String(option.receiverMessageID)} is not its receiver copy`);
|
||||
}
|
||||
|
||||
// Ref MariaDB and the dedicated PostgreSQL schema have independent
|
||||
// sequences. Keep the cross-row link exact, but compare its database-
|
||||
// local numeric key by relation rather than by an impossible shared id.
|
||||
return {
|
||||
...message,
|
||||
option: { ...option, receiverMessageID: 'receiver-copy' },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const readForbiddenSideEffects = async (db: GamePrismaClient) => ({
|
||||
inputEvents: await db.inputEvent.count(),
|
||||
webPushOutbox: await db.webPushOutbox.count(),
|
||||
events: await db.event.count(),
|
||||
auctions: await db.auction.count(),
|
||||
auctionBids: await db.auctionBid.count(),
|
||||
});
|
||||
|
||||
const expectNoDirtyWorldChanges = (world: InMemoryTurnWorld): void => {
|
||||
const { realtimeBacklogShiftTicks, ...arrayChanges } = world.peekDirtyState();
|
||||
expect(realtimeBacklogShiftTicks).toBe(0);
|
||||
for (const [changeName, entries] of Object.entries(arrayChanges)) {
|
||||
expect(entries, `world dirty state ${changeName}`).toEqual([]);
|
||||
}
|
||||
};
|
||||
|
||||
const projectReloadedGeneralTurns = (store: InMemoryReservedTurnStore, generalId: number) =>
|
||||
store.getGeneralTurns(generalId).map((turn, turnIndex) => ({
|
||||
generalId,
|
||||
turnIndex,
|
||||
action: turn.action,
|
||||
args: turn.args,
|
||||
}));
|
||||
|
||||
const projectReloadedNationTurns = (store: InMemoryReservedTurnStore, nationId: number, officerLevel: number) =>
|
||||
store.getNationTurns(nationId, officerLevel).map((turn, turnIndex) => ({
|
||||
nationId,
|
||||
officerLevel,
|
||||
turnIndex,
|
||||
action: turn.action,
|
||||
args: turn.args,
|
||||
}));
|
||||
|
||||
type ReloadedWorldSnapshot = Awaited<ReturnType<typeof loadTurnWorldFromDatabase>>['snapshot'];
|
||||
|
||||
const projectReloadableWorldGraph = (
|
||||
snapshot: Pick<ReloadedWorldSnapshot, 'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'>,
|
||||
selector: TurnSnapshotSelector
|
||||
) => {
|
||||
const selectedIds = (all: boolean | undefined, ids: readonly number[]) => (all ? null : new Set(ids));
|
||||
const generalIds = selectedIds(selector.allGenerals, selector.generalIds);
|
||||
const cityIds = selectedIds(selector.allCities, selector.cityIds);
|
||||
const nationIds = selectedIds(selector.allNations, selector.nationIds);
|
||||
const troopIds = selectedIds(selector.allTroops, selector.troopIds ?? []);
|
||||
const byId = <Row extends { id: number }>(rows: readonly Row[], ids: Set<number> | null) =>
|
||||
structuredClone(rows)
|
||||
.filter((row) => ids === null || ids.has(row.id))
|
||||
.sort((left, right) => left.id - right.id);
|
||||
const generals = byId(snapshot.generals, generalIds).map((general) => {
|
||||
const { itemInventory: _persistedItemInventory, legacyScanOrder: _legacyScanOrder, ...meta } = general.meta;
|
||||
return { ...general, meta };
|
||||
});
|
||||
const nations = byId(snapshot.nations, nationIds).map((nation) => {
|
||||
const { power: _projectedPower, ...meta } = nation.meta;
|
||||
return { ...nation, meta };
|
||||
});
|
||||
|
||||
return {
|
||||
// The database loader reconstructs itemInventory from its canonical
|
||||
// top-level field and may materialize projected defaults in meta. Keep
|
||||
// those three storage/fixture duplicates out, while comparing every
|
||||
// command-owned entity field (including top-level itemInventory) exact.
|
||||
generals,
|
||||
cities: byId(snapshot.cities, cityIds),
|
||||
nations,
|
||||
troops: byId(snapshot.troops, troopIds),
|
||||
diplomacy: structuredClone(snapshot.diplomacy)
|
||||
.filter(
|
||||
(entry) => nationIds === null || (nationIds.has(entry.fromNationId) && nationIds.has(entry.toNationId))
|
||||
)
|
||||
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId),
|
||||
};
|
||||
};
|
||||
|
||||
const projectCanonicalCommandLifecycleState = (
|
||||
snapshot: CanonicalTurnSnapshot,
|
||||
actorGeneralId: number,
|
||||
nationId: number,
|
||||
officerLevel: number
|
||||
) => {
|
||||
const general = snapshot.generals.find((row) => row.id === actorGeneralId);
|
||||
const nation = snapshot.nations.find((row) => row.id === nationId);
|
||||
if (!general) throw new Error(`canonical lifecycle actor is missing: ${actorGeneralId}`);
|
||||
if (!nation) throw new Error(`canonical lifecycle nation is missing: ${nationId}`);
|
||||
if (typeof nation.meta !== 'object' || nation.meta === null || Array.isArray(nation.meta)) {
|
||||
throw new Error(`canonical lifecycle nation meta is invalid: ${nationId}`);
|
||||
}
|
||||
const nationMeta = nation.meta as Record<string, unknown>;
|
||||
return {
|
||||
generalLastTurn: general.lastTurn,
|
||||
nationOfficerLastTurn: nationMeta[`turn_last_${officerLevel}`],
|
||||
nationCapitalRevision: nation.capitalRevision,
|
||||
nationCapset: nationMeta.capset,
|
||||
};
|
||||
};
|
||||
|
||||
const projectDomainCommandLifecycleState = (
|
||||
snapshot: Pick<ReloadedWorldSnapshot, 'generals' | 'nations'>,
|
||||
actorGeneralId: number,
|
||||
nationId: number,
|
||||
officerLevel: number
|
||||
) => {
|
||||
const general = snapshot.generals.find((row) => row.id === actorGeneralId);
|
||||
const nation = snapshot.nations.find((row) => row.id === nationId);
|
||||
if (!general) throw new Error(`domain lifecycle actor is missing: ${actorGeneralId}`);
|
||||
if (!nation) throw new Error(`domain lifecycle nation is missing: ${nationId}`);
|
||||
return {
|
||||
generalLastTurn: general.lastTurn,
|
||||
nationOfficerLastTurn: nation.meta[`turn_last_${officerLevel}`],
|
||||
nationCapitalRevision: nation.meta.capset,
|
||||
nationCapset: nation.meta.capset,
|
||||
};
|
||||
};
|
||||
|
||||
const requireLastTurnRecord = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.command !== 'string' || record.command === '') {
|
||||
throw new Error(`${label}.command must be a non-empty string`);
|
||||
}
|
||||
return record;
|
||||
};
|
||||
|
||||
const projectSemanticLastTurn = (value: unknown, label: string) => {
|
||||
const record = requireLastTurnRecord(value, label);
|
||||
const finiteIntegerOrZero = (candidate: unknown): number =>
|
||||
typeof candidate === 'number' && Number.isFinite(candidate) ? Math.floor(candidate) : 0;
|
||||
const rawArg = record.arg ?? {};
|
||||
return {
|
||||
command: record.command,
|
||||
// PHP's no-argument LastTurn serializes `arg` as [], while Core's typed
|
||||
// command contract uses {}. A non-empty list remains significant.
|
||||
arg: Array.isArray(rawArg) && rawArg.length === 0 ? {} : canonicalizeTurnCommandArgs(rawArg),
|
||||
term: finiteIntegerOrZero(record.term),
|
||||
seq: finiteIntegerOrZero(record.seq),
|
||||
};
|
||||
};
|
||||
|
||||
const projectReferenceOutcomeLastTurn = (outcome: unknown): unknown => {
|
||||
const record = asRecord(outcome);
|
||||
return record.lastTurn;
|
||||
};
|
||||
|
||||
const compareCanonicalGeneralTurns = (left: Record<string, unknown>, right: Record<string, unknown>): number =>
|
||||
Number(left.generalId) - Number(right.generalId) || Number(left.turnIndex) - Number(right.turnIndex);
|
||||
|
||||
const compareCanonicalNationTurns = (left: Record<string, unknown>, right: Record<string, unknown>): number =>
|
||||
Number(left.nationId) - Number(right.nationId) ||
|
||||
Number(left.officerLevel) - Number(right.officerLevel) ||
|
||||
Number(left.turnIndex) - Number(right.turnIndex);
|
||||
|
||||
databaseIntegration('risk-based command PostgreSQL durability matrix', () => {
|
||||
let db: GamePrismaClient | undefined;
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedTurnCommandDurableMatrixDatabase(databaseUrl!);
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
disconnect = () => connector.disconnect();
|
||||
await clearCoreTurnCommandPersistenceFixture(db);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (db) await clearCoreTurnCommandPersistenceFixture(db);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (db) await clearCoreTurnCommandPersistenceFixture(db);
|
||||
} finally {
|
||||
await disconnect?.();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(riskMatrixCases)(
|
||||
'$scope $risk $action ($label) matches Ref/Core, commits through one flush, and survives a fresh PostgreSQL reload',
|
||||
async (entry) => {
|
||||
if (!db) throw new Error('fixture database is not connected');
|
||||
const request = buildRiskMatrixRequest(entry);
|
||||
const selector = request.observe as TurnSnapshotSelector;
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const expected = await runCoreTurnCommandTrace(request, reference.before);
|
||||
expect(expected.execution.outcome).toMatchObject({
|
||||
requestedAction: entry.action,
|
||||
actionKey: entry.action,
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(expected.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, expected.before, expected.after, {
|
||||
ignoredPathPatterns: lifecycleIgnoredPaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
|
||||
const unitSet = await loadUnitSetDefinitionByName('che');
|
||||
const map = await loadMapDefinitionByName('che');
|
||||
const worldInput = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map);
|
||||
const actorBefore = reference.before.generals.find(
|
||||
(generalRow) => generalRow.id === request.actorGeneralId
|
||||
);
|
||||
if (!actorBefore) throw new Error('fixture actor is missing from the reference before snapshot');
|
||||
const actorNationId = Number(actorBefore.nationId);
|
||||
const actorOfficerLevel = Number(actorBefore.officerLevel);
|
||||
if (!Number.isSafeInteger(actorNationId) || !Number.isSafeInteger(actorOfficerLevel)) {
|
||||
throw new Error('fixture actor nation/officer identity is invalid');
|
||||
}
|
||||
const validatesMinimumChiefBoundary = entry.action === 'che_물자원조';
|
||||
if (validatesMinimumChiefBoundary) {
|
||||
expect(actorOfficerLevel).toBe(5);
|
||||
expect(
|
||||
expected.before.generals.find((generalRow) => generalRow.id === siblingRulerGeneralId)
|
||||
).toMatchObject({ nationId: actorNationId, officerLevel: 12 });
|
||||
}
|
||||
const siblingNationTurnRevisionSentinel = buildSiblingNationTurnRevisionSentinel(
|
||||
actorNationId,
|
||||
actorOfficerLevel
|
||||
);
|
||||
const expectedSiblingTurnRevisionSentinels = {
|
||||
general: siblingGeneralTurnRevisionSentinel,
|
||||
nation: siblingNationTurnRevisionSentinel,
|
||||
};
|
||||
const expectedBeforeLifecycle = projectCanonicalCommandLifecycleState(
|
||||
expected.before,
|
||||
request.actorGeneralId,
|
||||
actorNationId,
|
||||
actorOfficerLevel
|
||||
);
|
||||
const expectedAfterLifecycle = projectCanonicalCommandLifecycleState(
|
||||
expected.after,
|
||||
request.actorGeneralId,
|
||||
actorNationId,
|
||||
actorOfficerLevel
|
||||
);
|
||||
requireLastTurnRecord(expectedAfterLifecycle.generalLastTurn, 'expected actor lastTurn');
|
||||
requireLastTurnRecord(expectedAfterLifecycle.nationOfficerLastTurn, 'expected officer turn_last');
|
||||
const referenceAfterLifecycle = projectCanonicalCommandLifecycleState(
|
||||
reference.after,
|
||||
request.actorGeneralId,
|
||||
actorNationId,
|
||||
actorOfficerLevel
|
||||
);
|
||||
if (entry.scope === 'general') {
|
||||
expect(
|
||||
projectSemanticLastTurn(referenceAfterLifecycle.generalLastTurn, 'Ref actor lastTurn')
|
||||
).toStrictEqual(projectSemanticLastTurn(expectedAfterLifecycle.generalLastTurn, 'Core actor lastTurn'));
|
||||
expect(expectedAfterLifecycle.nationOfficerLastTurn).toStrictEqual({ command: '휴식', term: 0 });
|
||||
} else {
|
||||
// Ref snapshots project nation.aux but not nation_env. The trace
|
||||
// outcome is the exact resultTurnRaw value written to
|
||||
// nation_env.turn_last_<officerLevel> by the comparison harness.
|
||||
expect(
|
||||
projectSemanticLastTurn(
|
||||
projectReferenceOutcomeLastTurn(reference.execution.outcome),
|
||||
'Ref officer turn_last outcome'
|
||||
)
|
||||
).toStrictEqual(
|
||||
projectSemanticLastTurn(expectedAfterLifecycle.nationOfficerLastTurn, 'Core officer turn_last')
|
||||
);
|
||||
expect(expectedAfterLifecycle.generalLastTurn).toStrictEqual({ command: '휴식' });
|
||||
}
|
||||
if (entry.action === 'che_증축') {
|
||||
const referenceBeforeLifecycle = projectCanonicalCommandLifecycleState(
|
||||
reference.before,
|
||||
request.actorGeneralId,
|
||||
actorNationId,
|
||||
actorOfficerLevel
|
||||
);
|
||||
// Ref persists capset as nation.capset; Core mirrors that column
|
||||
// into both canonical capitalRevision and the domain meta value.
|
||||
expect(referenceBeforeLifecycle.nationCapitalRevision).toBe(0);
|
||||
expect(expectedBeforeLifecycle).toMatchObject({ nationCapitalRevision: 0, nationCapset: 0 });
|
||||
expect(referenceAfterLifecycle.nationCapitalRevision).toBe(1);
|
||||
expect(expectedAfterLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 });
|
||||
}
|
||||
const expectedSiblingRulerLifecycle = validatesMinimumChiefBoundary
|
||||
? projectCanonicalCommandLifecycleState(expected.before, siblingRulerGeneralId, actorNationId, 12)
|
||||
: null;
|
||||
if (expectedSiblingRulerLifecycle) {
|
||||
expect(
|
||||
projectCanonicalCommandLifecycleState(expected.after, siblingRulerGeneralId, actorNationId, 12)
|
||||
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||
expect(expectedSiblingRulerLifecycle.nationOfficerLastTurn).toStrictEqual({
|
||||
command: '국호 변경',
|
||||
arg: { nationName: '수뇌보존국' },
|
||||
term: 7,
|
||||
seq: 3,
|
||||
});
|
||||
}
|
||||
await seedCoreTurnCommandPersistenceFixture(db, {
|
||||
worldInput,
|
||||
scenarioCode: 'turn-command-durable-matrix',
|
||||
generalTurns: reference.before.generalTurns.map((turn) =>
|
||||
entry.scope === 'general' && turn.generalId === request.actorGeneralId && turn.turnIndex === 0
|
||||
? { ...turn, args: resolveCoreTurnCommandArgs(request) }
|
||||
: turn
|
||||
),
|
||||
nationTurns: reference.before.nationTurns.map((turn) =>
|
||||
entry.scope === 'nation' &&
|
||||
turn.nationId === actorBefore.nationId &&
|
||||
turn.officerLevel === actorBefore.officerLevel &&
|
||||
turn.turnIndex === 0
|
||||
? { ...turn, args: resolveCoreTurnCommandArgs(request) }
|
||||
: turn
|
||||
),
|
||||
});
|
||||
// Keep the active daemon owner on both sentinels deliberately. A
|
||||
// flush that releases leases by owner instead of by exact queue
|
||||
// key would corrupt these unrelated command streams.
|
||||
await db.generalTurnRevision.create({ data: siblingGeneralTurnRevisionSentinel });
|
||||
await db.nationTurnRevision.create({ data: siblingNationTurnRevisionSentinel });
|
||||
expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual(
|
||||
expectedSiblingTurnRevisionSentinels
|
||||
);
|
||||
const forbiddenSideEffectsBefore = await readForbiddenSideEffects(db);
|
||||
const databaseBefore = await readCoreDatabaseSnapshot(databaseUrl!, selector);
|
||||
expect(
|
||||
projectCanonicalCommandLifecycleState(
|
||||
databaseBefore,
|
||||
request.actorGeneralId,
|
||||
actorNationId,
|
||||
actorOfficerLevel
|
||||
)
|
||||
).toStrictEqual(expectedBeforeLifecycle);
|
||||
const siblingRulerTurnQueueBefore = validatesMinimumChiefBoundary
|
||||
? databaseBefore.nationTurns.filter(
|
||||
(turn) => turn.nationId === actorNationId && turn.officerLevel === 12
|
||||
)
|
||||
: [];
|
||||
if (expectedSiblingRulerLifecycle) {
|
||||
expect(siblingRulerTurnQueueBefore).toHaveLength(12);
|
||||
expect(
|
||||
projectCanonicalCommandLifecycleState(databaseBefore, siblingRulerGeneralId, actorNationId, 12)
|
||||
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||
}
|
||||
if (entry.action === 'che_증축') {
|
||||
expect(
|
||||
projectCanonicalCommandLifecycleState(
|
||||
databaseBefore,
|
||||
request.actorGeneralId,
|
||||
actorNationId,
|
||||
actorOfficerLevel
|
||||
)
|
||||
).toMatchObject({ nationCapitalRevision: 0, nationCapset: 0 });
|
||||
}
|
||||
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const reservedTurns = new InMemoryReservedTurnStore(db, {
|
||||
maxGeneralTurns: 30,
|
||||
maxNationTurns: 12,
|
||||
leaseOwner,
|
||||
leaseDurationMs: 60_000,
|
||||
});
|
||||
await reservedTurns.loadAll();
|
||||
const loadedActor = loaded.snapshot.generals.find((generalRow) => generalRow.id === request.actorGeneralId);
|
||||
if (!loadedActor) throw new Error('fixture actor is missing after database load');
|
||||
if (validatesMinimumChiefBoundary) {
|
||||
expect(
|
||||
loaded.snapshot.generals.find((generalRow) => generalRow.id === siblingRulerGeneralId)
|
||||
).toMatchObject({ nationId: actorNationId, officerLevel: 12 });
|
||||
}
|
||||
await reservedTurns.prepareTurnsForExecution(loadedActor.id, {
|
||||
nationId: loadedActor.nationId,
|
||||
officerLevel: loadedActor.officerLevel,
|
||||
});
|
||||
|
||||
const resolvedActions: Array<{ kind: string; requestedAction: string; usedFallback: boolean }> = [];
|
||||
let world: InMemoryTurnWorld | null = null;
|
||||
const gameNow = new Date(String(reference.before.world.gameNow));
|
||||
if (!Number.isFinite(gameNow.getTime())) {
|
||||
throw new Error(`reference world.gameNow is invalid: ${String(reference.before.world.gameNow)}`);
|
||||
}
|
||||
const handler = await createReservedTurnHandler({
|
||||
reservedTurns,
|
||||
scenarioConfig: loaded.snapshot.scenarioConfig,
|
||||
scenarioMeta: loaded.snapshot.scenarioMeta,
|
||||
map: loaded.snapshot.map,
|
||||
unitSet: loaded.snapshot.unitSet,
|
||||
getWorld: () => world,
|
||||
now: () => new Date(gameNow.getTime()),
|
||||
messageSharedIconBaseUrl: request.setup?.world?.messageSharedIconBaseUrl,
|
||||
commandProfile: createCoreTurnCommandProfile(request),
|
||||
onActionResolved: (resolved) => {
|
||||
resolvedActions.push({
|
||||
kind: resolved.kind,
|
||||
requestedAction: resolved.requestedAction,
|
||||
usedFallback: resolved.usedFallback,
|
||||
});
|
||||
},
|
||||
});
|
||||
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: {
|
||||
entries: [
|
||||
{
|
||||
startMinute: 0,
|
||||
tickMinutes: Math.max(1, Math.round(loaded.state.tickSeconds / 60)),
|
||||
},
|
||||
],
|
||||
},
|
||||
generalTurnHandler: handler,
|
||||
});
|
||||
const actor = world.getGeneralById(request.actorGeneralId);
|
||||
if (!actor) throw new Error('fixture actor is missing from executable world');
|
||||
world.executeGeneralTurn(actor);
|
||||
expect(resolvedActions).toContainEqual({
|
||||
kind: entry.scope,
|
||||
requestedAction: entry.action,
|
||||
usedFallback: false,
|
||||
});
|
||||
const liveAfterLifecycle = projectDomainCommandLifecycleState(
|
||||
{ generals: world.listGenerals(), nations: world.listNations() },
|
||||
request.actorGeneralId,
|
||||
actor.nationId,
|
||||
actor.officerLevel
|
||||
);
|
||||
expect(liveAfterLifecycle).toStrictEqual(expectedAfterLifecycle);
|
||||
if (expectedSiblingRulerLifecycle) {
|
||||
expect(
|
||||
projectDomainCommandLifecycleState(
|
||||
{ generals: world.listGenerals(), nations: world.listNations() },
|
||||
siblingRulerGeneralId,
|
||||
actor.nationId,
|
||||
12
|
||||
)
|
||||
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||
}
|
||||
if (entry.action === 'che_증축') {
|
||||
expect(liveAfterLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 });
|
||||
}
|
||||
if (entry.action === 'che_훈련') {
|
||||
const expectedActorAfter = expected.after.generals.find(
|
||||
(generalRow) => generalRow.id === request.actorGeneralId
|
||||
);
|
||||
const dirtyActor = world
|
||||
.peekDirtyState()
|
||||
.generals.find((generalRow) => generalRow.id === request.actorGeneralId);
|
||||
expect(dirtyActor?.role.items.weapon).toBe(expectedActorAfter?.itemWeapon);
|
||||
}
|
||||
|
||||
const beforeFlush = await readCoreDatabaseSnapshot(databaseUrl!, selector);
|
||||
expect(withoutVolatileGameNow(beforeFlush)).toStrictEqual(withoutVolatileGameNow(databaseBefore));
|
||||
expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual(
|
||||
expectedSiblingTurnRevisionSentinels
|
||||
);
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
|
||||
try {
|
||||
if (!hooks.hooks.flushChanges) throw new Error('database turn hooks do not expose flushChanges');
|
||||
await hooks.hooks.flushChanges({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 1,
|
||||
processedTurns: 1,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
const receipt = hooks.takeCommittedReadModelChangeReceipt();
|
||||
if (!receipt) throw new Error('flush did not publish a read-model change receipt');
|
||||
expect(receipt.invalidation.revisions.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
await db.readModelRevision.findMany({
|
||||
select: { domain: true, entityId: true, revision: true },
|
||||
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||
})
|
||||
).toEqual(receipt.invalidation.revisions);
|
||||
expect(await db.readModelOutbox.count()).toBe(1);
|
||||
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
expectNoDirtyWorldChanges(world);
|
||||
expect(reservedTurns.peekDirtyState()).toStrictEqual({
|
||||
generalIds: [],
|
||||
generalInitializationIds: [],
|
||||
generalLeaseIds: [],
|
||||
nationKeys: [],
|
||||
nationInitializationKeys: [],
|
||||
nationLeaseKeys: [],
|
||||
});
|
||||
expect(await readForbiddenSideEffects(db)).toEqual(forbiddenSideEffectsBefore);
|
||||
expect(await db.inputEvent.count()).toBe(0);
|
||||
expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual(
|
||||
expectedSiblingTurnRevisionSentinels
|
||||
);
|
||||
|
||||
const after = await readCoreDatabaseSnapshot(databaseUrl!, selector);
|
||||
const expectedPersistedGeneralTurns = [
|
||||
...databaseBefore.generalTurns.filter((turn) => turn.generalId !== request.actorGeneralId),
|
||||
...expected.after.generalTurns.filter((turn) => turn.generalId === request.actorGeneralId),
|
||||
].sort(compareCanonicalGeneralTurns);
|
||||
const expectedPersistedNationTurns = [
|
||||
...databaseBefore.nationTurns.filter(
|
||||
(turn) => turn.nationId !== actor.nationId || turn.officerLevel !== actor.officerLevel
|
||||
),
|
||||
...expected.after.nationTurns.filter(
|
||||
(turn) => turn.nationId === actor.nationId && turn.officerLevel === actor.officerLevel
|
||||
),
|
||||
].sort(compareCanonicalNationTurns);
|
||||
expect(after.generalTurns).toStrictEqual(expectedPersistedGeneralTurns);
|
||||
expect(after.nationTurns).toStrictEqual(expectedPersistedNationTurns);
|
||||
if (expectedSiblingRulerLifecycle) {
|
||||
expect(
|
||||
after.nationTurns.filter((turn) => turn.nationId === actor.nationId && turn.officerLevel === 12)
|
||||
).toStrictEqual(siblingRulerTurnQueueBefore);
|
||||
expect(
|
||||
projectCanonicalCommandLifecycleState(after, siblingRulerGeneralId, actor.nationId, 12)
|
||||
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||
}
|
||||
expect(
|
||||
projectCanonicalCommandLifecycleState(after, request.actorGeneralId, actor.nationId, actor.officerLevel)
|
||||
).toStrictEqual(expectedAfterLifecycle);
|
||||
if (entry.action === 'che_증축') {
|
||||
expect(
|
||||
projectCanonicalCommandLifecycleState(
|
||||
after,
|
||||
request.actorGeneralId,
|
||||
actor.nationId,
|
||||
actor.officerLevel
|
||||
)
|
||||
).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 });
|
||||
}
|
||||
expect(
|
||||
await db.generalTurnRevision.findMany({
|
||||
select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||
orderBy: { generalId: 'asc' },
|
||||
})
|
||||
).toStrictEqual([
|
||||
{ generalId: request.actorGeneralId, revision: 1, leaseOwner: null, leaseExpiresAt: null },
|
||||
siblingGeneralTurnRevisionSentinel,
|
||||
]);
|
||||
expect(
|
||||
await db.nationTurnRevision.findMany({
|
||||
select: {
|
||||
nationId: true,
|
||||
officerLevel: true,
|
||||
revision: true,
|
||||
leaseOwner: true,
|
||||
leaseExpiresAt: true,
|
||||
},
|
||||
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }],
|
||||
})
|
||||
).toStrictEqual([
|
||||
{
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
revision: 1,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
},
|
||||
siblingNationTurnRevisionSentinel,
|
||||
]);
|
||||
if (entry.action === 'che_물자원조') {
|
||||
const sourceNation = after.nations.find((nation) => nation.id === 1);
|
||||
const destinationNation = after.nations.find((nation) => nation.id === 2);
|
||||
expect(sourceNation).toMatchObject({ gold: 999_900, rice: 999_800 });
|
||||
expect(destinationNation).toMatchObject({ gold: 1_000_100, rice: 1_000_200 });
|
||||
expect(asRecord(sourceNation?.meta).surlimit).toBe(12);
|
||||
expect(asRecord(asRecord(destinationNation?.meta).recv_assist).n1).toEqual([1, 300]);
|
||||
}
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, databaseBefore, after, {
|
||||
ignoredPathPatterns: lifecycleIgnoredPaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(expected.before, expected.after, databaseBefore, after, {
|
||||
ignoredPathPatterns: lifecycleIgnoredPaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
const trailingDefaultGeneralRestLogs = after.logs.filter(isTrailingDefaultGeneralRestLog);
|
||||
if (entry.scope === 'nation') {
|
||||
// The production lifecycle always follows an officer's nation command with
|
||||
// that officer's general queue. turn_command_trace.php intentionally executes
|
||||
// only the requested command, so assert and remove this one known harness delta.
|
||||
expect(trailingDefaultGeneralRestLogs).toHaveLength(1);
|
||||
} else {
|
||||
expect(trailingDefaultGeneralRestLogs).toHaveLength(0);
|
||||
}
|
||||
const comparablePersistedLogs = after.logs.filter(
|
||||
(log) => entry.scope !== 'nation' || !isTrailingDefaultGeneralRestLog(log)
|
||||
);
|
||||
expect(orderedSemanticLogStreams(comparablePersistedLogs)).toEqual(
|
||||
orderedSemanticLogStreams(addedReferenceLogs(reference.before, reference.after.logs))
|
||||
);
|
||||
expect(projectDatabaseIndependentTurnMessages(after.messages, 0)).toEqual(
|
||||
projectDatabaseIndependentTurnMessages(reference.after.messages, reference.before.watermarks.messageId)
|
||||
);
|
||||
|
||||
const committedWorldGraph = projectReloadableWorldGraph(
|
||||
{
|
||||
generals: world.listGenerals(),
|
||||
cities: world.listCities(),
|
||||
nations: world.listNations(),
|
||||
troops: world.listTroops(),
|
||||
diplomacy: world.listDiplomacy(),
|
||||
},
|
||||
selector
|
||||
);
|
||||
const reloadedWorld = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(projectReloadableWorldGraph(reloadedWorld.snapshot, selector)).toStrictEqual(committedWorldGraph);
|
||||
const reloadedLifecycle = projectDomainCommandLifecycleState(
|
||||
reloadedWorld.snapshot,
|
||||
request.actorGeneralId,
|
||||
actor.nationId,
|
||||
actor.officerLevel
|
||||
);
|
||||
expect(reloadedLifecycle).toStrictEqual(expectedAfterLifecycle);
|
||||
if (expectedSiblingRulerLifecycle) {
|
||||
expect(
|
||||
projectDomainCommandLifecycleState(
|
||||
reloadedWorld.snapshot,
|
||||
siblingRulerGeneralId,
|
||||
actor.nationId,
|
||||
12
|
||||
)
|
||||
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||
}
|
||||
if (entry.action === 'che_증축') {
|
||||
expect(reloadedLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 });
|
||||
}
|
||||
|
||||
const reloadedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 });
|
||||
await reloadedTurns.loadAll();
|
||||
expect(projectReloadedGeneralTurns(reloadedTurns, request.actorGeneralId)).toStrictEqual(
|
||||
expected.after.generalTurns.filter((turn) => turn.generalId === request.actorGeneralId)
|
||||
);
|
||||
expect(projectReloadedNationTurns(reloadedTurns, actor.nationId, actor.officerLevel)).toStrictEqual(
|
||||
expected.after.nationTurns.filter(
|
||||
(turn) => turn.nationId === actor.nationId && turn.officerLevel === actor.officerLevel
|
||||
)
|
||||
);
|
||||
if (expectedSiblingRulerLifecycle) {
|
||||
expect(projectReloadedNationTurns(reloadedTurns, actor.nationId, 12)).toStrictEqual(
|
||||
siblingRulerTurnQueueBefore
|
||||
);
|
||||
}
|
||||
expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual(
|
||||
expectedSiblingTurnRevisionSentinels
|
||||
);
|
||||
},
|
||||
180_000
|
||||
);
|
||||
});
|
||||
@@ -39,6 +39,13 @@ const databaseSnapshot = (
|
||||
cityId: 1,
|
||||
troopId: 1,
|
||||
userId: 'owner-a',
|
||||
personalCode: 'che_안전',
|
||||
specialCode: 'che_농업',
|
||||
special2Code: 'che_신산',
|
||||
horseCode: 'che_명마_01_적토마',
|
||||
weaponCode: 'che_무기_07_맥궁',
|
||||
bookCode: 'che_서적_01_손자병법',
|
||||
itemCode: 'che_도구_01_옥새',
|
||||
meta: commandStateFixture?.generalMeta ?? {},
|
||||
penalty: {},
|
||||
...commandStateFixture?.generalFields,
|
||||
@@ -92,6 +99,18 @@ const databaseSnapshot = (
|
||||
});
|
||||
|
||||
describe('turn snapshot canonical blind-spot coverage', () => {
|
||||
it('projects Prisma general role and item column names into canonical fields', () => {
|
||||
expect(databaseSnapshot().generals[0]).toMatchObject({
|
||||
personality: 'che_안전',
|
||||
specialDomestic: 'che_농업',
|
||||
specialWar: 'che_신산',
|
||||
itemHorse: 'che_명마_01_적토마',
|
||||
itemWeapon: 'che_무기_07_맥궁',
|
||||
itemBook: 'che_서적_01_손자병법',
|
||||
itemExtra: 'che_도구_01_옥새',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects troop rows and detects a troop mutant', () => {
|
||||
const reference = databaseSnapshot();
|
||||
const core = {
|
||||
|
||||
@@ -41,7 +41,7 @@ node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z
|
||||
run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag}
|
||||
export CONDITIONAL_INTEGRATION_RUN_ID=$run_id
|
||||
schema_ownership_token="sammo-conditional-integration:$run_id"
|
||||
supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_full_lifecycle reference_live_sortie reference_npc_possession select_pool web_push_gateway"
|
||||
supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_command_durable_matrix reference_full_lifecycle reference_live_sortie reference_npc_possession security_transport select_pool web_push_gateway"
|
||||
term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10}
|
||||
case "$term_grace_seconds" in
|
||||
''|*[!0-9]*)
|
||||
@@ -62,8 +62,10 @@ immediate_action_schema=${IMMEDIATE_ACTION_INTEGRATION_SCHEMA:-ci_${run_id}_imme
|
||||
gateway_runtime_schema=${GATEWAY_RUNTIME_INTEGRATION_SCHEMA:-ci_${run_id}_gateway_runtime_integration}
|
||||
web_push_gateway_schema=${WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA:-ci_${run_id}_web_push_integration}
|
||||
read_model_journal_schema=${READ_MODEL_JOURNAL_INTEGRATION_SCHEMA:-ci_${run_id}_read_model_journal_integration}
|
||||
security_transport_schema=${SECURITY_TRANSPORT_SCHEMA:-ci_${run_id}_security_transport}
|
||||
npc_possession_differential_schema=${NPC_POSSESSION_DIFFERENTIAL_SCHEMA:-ci_${run_id}_npc_possession_differential}
|
||||
live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-ci_${run_id}_live_sortie_persistence}
|
||||
turn_command_durable_matrix_schema=${TURN_COMMAND_DURABLE_MATRIX_SCHEMA:-ci_${run_id}_turn_command_durable_matrix}
|
||||
turn_full_lifecycle_schema=${TURN_FULL_LIFECYCLE_PERSISTENCE_SCHEMA:-ci_${run_id}_turn_full_lifecycle_persistence}
|
||||
|
||||
for schema in \
|
||||
@@ -76,8 +78,10 @@ for schema in \
|
||||
"$gateway_runtime_schema" \
|
||||
"$web_push_gateway_schema" \
|
||||
"$read_model_journal_schema" \
|
||||
"$security_transport_schema" \
|
||||
"$npc_possession_differential_schema" \
|
||||
"$live_sortie_schema" \
|
||||
"$turn_command_durable_matrix_schema" \
|
||||
"$turn_full_lifecycle_schema"; do
|
||||
case "$schema" in
|
||||
''|[!a-z_]*|*[!a-z0-9_]*)
|
||||
@@ -196,6 +200,7 @@ delete_owned_redis_keys() {
|
||||
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID;
|
||||
const patterns = [
|
||||
`sammo:game:*:che:security-http-${runId}:*`,
|
||||
`sammo:che:security-http-${runId}:*`,
|
||||
`sammo:game:*:che:nation-html-${runId}:*`,
|
||||
`sammo:che:battle-sim-e2e-${runId}-*:battle-sim:*`,
|
||||
];
|
||||
@@ -443,14 +448,14 @@ run_marked_tests() {
|
||||
|
||||
run_redis_only_tests() {
|
||||
package_dir=app/game-api
|
||||
database_marker=$1
|
||||
database_markers=$1
|
||||
test_files=$(
|
||||
cd "$workspace_root/$package_dir"
|
||||
redis_files=$(rg -l 'process\.env\.REDIS_URL' test -g '*.integration.test.ts' | sort)
|
||||
# Files already selected through a database marker run once in that
|
||||
# database group, where Redis is also available.
|
||||
# shellcheck disable=SC2086
|
||||
rg --files-without-match "$database_marker" $redis_files
|
||||
rg --files-without-match "$database_markers" $redis_files
|
||||
)
|
||||
if [ -z "$test_files" ]; then
|
||||
echo "no Redis-only integration tests found under $package_dir" >&2
|
||||
@@ -473,6 +478,8 @@ pnpm --filter @sammo-ts/game-engine build
|
||||
|
||||
GATEWAY_MIGRATION_TEST_DATABASE_URL=$base_database_url \
|
||||
pnpm --filter @sammo-ts/infra verify:migration:account-icon
|
||||
GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL=$base_database_url \
|
||||
pnpm --filter @sammo-ts/infra verify:migration:outbox-utc
|
||||
|
||||
cleanup_resources_started=1
|
||||
create_owned_schema "$integration_schema"
|
||||
@@ -518,6 +525,22 @@ run_marked_tests app/game-engine \
|
||||
"$(markers_for_mode read_model_journal)" \
|
||||
"read_model_journal_engine_postgresql"
|
||||
|
||||
create_owned_schema "$security_transport_schema"
|
||||
security_transport_database_url=$(build_database_url "$security_transport_schema")
|
||||
(
|
||||
export POSTGRES_SCHEMA=$security_transport_schema
|
||||
export DATABASE_URL=$security_transport_database_url
|
||||
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game
|
||||
)
|
||||
export POSTGRES_SCHEMA=$security_transport_schema
|
||||
export DATABASE_URL=$security_transport_database_url
|
||||
export SECURITY_TRANSPORT_DATABASE_URL=$security_transport_database_url
|
||||
run_marked_tests app/game-api \
|
||||
"$(markers_for_mode security_transport)" \
|
||||
"security_transport_postgresql"
|
||||
export POSTGRES_SCHEMA=$integration_schema
|
||||
export DATABASE_URL=$database_url
|
||||
|
||||
create_owned_schema "$create_general_schema"
|
||||
create_general_database_url=$(build_database_url "$create_general_schema")
|
||||
(
|
||||
@@ -672,6 +695,20 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
|
||||
"$(markers_for_mode reference_live_sortie)" \
|
||||
"live_sortie_postgresql"
|
||||
|
||||
create_owned_schema "$turn_command_durable_matrix_schema"
|
||||
turn_command_durable_matrix_database_url=$(build_database_url "$turn_command_durable_matrix_schema")
|
||||
(
|
||||
export POSTGRES_SCHEMA=$turn_command_durable_matrix_schema
|
||||
export DATABASE_URL=$turn_command_durable_matrix_database_url
|
||||
pnpm --filter @sammo-ts/infra prisma:db:push:game
|
||||
)
|
||||
export POSTGRES_SCHEMA=$turn_command_durable_matrix_schema
|
||||
export DATABASE_URL=$turn_command_durable_matrix_database_url
|
||||
export TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL=$turn_command_durable_matrix_database_url
|
||||
run_marked_tests tools/integration-tests \
|
||||
"$(markers_for_mode reference_command_durable_matrix)" \
|
||||
"turn_command_durable_matrix_postgresql"
|
||||
|
||||
create_owned_schema "$turn_full_lifecycle_schema"
|
||||
turn_full_lifecycle_database_url=$(build_database_url "$turn_full_lifecycle_schema")
|
||||
(
|
||||
@@ -689,7 +726,8 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
|
||||
export DATABASE_URL=$database_url
|
||||
fi
|
||||
|
||||
run_redis_only_tests "$core_database_markers"
|
||||
all_database_markers=$(cut -f1 "$validated_registry_file" | paste -sd '|' -)
|
||||
run_redis_only_tests "$all_database_markers"
|
||||
|
||||
scenario_database_url=$(build_database_url "$scenario_schema")
|
||||
export POSTGRES_SCHEMA=$scenario_schema
|
||||
|
||||
Reference in New Issue
Block a user