From 2106f03dc3318c3f1af76c934eb915c489d84229 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 23 Jan 2026 11:58:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=BD=EB=A7=A4=20=EC=B5=9C=EC=A2=85?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/auction/finalizer.ts | 73 ++- .../test/auctionFlow.test.ts | 620 ++++++++++++++++++ 2 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 tools/integration-tests/test/auctionFlow.test.ts diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index d21caa3..abe79a7 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -14,6 +14,9 @@ export interface AuctionFinalizer { type AuctionType = 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM'; type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED'; +const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6; +const MIN_EXTENSION_MINUTES_PER_BID = 1; + interface AuctionRow { id: number; type: AuctionType; @@ -22,6 +25,7 @@ interface AuctionRow { hostName: string | null; detail: unknown; status: AuctionStatus; + closeAt: Date; } interface AuctionBidRow { @@ -33,6 +37,7 @@ interface AuctionBidRow { interface AuctionDetailBase { title?: string; isReverse?: boolean; + availableLatestBidCloseDate?: string | null; } interface AuctionDetailResource extends AuctionDetailBase { @@ -65,6 +70,33 @@ const parseDetail = (detail: unknown): AuctionDetailResource => { return detail as AuctionDetailResource; }; +const toTurnMinutes = (tickSeconds: number): number => Math.max(1, Math.round(tickSeconds / 60)); + +const resolveTurnMinutes = async (prisma: GamePrismaClient): Promise => { + const rows = (await prisma.$queryRaw( + GamePrisma.sql`SELECT tick_seconds as "tickSeconds" FROM world_state ORDER BY id LIMIT 1` + )) as Array<{ tickSeconds: number }>; + return toTurnMinutes(rows[0]?.tickSeconds ?? 60); +}; + +const extendCloseDate = (options: { + now: Date; + closeAt: Date; + turnMinutes: number; + availableLatestBidCloseDate?: Date | null; +}): Date => { + const { now, closeAt, turnMinutes, availableLatestBidCloseDate } = options; + const extendMinutes = Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID); + const extended = new Date(now.getTime() + extendMinutes * 60 * 1000); + if (extended.getTime() <= closeAt.getTime()) { + return closeAt; + } + if (availableLatestBidCloseDate && extended.getTime() > availableLatestBidCloseDate.getTime()) { + return availableLatestBidCloseDate; + } + return extended; +}; + const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => { for (const log of logs) { world.pushLog(log); @@ -136,7 +168,8 @@ export const createAuctionFinalizer = async (options: { host_general_id as "hostGeneralId", host_name as "hostName", detail, - status + status, + close_at as "closeAt" FROM auction WHERE id = ${auctionId} ` @@ -338,6 +371,44 @@ export const createAuctionFinalizer = async (options: { } const slot = itemModule.slot; + const currentItem = bidder.role.items?.[slot] ?? null; + if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) { + const currentModule = await itemLoader.load(currentItem).catch(() => null); + if (currentModule && !currentModule.buyable) { + const turnMinutes = await resolveTurnMinutes(prisma); + const availableLatestBidCloseDate = detail.availableLatestBidCloseDate + ? new Date(detail.availableLatestBidCloseDate) + : null; + const nextCloseAt = extendCloseDate({ + now, + closeAt: auction.closeAt, + turnMinutes, + availableLatestBidCloseDate, + }); + await prisma.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET status = 'OPEN', + close_at = ${nextCloseAt}, + updated_at = ${now} + WHERE id = ${auctionId} + ` + ); + globalLogger.pushGlobalActionLog( + `유니크 경매 ${auctionId}번이 보유 제한으로 연장되었습니다.`, + LogFormat.PLAIN + ); + logs.push(...globalLogger.flush()); + pushLogs(world, logs); + await flushWorld(world, hooks); + return { + type: 'auctionFinalize', + ok: false, + auctionId, + reason: '유니크 보유 제한으로 연장', + }; + } + } world.updateGeneral(bidder.id, { role: { ...bidder.role, diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts new file mode 100644 index 0000000..360f26e --- /dev/null +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -0,0 +1,620 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; + +import { beforeAll, afterAll, describe, expect, it } from 'vitest'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; + +import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; +import { createGatewayApiServer } from '@sammo-ts/gateway-api'; +import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; +import { + createGameApiServer, + buildAuctionTimerKeys, + seedAuctionTimers, + buildTurnDaemonStreamKeys, + RedisTurnDaemonTransport, +} from '@sammo-ts/game-api'; +import { createTurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { + createGatewayPostgresConnector, + createGamePostgresConnector, + resolvePostgresConfigFromEnv, + createRedisConnector, + resolveRedisConfigFromEnv, + GamePrisma, +} from '@sammo-ts/infra'; +import { ItemLoader, ITEM_KEYS } from '@sammo-ts/logic'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +const parseEnvFile = (rawText: string): Record => { + const env: Record = {}; + const lines = rawText.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const index = trimmed.indexOf('='); + if (index < 0) { + continue; + } + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +}; + +const loadEnv = async () => { + const envPath = path.join(workspaceRoot, '.env.ci'); + const raw = await fs.readFile(envPath, 'utf8'); + const values = parseEnvFile(raw); + for (const [key, value] of Object.entries(values)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + process.env.INTEGRATION_JOIN_ALLOW_CITY ??= 'true'; + process.env.INTEGRATION_WORLD_SEED ??= 'integration-seed'; +}; + +const execCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv) => + new Promise((resolve, reject) => { + execFile(command, args, { env, cwd: workspaceRoot }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`${command} ${args.join(' ')} failed:\n${stdout}\n${stderr}`)); + return; + } + resolve(); + }); + }); + +const ensureSchema = async (schema: string) => { + const adminUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + const connector = createGatewayPostgresConnector({ url: adminUrl }); + await connector.connect(); + try { + await connector.prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + } finally { + await connector.disconnect(); + } +}; + +const truncateSchema = async (schema: string) => { + const adminUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + const connector = createGatewayPostgresConnector({ url: adminUrl }); + await connector.connect(); + try { + const rows = (await connector.prisma.$queryRawUnsafe( + `SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'` + )) as Array<{ tablename: string }>; + if (rows.length === 0) { + return; + } + const tableList = rows.map((row) => `"${schema}"."${row.tablename}"`).join(', '); + await connector.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tableList} RESTART IDENTITY CASCADE`); + } finally { + await connector.disconnect(); + } +}; + +const resetDatabase = async () => { + await ensureSchema('public'); + await ensureSchema('che'); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { + ...process.env, + POSTGRES_SCHEMA: 'public', + }); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { + ...process.env, + POSTGRES_SCHEMA: 'che', + }); + await truncateSchema('public'); + await truncateSchema('che'); +}; + +const resetRedis = async () => { + const redis = createRedisConnector(resolveRedisConfigFromEnv()); + await redis.connect(); + try { + await redis.client.flushDb(); + } finally { + await redis.disconnect(); + } +}; + +const createGatewayClient = (baseUrl: string, trpcPath: string, sessionTokenRef: { value?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseUrl}${trpcPath}`, + headers: () => + sessionTokenRef.value + ? { + 'x-session-token': sessionTokenRef.value, + } + : {}, + }), + ], + }); + +const createGameClient = (baseUrl: string, trpcPath: string, accessTokenRef: { value?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseUrl}${trpcPath}`, + headers: () => + accessTokenRef.value + ? { + authorization: `Bearer ${accessTokenRef.value}`, + } + : {}, + }), + ], + }); + +const resolveSlotField = (slot: string) => { + switch (slot) { + case 'weapon': + return 'weaponCode'; + case 'book': + return 'bookCode'; + case 'horse': + return 'horseCode'; + case 'item': + return 'itemCode'; + default: + return null; + } +}; + +const findUniqueItemPair = async () => { + const loader = new ItemLoader(); + const bySlot = new Map(); + for (const key of ITEM_KEYS) { + const module = await loader.load(key).catch(() => null); + if (!module || module.buyable) { + continue; + } + const slot = module.slot; + if (!slot) { + continue; + } + const list = bySlot.get(slot) ?? []; + list.push(key); + bySlot.set(slot, list); + } + for (const [slot, keys] of bySlot.entries()) { + if (keys.length >= 2) { + return { slot, keyA: keys[0]!, keyB: keys[1]! }; + } + } + throw new Error('unique item pair not found'); +}; + +describe('auction integration flow', () => { + let gatewayServer: Awaited> | null = null; + let gameServer: Awaited> | null = null; + let turnDaemon: Awaited> | null = null; + let turnDaemonLoop: Promise | null = null; + + let gatewayClient: ReturnType | null = null; + let gameClient: ReturnType | null = null; + let gameUrl = ''; + + let gameConnector: Awaited> | null = null; + let redisConnector: Awaited> | null = null; + + const userSessions: Array<{ + username: string; + accessToken: string; + generalId: number; + userId: string; + }> = []; + + beforeAll(async () => { + await loadEnv(); + process.chdir(workspaceRoot); + await resetDatabase(); + await resetRedis(); + + gatewayServer = await createGatewayApiServer(); + await gatewayServer.app.listen({ + host: gatewayServer.config.host, + port: gatewayServer.config.port, + }); + + gameServer = await createGameApiServer(); + await gameServer.app.listen({ + host: gameServer.config.host, + port: gameServer.config.port, + }); + + const gatewayUrl = `http://localhost:${gatewayServer.config.port}`; + gameUrl = `http://localhost:${gameServer.config.port}`; + const adminSessionRef: { value?: string } = {}; + const gameAccessRef: { value?: string } = {}; + gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSessionRef); + gameClient = createGameClient(gameUrl, gameServer.config.trpcPath, gameAccessRef); + if (!gatewayClient || !gameClient) { + throw new Error('clients not initialized'); + } + + const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({ + token: process.env.GATEWAY_BOOTSTRAP_TOKEN ?? '', + username: 'admin', + password: 'admin-pass-123', + displayName: '관리자', + }); + adminSessionRef.value = bootstrap.sessionToken; + + const demoUsers = Array.from({ length: 4 }, (_, idx) => ({ + username: `auction-user-${idx + 1}`, + password: `auction-pass-${idx + 1}`, + displayName: `경매유저${idx + 1}`, + })); + + for (const user of demoUsers) { + await gatewayClient.admin.users.createLocal.mutate({ + username: user.username, + password: user.password, + displayName: user.displayName, + }); + } + + await gatewayClient.admin.profiles.upsert.mutate({ + profile: 'che', + scenario: '2', + apiPort: Number(process.env.GAME_API_PORT ?? 14000), + status: 'RUNNING', + }); + + await gatewayClient.admin.profiles.installNow.mutate({ + profileName: 'che:2', + install: { + scenarioId: 2, + turnTermMinutes: 1, + sync: false, + fiction: 0, + extend: true, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 0, + tournamentTrig: false, + joinMode: 'full', + autorunUser: null, + }, + }); + + for (const user of demoUsers) { + const login = await gatewayClient.auth.login.mutate({ + username: user.username, + password: user.password, + }); + const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({ + sessionToken: login.sessionToken, + profile: 'che:2', + }); + const access = await gameClient.auth.exchangeGatewayToken.mutate({ + gatewayToken: gatewayToken.gameToken, + }); + const accessRef = { value: access.accessToken }; + const userGameClient = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef); + const created = await userGameClient.join.createGeneral.mutate({ + name: `${user.displayName}`, + leadership: 55, + strength: 55, + intel: 55, + character: 'Random', + }); + userSessions.push({ + username: user.username, + accessToken: access.accessToken, + generalId: created.generalId, + userId: '', + }); + } + + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:2', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + }); + turnDaemonLoop = turnDaemon.lifecycle.start(); + + const waitForStatus = async (timeoutMs = 10_000) => { + if (!gameClient) { + throw new Error('game client missing'); + } + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const status = await gameClient.turnDaemon.status.query({ timeoutMs: 3000 }); + if (status) { + return status; + } + await sleep(200); + } + throw new Error('turn daemon status timeout'); + }; + await waitForStatus(); + + gameConnector = createGamePostgresConnector({ url: gameDatabaseUrl }); + await gameConnector.connect(); + redisConnector = createRedisConnector(resolveRedisConfigFromEnv()); + await redisConnector.connect(); + + for (const session of userSessions) { + const row = await gameConnector.prisma.general.findUnique({ + where: { id: session.generalId }, + select: { userId: true }, + }); + if (!row?.userId) { + throw new Error('user id not found'); + } + session.userId = row.userId; + } + }, 120_000); + + afterAll(async () => { + if (turnDaemon) { + await turnDaemon.lifecycle.stop('integration-test'); + await turnDaemon.close(); + await turnDaemonLoop; + } + if (redisConnector) { + await redisConnector.disconnect(); + } + if (gameConnector) { + await gameConnector.disconnect(); + } + if (gameServer) { + await gameServer.app.close(); + } + if (gatewayServer) { + await gatewayServer.app.close(); + } + }, 30_000); + + it('handles resource auction flow with bidding, extension, and finalization', async () => { + if (!gameConnector || !redisConnector || !gameServer) { + throw new Error('runtime not ready'); + } + const prisma = gameConnector.prisma; + const redis = redisConnector.client; + + const [bidder1, bidder2, bidder3, poorBidder] = userSessions; + if (!bidder1 || !bidder2 || !bidder3 || !poorBidder) { + throw new Error('not enough bidders'); + } + + await prisma.general.updateMany({ + where: { id: { in: [bidder1.generalId, bidder2.generalId, bidder3.generalId] } }, + data: { gold: 2000, rice: 1500 }, + }); + await prisma.general.update({ + where: { id: poorBidder.generalId }, + data: { gold: 50, rice: 1000 }, + }); + + const now = new Date(); + const initialCloseAt = new Date(now.getTime() + 10_000); + const auction = await prisma.auction.create({ + data: { + type: 'BUY_RICE', + targetCode: null, + hostGeneralId: 0, + hostName: '시스템', + detail: { + amount: 500, + startBidAmount: 100, + isReverse: false, + availableLatestBidCloseDate: new Date(now.getTime() + 5 * 60_000).toISOString(), + }, + status: 'OPEN', + closeAt: initialCloseAt, + }, + }); + + const keys = buildAuctionTimerKeys(gameServer.config.profileName); + await seedAuctionTimers(prisma, redis, keys); + const initialScore = await redis.zScore(keys.timerKey, String(auction.id)); + expect(Number(initialScore)).toBe(initialCloseAt.getTime()); + + const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, { + value: bidder1.accessToken, + }); + await bidder1Client.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 200 }); + + const bidRows = await prisma.auctionBid.findMany({ where: { auctionId: auction.id } }); + expect(bidRows).toHaveLength(1); + + const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, { + value: poorBidder.accessToken, + }); + await expect( + poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 200 }) + ).rejects.toThrow('금이 부족합니다.'); + + const updatedAuction = await prisma.auction.findUnique({ + where: { id: auction.id }, + select: { closeAt: true }, + }); + expect(updatedAuction).not.toBeNull(); + expect(updatedAuction!.closeAt.getTime()).toBeGreaterThan(initialCloseAt.getTime()); + + const updatedScore = await redis.zScore(keys.timerKey, String(auction.id)); + expect(Number(updatedScore)).toBe(updatedAuction?.closeAt.getTime()); + + const finalizeAt = new Date(Date.now() - 1000); + await prisma.auction.update({ + where: { id: auction.id }, + data: { closeAt: finalizeAt, status: 'OPEN' }, + }); + await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]); + + const transport = new RedisTurnDaemonTransport(redis, { + keys: buildTurnDaemonStreamKeys(gameServer.config.profileName), + requestTimeoutMs: 10_000, + }); + await prisma.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET status = 'FINALIZING', + finalizing_at = ${new Date()}, + updated_at = ${new Date()} + WHERE id = ${auction.id} + ` + ); + const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000); + expect(result?.ok).toBe(true); + + const finished = await prisma.auction.findUnique({ + where: { id: auction.id }, + select: { status: true }, + }); + expect(finished?.status).toBe('FINISHED'); + + const bidderRow = await prisma.general.findUnique({ + where: { id: bidder1.generalId }, + select: { rice: true }, + }); + expect(bidderRow?.rice).toBeGreaterThan(1500); + }, 60_000); + + it('handles unique auction constraints and finalization extension', async () => { + if (!gameConnector || !redisConnector || !gameServer) { + throw new Error('runtime not ready'); + } + const prisma = gameConnector.prisma; + const redis = redisConnector.client; + + const [ownerBidder, validBidder, spareBidder, extraBidder] = userSessions; + if (!ownerBidder || !validBidder || !spareBidder || !extraBidder) { + throw new Error('not enough bidders'); + } + + const uniquePair = await findUniqueItemPair(); + const slotField = resolveSlotField(uniquePair.slot); + if (!slotField) { + throw new Error('unsupported item slot'); + } + const slotUpdate = { [slotField]: uniquePair.keyB } as GamePrisma.GeneralUpdateInput; + + await prisma.general.update({ + where: { id: ownerBidder.generalId }, + data: slotUpdate, + }); + await prisma.general.update({ + where: { id: validBidder.generalId }, + data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' }, + }); + + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: ownerBidder.userId, key: 'previous' } }, + update: { value: 1000 }, + create: { userId: ownerBidder.userId, key: 'previous', value: 1000 }, + }); + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: validBidder.userId, key: 'previous' } }, + update: { value: 2000 }, + create: { userId: validBidder.userId, key: 'previous', value: 2000 }, + }); + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: spareBidder.userId, key: 'previous' } }, + update: { value: 2000 }, + create: { userId: spareBidder.userId, key: 'previous', value: 2000 }, + }); + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: extraBidder.userId, key: 'previous' } }, + update: { value: 2000 }, + create: { userId: extraBidder.userId, key: 'previous', value: 2000 }, + }); + + const now = new Date(); + const initialCloseAt = new Date(now.getTime() + 10_000); + const auction = await prisma.auction.create({ + data: { + type: 'UNIQUE_ITEM', + targetCode: uniquePair.keyA, + hostGeneralId: 0, + hostName: '시스템', + detail: { + startBidAmount: 200, + isReverse: false, + availableLatestBidCloseDate: new Date(now.getTime() + 10 * 60_000).toISOString(), + }, + status: 'OPEN', + closeAt: initialCloseAt, + }, + }); + + const keys = buildAuctionTimerKeys(gameServer.config.profileName); + await seedAuctionTimers(prisma, redis, keys); + const initialScore = await redis.zScore(keys.timerKey, String(auction.id)); + expect(Number(initialScore)).toBe(initialCloseAt.getTime()); + + const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, { + value: ownerBidder.accessToken, + }); + await expect( + ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 }) + ).rejects.toThrow('이미 다른 유니크를 가지고 있습니다.'); + + const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, { + value: validBidder.accessToken, + }); + await validClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 }); + + const updatedAuction = await prisma.auction.findUnique({ + where: { id: auction.id }, + select: { closeAt: true }, + }); + expect(updatedAuction).not.toBeNull(); + expect(updatedAuction!.closeAt.getTime()).toBeGreaterThan(initialCloseAt.getTime()); + + await prisma.general.update({ + where: { id: validBidder.generalId }, + data: slotUpdate, + }); + + const finalizeAt = new Date(Date.now() - 1000); + await prisma.auction.update({ + where: { id: auction.id }, + data: { closeAt: finalizeAt, status: 'OPEN' }, + }); + await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]); + + const transport = new RedisTurnDaemonTransport(redis, { + keys: buildTurnDaemonStreamKeys(gameServer.config.profileName), + requestTimeoutMs: 10_000, + }); + await prisma.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET status = 'FINALIZING', + finalizing_at = ${new Date()}, + updated_at = ${new Date()} + WHERE id = ${auction.id} + ` + ); + const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000); + expect(result?.ok).toBe(false); + + const reopened = await prisma.auction.findUnique({ + where: { id: auction.id }, + select: { status: true, closeAt: true }, + }); + expect(reopened?.status).toBe('OPEN'); + expect(reopened?.closeAt.getTime()).toBeGreaterThan(finalizeAt.getTime()); + }, 60_000); +});