diff --git a/tools/integration-tests/test/reservedTurnDaemonApiRace.integration.test.ts b/tools/integration-tests/test/reservedTurnDaemonApiRace.integration.test.ts new file mode 100644 index 0000000..09e0888 --- /dev/null +++ b/tools/integration-tests/test/reservedTurnDaemonApiRace.integration.test.ts @@ -0,0 +1,94 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { DatabaseClient } from '@sammo-ts/game-api/context.js'; +import { + ReservedTurnRevisionConflictError, + getGeneralTurnSnapshot, + setGeneralTurn, +} from '@sammo-ts/game-api/turns/reservedTurns.js'; +import { InMemoryReservedTurnStore } from '@sammo-ts/game-engine/turn/reservedTurnStore.js'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; + +const databaseUrl = process.env.RESERVED_TURN_DATABASE_URL; +const describeIntegration = databaseUrl ? describe : describe.skip; +const GENERAL_ID = 2_147_400_010; + +describeIntegration('reserved turn daemon/API stale-cache race', () => { + const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null; + + beforeAll(async () => { + if (!connector) { + return; + } + await connector.connect(); + await connector.prisma.generalTurn.deleteMany({ where: { generalId: GENERAL_ID } }); + await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: GENERAL_ID } }); + await connector.prisma.generalTurn.createMany({ + data: [ + { generalId: GENERAL_ID, turnIdx: 0, actionCode: 'che_훈련', arg: {} }, + { generalId: GENERAL_ID, turnIdx: 1, actionCode: '휴식', arg: {} }, + ], + }); + await connector.prisma.generalTurnRevision.create({ + data: { generalId: GENERAL_ID, revision: 0 }, + }); + }); + + afterAll(async () => { + if (!connector) { + return; + } + await connector.prisma.generalTurn.deleteMany({ where: { generalId: GENERAL_ID } }); + await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: GENERAL_ID } }); + await connector.disconnect(); + }); + + it('refreshes an already-loaded daemon queue after an API write and serializes later writers', async () => { + if (!connector) { + throw new Error('integration connector is unavailable'); + } + const store = new InMemoryReservedTurnStore(connector.prisma, { + maxGeneralTurns: 30, + maxNationTurns: 12, + leaseOwner: 'integration-stale-cache-daemon', + leaseDurationMs: 60_000, + }); + + await store.loadAll(); + expect(store.getGeneralTurn(GENERAL_ID, 1).action).toBe('휴식'); + + const apiWrite = await connector.prisma.$transaction((transaction) => + setGeneralTurn(transaction as unknown as DatabaseClient, GENERAL_ID, 1, 'che_화계', { destCityId: 7 }, 0) + ); + expect(apiWrite.revision).toBe(1); + + await store.prepareTurnsForExecution(GENERAL_ID); + expect(store.getGeneralTurn(GENERAL_ID, 1)).toEqual({ + action: 'che_화계', + args: { destCityId: 7 }, + }); + + await expect( + connector.prisma.$transaction((transaction) => + setGeneralTurn(transaction as unknown as DatabaseClient, GENERAL_ID, 2, 'che_사기진작', {}, 1) + ) + ).rejects.toBeInstanceOf(ReservedTurnRevisionConflictError); + + store.shiftGeneralTurns(GENERAL_ID, -1); + const changes = store.peekDirtyState(); + await connector.prisma.$transaction((transaction) => store.persistChanges(transaction, changes)); + store.acknowledgeDirtyState(changes); + + const afterDaemon = await getGeneralTurnSnapshot(connector.prisma as unknown as DatabaseClient, GENERAL_ID); + expect(afterDaemon.revision).toBe(2); + expect(afterDaemon.turns[0]).toMatchObject({ + action: 'che_화계', + args: { destCityId: 7 }, + }); + + const retry = await connector.prisma.$transaction((transaction) => + setGeneralTurn(transaction as unknown as DatabaseClient, GENERAL_ID, 2, 'che_사기진작', {}, 2) + ); + expect(retry.revision).toBe(3); + }); +});