test live sortie postgres persistence matrix
This commit is contained in:
@@ -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
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user