import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { decodeAuditDecisionSteps, createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js'; import { buildAuditExecutionFixture, buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js'; import { prunePreviousAuditBatch } from '../src/playAudit/retention.js'; const databaseUrl = process.env.PLAY_AUDIT_DECISION_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); integration('decision persistence and bounded retention', () => { let db: GamePrismaClient; let close: () => Promise; beforeAll(async () => { if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('_decision_fixture')) throw new Error('Dedicated decision fixture required'); const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; close = () => connector.disconnect(); await db.playAuditDecisionChunk.deleteMany(); await db.playAuditDecision.deleteMany(); await db.nation.deleteMany({ where: { id: 990321 } }); await db.worldState.deleteMany(); await db.worldState.create({ data: { scenarioCode: 'decision', currentYear: 190, currentMonth: 1, tickSeconds: 600, config: {}, meta: { serverId: 'decision-current' }, }, }); }); afterAll(async () => { await close?.(); }); it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => { const decision = draft('decision-one'); if (decision.steps[0]?.kind === 'DECISION_START') decision.steps[0].effectivePolicy = { schemaVersion: 1, general: { priority: ['징병'], flags: { 징병: true, 출병: false } }, nation: { priority: ['천도'], flags: { 천도: true }, values: { reqNationGold: 4321, reqNationRice: 100, reqHumanWarUrgentGold: 100, reqHumanWarUrgentRice: 100, reqHumanWarRecommandGold: 100, reqHumanWarRecommandRice: 100, reqHumanDevelGold: 100, reqHumanDevelRice: 100, reqNpcWarGold: 100, reqNpcWarRice: 100, reqNpcDevelGold: 100, reqNpcDevelRice: 100, minimumResourceActionAmount: 100, maximumResourceActionAmount: 100, minNpcWarLeadership: 100, minWarCrew: 100, minNpcRecruitCityPopulation: 100, safeRecruitCityPopulationRatio: 100, properWarTrainAtmos: 100, cureThreshold: 100, }, combatForce: { '1': [2, 3] }, supportForce: [4], developForce: [5], }, }; decision.summary.codeVersion = 'a'.repeat(40); decision.summary.executionCoverage = 'ATTEMPTS'; decision.steps.push({ ...decision.steps[0]!, ...buildAuditExecutionFixture(), sequence: 302 }); await expect( db.$transaction((tx) => persistAuditDecisions(tx, [{ ...decision, steps: decision.steps.slice(0, -1) }])) ).rejects.toThrow('Incomplete play audit decision'); await db.$executeRawUnsafe( `CREATE OR REPLACE FUNCTION decision_fixture_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.ordinal=1 THEN RAISE EXCEPTION 'decision chunk failure'; END IF; RETURN NEW; END $$` ); await db.$executeRawUnsafe( `CREATE TRIGGER decision_fixture_failure BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION decision_fixture_failure()` ); try { await expect( db.$transaction(async (tx) => { await tx.nation.create({ data: { id: 990321, name: '결정국', color: '#fff' } }); await persistAuditDecisions(tx, [decision]); }) ).rejects.toThrow('decision chunk failure'); } finally { await db.$executeRawUnsafe('DROP TRIGGER decision_fixture_failure ON play_audit_decision_chunk'); } expect(await db.nation.findUnique({ where: { id: 990321 } })).toBeNull(); expect(await db.playAuditDecision.count()).toBe(0); expect(await db.playAuditDecisionChunk.count()).toBe(0); await db.$transaction(async (tx) => { await tx.nation.create({ data: { id: 990321, name: '결정국', color: '#fff' } }); await persistAuditDecisions(tx, [decision]); }); await db.$transaction((tx) => persistAuditDecisions(tx, [decision])); const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } }); expect(header).toMatchObject({ tick: 4_320_000_000n, stepCount: 303, summary: { codeVersion: 'a'.repeat(40) }, }); await expect( db.$transaction((tx) => persistAuditDecisions(tx, [ { ...decision, summary: { ...decision.summary, codeVersion: 'b'.repeat(40) } }, ]) ) ).rejects.toThrow('replay conflict'); const chunks = await db.playAuditDecisionChunk.findMany({ where: { decisionId: decision.id }, orderBy: { ordinal: 'asc' }, }); expect(chunks).toHaveLength(3); expect(chunks.flatMap((chunk) => decodeAuditDecisionSteps(chunk.steps))).toEqual(decision.steps); await expect( db.$transaction((tx) => persistAuditDecisions(tx, [{ ...decision, summary: { ...decision.summary, completed: true } }]) ) ).rejects.toThrow('replay conflict'); await expect(db.playAuditDecision.delete({ where: { id: decision.id } })).rejects.toThrow(); expect(await db.playAuditDecision.count()).toBe(1); }); it('deletes at most 200 chunks before a header and preserves current season', async () => { const active = draft('decision-active', 'decision-current'); await db.$transaction((tx) => persistAuditDecisions(tx, [active])); await db.playAuditDecisionChunk.createMany({ data: Array.from({ length: 401 }, (_, index) => ({ decisionId: 'decision-one', ordinal: index + 3, steps: [], })), }); expect(await prunePreviousAuditBatch(db, 'wrong')).toEqual({ status: 'identityChanged', deleted: 0 }); expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 }); expect(await db.playAuditDecisionChunk.count({ where: { decisionId: 'decision-one' } })).toBe(204); expect(await db.playAuditDecision.count({ where: { id: 'decision-one' } })).toBe(1); expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 }); expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 5 }); expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'complete', deleted: 0 }); expect(await db.playAuditDecision.count()).toBe(1); expect(await db.playAuditDecisionChunk.count()).toBe(3); }); it('removes many small decisions in one bounded batch and rolls chunks back on header failure', async () => { const decisions = Array.from({ length: 100 }, (_, index) => { const row = draft(`batch-old-${index}`, 'batch-old'); return { ...row, steps: [row.steps[0]!, row.steps.at(-1)!] }; }); await db.$transaction((tx) => persistAuditDecisions(tx, decisions)); await db.$executeRawUnsafe( `CREATE OR REPLACE FUNCTION decision_header_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'header fixture rollback'; END $$` ); await db.$executeRawUnsafe( `CREATE TRIGGER decision_header_failure BEFORE DELETE ON play_audit_decision FOR EACH ROW EXECUTE FUNCTION decision_header_failure()` ); try { await expect(prunePreviousAuditBatch(db, 'decision-current')).rejects.toThrow(); expect( await db.playAuditDecisionChunk.count({ where: { decisionId: { in: decisions.map(({ id }) => id) } } }) ).toBe(100); } finally { await db.$executeRawUnsafe('DROP TRIGGER decision_header_failure ON play_audit_decision'); await db.$executeRawUnsafe('DROP FUNCTION decision_header_failure()'); } expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 }); expect(await db.playAuditDecision.count()).toBe(1); expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'complete', deleted: 0 }); }); });