Files
core2026/app/game-engine/test/playAuditRetention.integration.test.ts
T

252 lines
11 KiB
TypeScript

import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { AUDIT_RETENTION_BATCH_SIZE, prunePreviousAuditBatch } from '../src/playAudit/retention.js';
const databaseUrl = process.env.PLAY_AUDIT_RETENTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
integration('bounded previous-season audit retention', () => {
let db: GamePrismaClient;
let close: () => Promise<void>;
beforeAll(async () => {
if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('_retention_fixture')) {
throw new Error('Audit retention requires its dedicated fixture schema');
}
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
});
afterAll(async () => {
await close?.();
});
it('keeps the active season, bounds each transaction and retries after rollback', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.deleteMany();
const world = await db.worldState.create({
data: {
scenarioCode: 'audit-retention',
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: { serverId: 'active' },
},
});
const sample = (id: string, serverId: string) => ({
id,
serverId,
year: 190,
month: 1,
kind: 'MONTH_END',
hash: id,
settlementsComplete: true,
});
await db.playAuditMonth.createMany({ data: [sample('old-sample', 'old'), sample('active-sample', 'active')] });
await db.playAuditGeneral.createMany({
data: Array.from({ length: 401 }, (_, index) => ({
sampleId: 'old-sample',
generalId: index + 1,
nationId: 1,
cityId: 1,
npcState: 2,
data: { marker: 'old' },
})),
});
await db.playAuditCity.createMany({
data: Array.from({ length: 201 }, (_, index) => ({
sampleId: 'old-sample',
cityId: index + 1,
nationId: 1,
data: {},
})),
});
await db.playAuditNation.create({ data: { sampleId: 'old-sample', nationId: 1, data: {} } });
await db.playAuditGeneral.create({
data: {
sampleId: 'active-sample',
generalId: 1,
nationId: 1,
cityId: 1,
npcState: 2,
data: { marker: 'preserved' },
},
});
expect(await prunePreviousAuditBatch(db, 'wrong')).toEqual({ status: 'identityChanged', deleted: 0 });
expect(await prunePreviousAuditBatch(db, '')).toEqual({ status: 'identityChanged', deleted: 0 });
expect(await prunePreviousAuditBatch(db, 'active')).toEqual({
status: 'progress',
deleted: AUDIT_RETENTION_BATCH_SIZE,
});
expect(await db.playAuditGeneral.count({ where: { sampleId: 'old-sample' } })).toBe(201);
await db.$executeRawUnsafe(
`CREATE OR REPLACE FUNCTION audit_retention_test_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'retention fixture rollback'; END $$`
);
await db.$executeRawUnsafe(
`CREATE TRIGGER audit_retention_test_failure BEFORE DELETE ON play_audit_general FOR EACH ROW EXECUTE FUNCTION audit_retention_test_failure()`
);
try {
await expect(prunePreviousAuditBatch(db, 'active')).rejects.toThrow();
expect(await db.playAuditGeneral.count({ where: { sampleId: 'old-sample' } })).toBe(201);
} finally {
await db.$executeRawUnsafe('DROP TRIGGER audit_retention_test_failure ON play_audit_general');
await db.$executeRawUnsafe('DROP FUNCTION audit_retention_test_failure()');
}
// A RESET holder makes cleanup defer without waiting for the reset transaction.
await db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(current_schema(), 0))::text`;
expect(await prunePreviousAuditBatch(db, 'active')).toEqual({ status: 'busy', deleted: 0 });
});
const deleted = [];
for (let attempt = 0; attempt < 10; attempt++) {
const result = await prunePreviousAuditBatch(db, 'active');
if (result.status === 'complete') break;
expect(result.status).toBe('progress');
expect(result.deleted).toBeLessThanOrEqual(AUDIT_RETENTION_BATCH_SIZE);
deleted.push(result.deleted);
}
expect(deleted).toEqual([200, 1, 200, 1, 1, 1]);
expect(await db.playAuditMonth.findUnique({ where: { id: 'old-sample' } })).toBeNull();
expect(
await db.playAuditGeneral.findUnique({
where: { sampleId_generalId: { sampleId: 'active-sample', generalId: 1 } },
})
).toMatchObject({ data: { marker: 'preserved' } });
expect(await prunePreviousAuditBatch(db, 'active')).toEqual({ status: 'complete', deleted: 0 });
// A worker from the old runtime must stop after the world identity changes.
await db.worldState.update({ where: { id: world.id }, data: { meta: { serverId: 'next' } } });
expect(await prunePreviousAuditBatch(db, 'active')).toEqual({ status: 'identityChanged', deleted: 0 });
expect(await db.playAuditMonth.count()).toBe(1);
});
it('starts bounded cleanup only after seed commits, including a reserved opening', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.deleteMany();
await db.worldState.create({
data: {
scenarioCode: 'before-reset',
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: { serverId: 'before-reset' },
},
});
await db.playAuditMonth.create({
data: {
id: 'reset-old-sample',
serverId: 'before-reset',
year: 190,
month: 1,
kind: 'MONTH_END',
hash: 'reset',
settlementsComplete: true,
},
});
await db.playAuditGeneral.createMany({
data: Array.from({ length: 201 }, (_, index) => ({
sampleId: 'reset-old-sample',
generalId: index + 1,
nationId: 1,
cityId: 1,
npcState: 2,
data: {},
})),
});
const options = {
scenarioId: 1010,
databaseUrl: databaseUrl!,
resetTables: true,
now: new Date('2030-01-01T00:00:00Z'),
wallNow: new Date('2030-01-01T00:00:00Z'),
installOptions: {
serverId: 'after-reset',
preopenAt: new Date('2030-01-03T00:00:00Z'),
openAt: new Date('2030-01-04T00:00:00Z'),
},
};
await expect(
seedScenarioToDatabase({
...options,
onBeforeCommit: async () => {
throw new Error('seed fixture rollback');
},
})
).rejects.toThrow('seed fixture rollback');
expect(await db.playAuditGeneral.count({ where: { sampleId: 'reset-old-sample' } })).toBe(201);
expect(await db.worldState.findFirst()).toMatchObject({ meta: { serverId: 'before-reset' } });
const result = await seedScenarioToDatabase(options);
expect(result.applied).toBe(true);
expect(await db.worldState.findFirst()).toMatchObject({ meta: { serverId: 'after-reset' } });
expect(await db.playAuditGeneral.count({ where: { sampleId: 'reset-old-sample' } })).toBe(1);
expect(result.warnings).toContainEqual({
code: 'audit_retention_pending',
message: '이전 플레이 감사 자료의 나머지는 서버 시작 후 정리합니다.',
});
});
it('prunes old policy versions by key and preserves the active policy history', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.worldState.updateMany({ data: { meta: { serverId: 'policy-active' } } });
const row = (id: string, serverId: string, revision: number) => ({
id,
serverId,
nationId: 1,
area: 'DEFENCE',
revision,
source: 'BASELINE',
year: 190,
month: 1,
ordinal: revision,
after: {},
hash: id,
});
await db.playAuditPolicy.createMany({
data: [
...Array.from({ length: 201 }, (_, index) => row(`old-policy-${index}`, 'old-policy', index + 1)),
row('active-policy', 'policy-active', 1),
],
});
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'progress', deleted: 200 });
expect(await db.playAuditPolicy.count({ where: { serverId: 'old-policy' } })).toBe(1);
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'progress', deleted: 1 });
expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'complete', deleted: 0 });
expect(await db.playAuditPolicy.findUnique({ where: { id: 'active-policy' } })).not.toBeNull();
});
it('prunes diplomacy history in bounded batches without touching the active season', async () => {
await db.playAuditMonth.deleteMany();
await db.playAuditPolicy.deleteMany();
await db.playAuditDiplomacyEvent.deleteMany();
await db.worldState.updateMany({ data: { meta: { serverId: 'diplomacy-active' } } });
const row = (id: string, serverId: string, ordinal: number) => ({
id,
serverId,
ordinal,
executionId: 'fixture',
nationA: 1,
nationB: 2,
srcNationId: 1,
destNationId: 2,
category: 'DOCUMENT',
source: 'API',
eventType: 'LETTER_PROPOSED',
year: 190,
month: 1,
hash: id,
});
await db.playAuditDiplomacyEvent.createMany({
data: [
...Array.from({ length: 201 }, (_, index) => row(`old-diplomacy-${index}`, 'old-diplomacy', index + 1)),
row('active-diplomacy', 'diplomacy-active', 1),
],
});
expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'progress', deleted: 200 });
expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'progress', deleted: 1 });
expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'complete', deleted: 0 });
expect(await db.playAuditDiplomacyEvent.findUnique({ where: { id: 'active-diplomacy' } })).not.toBeNull();
await db.playAuditDiplomacyEvent.deleteMany();
});
});