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 fs from 'node:fs';
import path from 'node:path'; 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 { asRecord } from '@sammo-ts/common';
import { buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js'; import { buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js';
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.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( const fixturePath = path.join(
workspaceRoot!, 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; const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as TurnCommandFixtureRequest;
return { return {
@@ -81,6 +81,7 @@ const readFixture = (): TurnCommandFixtureRequest => {
const cleanup = async (db: GamePrismaClient): Promise<void> => { const cleanup = async (db: GamePrismaClient): Promise<void> => {
await db.logEntry.deleteMany(); await db.logEntry.deleteMany();
await db.oldNation.deleteMany();
await db.rankData.deleteMany(); await db.rankData.deleteMany();
await db.generalTurn.deleteMany(); await db.generalTurn.deleteMany();
await db.generalTurnRevision.deleteMany(); await db.generalTurnRevision.deleteMany();
@@ -107,13 +108,28 @@ integration('live sortie PostgreSQL persistence retry', () => {
await cleanup(db); await cleanup(db);
}); });
beforeEach(async () => {
await cleanup(db);
});
afterAll(async () => { afterAll(async () => {
await cleanup(db); await cleanup(db);
await disconnect?.(); await disconnect?.();
}); });
it('rolls back a lost queue lease, retains dirty state, and flushes the same sortie exactly once on retry', async () => { it.each([
const request = readFixture(); ['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( const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!, workspaceRoot!,
request as unknown as Record<string, unknown> request as unknown as Record<string, unknown>
@@ -292,12 +308,11 @@ integration('live sortie PostgreSQL persistence retry', () => {
const dirtyBeforeFailure = world.peekDirtyState(); const dirtyBeforeFailure = world.peekDirtyState();
expect(dirtyBeforeFailure.generals.length).toBeGreaterThan(0); expect(dirtyBeforeFailure.generals.length).toBeGreaterThan(0);
expect(dirtyBeforeFailure.logs.length).toBeGreaterThan(0); expect(dirtyBeforeFailure.logs.length).toBeGreaterThan(0);
expect( for (const dirtyGeneral of dirtyBeforeFailure.generals) {
dirtyBeforeFailure.generals.find((general) => general.id === request.actorGeneralId)?.recentWarTime expect(dirtyGeneral.recentWarTime?.toISOString() ?? null).toBe(
).toEqual(new Date('2026-07-26T13:38:45.000Z')); expected.after.generals.find((general) => general.id === dirtyGeneral.id)?.recentWarTime ?? null
expect(dirtyBeforeFailure.generals.find((general) => general.id === 2)?.recentWarTime).toEqual(
new Date('2026-07-26T13:38:45.000Z')
); );
}
expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId); expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
@@ -327,6 +342,7 @@ integration('live sortie PostgreSQL persistence retry', () => {
expect(rolledBackActor.recentWarTime).toBeNull(); expect(rolledBackActor.recentWarTime).toBeNull();
expect(rolledBackTurns[0]?.actionCode).toBe('che_출병'); expect(rolledBackTurns[0]?.actionCode).toBe('che_출병');
expect(await db.logEntry.count()).toBe(0); expect(await db.logEntry.count()).toBe(0);
expect(await db.oldNation.count()).toBe(0);
expect(world.peekDirtyState().logs).toHaveLength(dirtyBeforeFailure.logs.length); expect(world.peekDirtyState().logs).toHaveLength(dirtyBeforeFailure.logs.length);
expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId); expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId);
@@ -339,23 +355,61 @@ integration('live sortie PostgreSQL persistence retry', () => {
}); });
await hooks.hooks.flushChanges?.(turnRunResult); 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({ const persistedTurns = await db.generalTurn.findMany({
where: { generalId: request.actorGeneralId }, where: { generalId: request.actorGeneralId },
orderBy: { turnIdx: 'asc' }, orderBy: { turnIdx: 'asc' },
}); });
const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId); 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.turnTime.toISOString()).toBe(expectedActor?.turnTime);
expect(persistedActor.recentWarTime?.toISOString()).toBe(expectedActor?.recentWarTime); 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).myset).toBe(expectedActor?.mySet);
expect(asRecord(persistedActor.meta).killturn).toBe(expectedActor?.killTurn); expect(asRecord(persistedActor.meta).killturn).toBe(expectedActor?.killTurn);
expect(asRecord(persistedActor.lastTurn).command).toBe(asRecord(expectedActor?.lastTurn).command); expect(asRecord(persistedActor.lastTurn).command).toBe(asRecord(expectedActor?.lastTurn).command);
expect(persistedTurns[0]?.actionCode).toBe('휴식'); 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(await db.logEntry.count()).toBe(expected.after.logs.length);
expect(world.peekDirtyState().logs).toEqual([]); expect(world.peekDirtyState().logs).toEqual([]);
expect(reservedTurns.peekDirtyState().generalIds).toEqual([]); expect(reservedTurns.peekDirtyState().generalIds).toEqual([]);
@@ -364,8 +418,10 @@ integration('live sortie PostgreSQL persistence retry', () => {
where: { generalId: request.actorGeneralId }, where: { generalId: request.actorGeneralId },
}); });
const logCount = await db.logEntry.count(); const logCount = await db.logEntry.count();
const oldNationCount = await db.oldNation.count();
await hooks.hooks.flushChanges?.(turnRunResult); await hooks.hooks.flushChanges?.(turnRunResult);
expect(await db.logEntry.count()).toBe(logCount); expect(await db.logEntry.count()).toBe(logCount);
expect(await db.oldNation.count()).toBe(oldNationCount);
expect( expect(
await db.generalTurnRevision.findUniqueOrThrow({ await db.generalTurnRevision.findUniqueOrThrow({
where: { generalId: request.actorGeneralId }, where: { generalId: request.actorGeneralId },
@@ -377,5 +433,7 @@ integration('live sortie PostgreSQL persistence retry', () => {
} finally { } finally {
await hooks.close(); await hooks.close();
} }
}, 120_000); },
120_000
);
}); });