import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { cancelGame } from '../src/scenario/gameCancellation.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const serverId = 'che_game_cancellation_fixture'; const userId = 'game-cancellation-user'; const generalId = 9_851; const openedAt = new Date('2026-08-18T00:00:00.000Z'); const cancelledAt = new Date('2026-08-18T01:00:00.000Z'); integration('game cancellation transaction', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; const cleanup = async (): Promise => { await db.gameCancellation.deleteMany({ where: { serverId } }); await db.gameInheritanceBaseline.deleteMany({ where: { serverId } }); await db.unificationFinalization.deleteMany({ where: { serverId } }); await db.yearbookHistory.deleteMany({ where: { profileName: serverId } }); await db.emperor.deleteMany({ where: { serverId } }); await db.oldGeneral.deleteMany({ where: { serverId } }); await db.oldNation.deleteMany({ where: { serverId } }); await db.hallOfFame.deleteMany({ where: { serverId } }); await db.inheritanceResult.deleteMany({ where: { serverId } }); await db.inheritanceLog.deleteMany({ where: { userId } }); await db.inheritancePoint.deleteMany({ where: { userId } }); await db.gameHistory.deleteMany({ where: { serverId } }); await db.logEntry.deleteMany({ where: { generalId } }); await db.rankData.deleteMany({ where: { generalId } }); await db.general.deleteMany({ where: { id: generalId } }); await db.worldState.deleteMany({ where: { scenarioCode: 'game-cancellation-fixture' } }); }; const seed = async (): Promise => { await db.worldState.create({ data: { scenarioCode: 'game-cancellation-fixture', currentYear: 190, currentMonth: 7, tickSeconds: 600, meta: { serverId, season: 7, isUnited: 0 }, }, }); await db.gameHistory.create({ data: { serverId, date: openedAt, season: 7, scenario: 1010, scenarioName: '취소 테스트', status: 'OPEN', env: { meta: { serverId, season: 7 } }, }, }); await db.general.create({ data: { id: generalId, userId, name: '취소장수', turnTime: openedAt, meta: { inherit_spent_dyn: 4_500 }, }, }); await db.rankData.createMany({ data: [ { generalId, nationId: 0, type: 'inherit_spent_dyn', value: 4_500 }, { generalId, nationId: 0, type: 'warnum', value: 10 }, ], }); await db.inheritancePoint.createMany({ data: [ { userId, key: 'previous', value: 7_000 }, { userId, key: 'max_domestic_critical', value: 200 }, ], }); await db.gameInheritanceBaseline.create({ data: { serverId, userId, openingPoint: 10_000, source: 'OPENING' }, }); await db.inheritanceLog.create({ data: { userId, serverId, year: 190, month: 7, text: '신규/복귀 생성으로 포인트 1500 지급', createdAt: new Date('2026-08-18T00:30:00.000Z'), }, }); await db.oldGeneral.create({ data: { serverId, generalNo: generalId - 1, owner: userId, name: '사망장수', lastYearMonth: 19006, turnTime: openedAt, data: { meta: { inherit_spent_dyn: 0 } }, }, }); await db.hallOfFame.create({ data: { serverId, season: 7, scenario: 1010, generalNo: generalId, type: 'warnum', value: 10 }, }); await db.oldNation.create({ data: { serverId, nation: 1, sourceId: 1 } }); await db.emperor.create({ data: { serverId, name: '취소 황제' } }); await db.yearbookHistory.create({ data: { profileName: serverId, year: 190, month: 7, map: {}, nations: {} }, }); await db.unificationFinalization.create({ data: { generationKey: `${serverId}:fixture`, serverId, profileName: 'che', winnerNation: 1, year: 190, month: 7, completedAt: cancelledAt, }, }); await db.inheritanceResult.create({ data: { serverId, owner: userId, generalId, year: 190, month: 7, value: { refund: 0 }, }, }); }; beforeAll(async () => { const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); }); beforeEach(async () => { await cleanup(); await seed(); }); afterAll(async () => { await cleanup(); await closeDb?.(); }); it('rolls back a late failure, then refunds spending and retains selected earnings exactly once', async () => { const request = { cancellationId: 'game-cancellation-retain-fixture', databaseUrl: databaseUrl!, cancelledBy: 'admin', reason: '잘못 연 게임 취소', historyMode: 'RETAIN_ABANDONED' as const, generalMode: 'RETAIN' as const, earnedPointRetentionPercent: 40, cancelledAt, }; await expect(cancelGame({ ...request, cancelledBy: 'admin\0invalid' })).rejects.toThrow(); await expect(db.gameHistory.findUniqueOrThrow({ where: { serverId } })).resolves.toMatchObject({ status: 'OPEN', }); await expect( db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } }) ).resolves.toMatchObject({ value: 7_000 }); await expect(db.hallOfFame.count({ where: { serverId } })).resolves.toBe(1); await expect(db.gameCancellation.count({ where: { serverId } })).resolves.toBe(0); const result = await cancelGame(request); expect(result).toMatchObject({ participantCount: 1, preservedGeneralCount: 2, alreadyApplied: false, settlements: { [userId]: { openingPoint: 10_000, currentPoint: 7_000, earnedPoint: 1_750, retainedEarnedPoint: 700, finalPoint: 10_700, baselineSource: 'OPENING', }, }, }); await expect( db.inheritancePoint.findMany({ where: { userId }, orderBy: { key: 'asc' } }) ).resolves.toMatchObject([{ key: 'previous', value: 10_700 }]); await expect(db.gameHistory.findUniqueOrThrow({ where: { serverId } })).resolves.toMatchObject({ status: 'ABANDONED', winnerNation: null, }); const archived = await db.oldGeneral.findMany({ where: { serverId }, orderBy: { generalNo: 'asc' } }); expect(archived).toHaveLength(2); expect(archived.every((row) => JSON.stringify(row.data).includes(request.cancellationId))).toBe(true); await expect(db.hallOfFame.count({ where: { serverId } })).resolves.toBe(0); await expect(db.oldNation.count({ where: { serverId } })).resolves.toBe(0); await expect(db.emperor.count({ where: { serverId } })).resolves.toBe(0); await expect(db.yearbookHistory.count({ where: { profileName: serverId } })).resolves.toBe(0); await expect(db.unificationFinalization.count({ where: { serverId } })).resolves.toBe(0); await expect(db.inheritanceResult.count({ where: { serverId } })).resolves.toBe(0); await expect(db.worldState.findFirstOrThrow()).resolves.toMatchObject({ meta: expect.objectContaining({ isCancelled: 1, cancellationId: request.cancellationId }), }); await expect(cancelGame(request)).resolves.toMatchObject({ alreadyApplied: true }); await expect( db.inheritanceLog.count({ where: { userId, text: { startsWith: '취소 게임 정산:' } } }) ).resolves.toBe(1); }); it('physically deletes the numbered history row and past-play general archive on request', async () => { const result = await cancelGame({ cancellationId: 'game-cancellation-delete-fixture', databaseUrl: databaseUrl!, cancelledBy: 'admin', reason: '기수와 장수 기록 삭제', historyMode: 'DELETE', generalMode: 'DELETE', earnedPointRetentionPercent: 0, cancelledAt, }); expect(result).toMatchObject({ participantCount: 1, preservedGeneralCount: 0, alreadyApplied: false }); await expect(db.gameHistory.findUnique({ where: { serverId } })).resolves.toBeNull(); await expect(db.oldGeneral.count({ where: { serverId } })).resolves.toBe(0); await expect(db.gameCancellation.findUnique({ where: { serverId } })).resolves.toMatchObject({ originalSeason: 7, historyMode: 'DELETE', generalMode: 'DELETE', }); }); });