test live sortie postgres persistence matrix

This commit is contained in:
2026-07-27 00:02:48 +00:00
parent 0ebb89f3a7
commit d8220a18b3
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { asRecord } from '@sammo-ts/common';
import { buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js';
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js';
@@ -49,10 +49,10 @@ const assertDedicatedDatabase = (rawUrl: string): void => {
}
};
const readFixture = (): TurnCommandFixtureRequest => {
const readFixture = (fixtureName: string): TurnCommandFixtureRequest => {
const fixturePath = path.join(
workspaceRoot!,
'docker_compose_files/reference/fixtures/turn-differential/live-sortie-defender.json'
`docker_compose_files/reference/fixtures/turn-differential/${fixtureName}`
);
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as TurnCommandFixtureRequest;
return {
@@ -81,6 +81,7 @@ const readFixture = (): TurnCommandFixtureRequest => {
const cleanup = async (db: GamePrismaClient): Promise<void> => {
await db.logEntry.deleteMany();
await db.oldNation.deleteMany();
await db.rankData.deleteMany();
await db.generalTurn.deleteMany();
await db.generalTurnRevision.deleteMany();
@@ -107,13 +108,28 @@ integration('live sortie PostgreSQL persistence retry', () => {
await cleanup(db);
});
beforeEach(async () => {
await cleanup(db);
});
afterAll(async () => {
await cleanup(db);
await disconnect?.();
});
it('rolls back a lost queue lease, retains dirty state, and flushes the same sortie exactly once on retry', async () => {
const request = readFixture();
it.each([
['conquest and nation collapse', 'live-sortie-conquest.json'],
['single defending general', 'live-sortie-defender.json'],
['multiple defending generals', 'live-sortie-multiple-defenders.json'],
['supply retreat', 'live-sortie-supply-retreat.json'],
['noncapital conquest', 'live-sortie-noncapital-conquest.json'],
['emergency capital', 'live-sortie-emergency-capital.json'],
['conflict arbitration', 'live-sortie-conflict-arbitration.json'],
['tied conflict', 'live-sortie-conflict-tie.json'],
])(
'%s rolls back a lost lease and flushes exactly once on retry',
async (_label, fixtureName) => {
const request = readFixture(fixtureName);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
@@ -292,12 +308,11 @@ integration('live sortie PostgreSQL persistence retry', () => {
const dirtyBeforeFailure = world.peekDirtyState();
expect(dirtyBeforeFailure.generals.length).toBeGreaterThan(0);
expect(dirtyBeforeFailure.logs.length).toBeGreaterThan(0);
expect(
dirtyBeforeFailure.generals.find((general) => general.id === request.actorGeneralId)?.recentWarTime
).toEqual(new Date('2026-07-26T13:38:45.000Z'));
expect(dirtyBeforeFailure.generals.find((general) => general.id === 2)?.recentWarTime).toEqual(
new Date('2026-07-26T13:38:45.000Z')
for (const dirtyGeneral of dirtyBeforeFailure.generals) {
expect(dirtyGeneral.recentWarTime?.toISOString() ?? null).toBe(
expected.after.generals.find((general) => general.id === dirtyGeneral.id)?.recentWarTime ?? null
);
}
expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
@@ -327,6 +342,7 @@ integration('live sortie PostgreSQL persistence retry', () => {
expect(rolledBackActor.recentWarTime).toBeNull();
expect(rolledBackTurns[0]?.actionCode).toBe('che_출병');
expect(await db.logEntry.count()).toBe(0);
expect(await db.oldNation.count()).toBe(0);
expect(world.peekDirtyState().logs).toHaveLength(dirtyBeforeFailure.logs.length);
expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId);
@@ -339,23 +355,61 @@ integration('live sortie PostgreSQL persistence retry', () => {
});
await hooks.hooks.flushChanges?.(turnRunResult);
const persistedActor = await db.general.findUniqueOrThrow({
where: { id: request.actorGeneralId },
});
const persistedDefender = await db.general.findUniqueOrThrow({ where: { id: 2 } });
const persistedTurns = await db.generalTurn.findMany({
where: { generalId: request.actorGeneralId },
orderBy: { turnIdx: 'asc' },
});
const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId);
const expectedDefender = expected.after.generals.find((general) => general.id === 2);
const persistedActor = await db.general.findUniqueOrThrow({
where: { id: request.actorGeneralId },
});
expect(persistedActor.turnTime.toISOString()).toBe(expectedActor?.turnTime);
expect(persistedActor.recentWarTime?.toISOString()).toBe(expectedActor?.recentWarTime);
expect(persistedDefender.recentWarTime?.toISOString()).toBe(expectedDefender?.recentWarTime);
expect(asRecord(persistedActor.meta).myset).toBe(expectedActor?.mySet);
expect(asRecord(persistedActor.meta).killturn).toBe(expectedActor?.killTurn);
expect(asRecord(persistedActor.lastTurn).command).toBe(asRecord(expectedActor?.lastTurn).command);
expect(persistedTurns[0]?.actionCode).toBe('휴식');
for (const expectedGeneral of expected.after.generals) {
const persistedGeneral = await db.general.findUniqueOrThrow({
where: { id: Number(expectedGeneral.id) },
});
expect(persistedGeneral).toMatchObject({
nationId: expectedGeneral.nationId,
cityId: expectedGeneral.cityId,
crew: expectedGeneral.crew,
});
expect(persistedGeneral.turnTime.toISOString()).toBe(expectedGeneral.turnTime);
expect(persistedGeneral.recentWarTime?.toISOString() ?? null).toBe(expectedGeneral.recentWarTime);
}
for (const expectedCity of expected.after.cities) {
const persistedCity = await db.city.findUniqueOrThrow({
where: { id: Number(expectedCity.id) },
});
expect(persistedCity).toMatchObject({
nationId: expectedCity.nationId,
defence: expectedCity.defence,
conflict: expectedCity.conflict,
});
}
for (const beforeNation of expected.before.nations) {
const expectedNation = expected.after.nations.find((nation) => nation.id === beforeNation.id);
const persistedNation = await db.nation.findUnique({
where: { id: Number(beforeNation.id) },
});
if (!expectedNation) {
expect(persistedNation).toBeNull();
} else {
expect(persistedNation).toMatchObject({
capitalCityId: expectedNation.capitalCityId,
gold: expectedNation.gold,
rice: expectedNation.rice,
});
}
}
const deletedNationCount = expected.before.nations.filter(
(nation) => !expected.after.nations.some((afterNation) => afterNation.id === nation.id)
).length;
expect(await db.oldNation.count()).toBe(deletedNationCount);
expect(await db.logEntry.count()).toBe(expected.after.logs.length);
expect(world.peekDirtyState().logs).toEqual([]);
expect(reservedTurns.peekDirtyState().generalIds).toEqual([]);
@@ -364,8 +418,10 @@ integration('live sortie PostgreSQL persistence retry', () => {
where: { generalId: request.actorGeneralId },
});
const logCount = await db.logEntry.count();
const oldNationCount = await db.oldNation.count();
await hooks.hooks.flushChanges?.(turnRunResult);
expect(await db.logEntry.count()).toBe(logCount);
expect(await db.oldNation.count()).toBe(oldNationCount);
expect(
await db.generalTurnRevision.findUniqueOrThrow({
where: { generalId: request.actorGeneralId },
@@ -377,5 +433,7 @@ integration('live sortie PostgreSQL persistence retry', () => {
} finally {
await hooks.close();
}
}, 120_000);
},
120_000
);
});