fix: 10분 중단 즉시 복구와 대기 중 토너먼트 참가 허용
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import fastify from 'fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { createGamePostgresConnector, createRedisConnector } from '@sammo-ts/infra';
|
||||
import { DatabaseTurnDaemonCommandQueue, DatabaseTurnDaemonLease } from '@sammo-ts/game-engine';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { loadCurrentGameTime } from '../src/services/gameClock.js';
|
||||
|
||||
const databaseUrl = process.env.TOURNAMENT_RECOVERY_DATABASE_URL;
|
||||
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
|
||||
|
||||
integration(
|
||||
'accepts an authenticated HTTP tournament join during recovery wait with durable fee and Redis fences',
|
||||
async () => {
|
||||
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||
if (!schema?.endsWith('_tournament_recovery_integration'))
|
||||
throw new Error('Dedicated tournament fixture schema required');
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
const redisConnector = createRedisConnector({ url: process.env.REDIS_URL! });
|
||||
await connector.connect();
|
||||
await redisConnector.connect();
|
||||
const db = connector.prisma;
|
||||
const redis = redisConnector.client;
|
||||
const profile = 'test:recovery-wait';
|
||||
const prefix = `sammo:${profile}`;
|
||||
const tokenStore = new RedisAccessTokenStore(redis, profile);
|
||||
const transport = new DatabaseTurnDaemonTransport(db, 4000);
|
||||
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
const app = fastify();
|
||||
let workerError: unknown;
|
||||
let workerBusy = false;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
try {
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.general.deleteMany();
|
||||
await db.turnDaemonLease.deleteMany();
|
||||
await db.worldState.deleteMany();
|
||||
const baseTime = new Date('2026-01-01T00:00:00Z');
|
||||
const waitAt = new Date(Date.now() + 1_800_000);
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'recovery-wait',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
clockBaseTime: baseTime,
|
||||
clockTick: 0n,
|
||||
lastTurnTick: 0n,
|
||||
clockWallAnchor: waitAt,
|
||||
clockRecoveryStartTick: 0n,
|
||||
clockRecoveryEndTick: 36_000_000n,
|
||||
clockRecoveryStartWallAt: waitAt,
|
||||
clockMode: 'realtime',
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
config: { const: { develCost: 100 } },
|
||||
},
|
||||
});
|
||||
await db.general.create({
|
||||
data: { id: 901, userId: 'fixture-user', name: 'fixture', gold: 1000, turnTime: baseTime },
|
||||
});
|
||||
await lease.acquire();
|
||||
await lease.markClockReady();
|
||||
await queue.initialize();
|
||||
await redis.set(
|
||||
`${prefix}:tournament:state`,
|
||||
JSON.stringify({
|
||||
stage: 1,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 200,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: baseTime.toISOString(),
|
||||
})
|
||||
);
|
||||
await redis.set(`${prefix}:tournament:participants`, '[]');
|
||||
await redis.del([
|
||||
`${prefix}:clock:active-revision`,
|
||||
`${prefix}:clock:deadline-generation`,
|
||||
`${prefix}:clock:phase`,
|
||||
]);
|
||||
const auth: NonNullable<GameApiContext['auth']> = {
|
||||
version: 1,
|
||||
profile,
|
||||
sessionId: 'fixture-session',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: '2999-01-01T00:00:00Z',
|
||||
user: { id: 'fixture-user', username: 'fixture', displayName: 'fixture', roles: [] },
|
||||
sanctions: {},
|
||||
};
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext: ({
|
||||
req,
|
||||
}: {
|
||||
req: { headers: Record<string, string | string[] | undefined> };
|
||||
}): GameApiContext => ({
|
||||
db,
|
||||
redis,
|
||||
profile: { id: 'test', scenario: 'recovery-wait', name: profile },
|
||||
auth: req.headers.authorization === 'Bearer fixture-auth' ? auth : null,
|
||||
requestId:
|
||||
typeof req.headers['x-test-request'] === 'string'
|
||||
? req.headers['x-test-request']
|
||||
: undefined,
|
||||
turnDaemon: transport,
|
||||
accessTokenStore: tokenStore,
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
uploadDir: '/tmp',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
gameTokenSecret: 'fixture-only',
|
||||
}),
|
||||
},
|
||||
});
|
||||
await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const address = app.server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Missing fixture listener');
|
||||
const url = `http://127.0.0.1:${address.port}/trpc/tournament.join`;
|
||||
const post = (authenticated = true) =>
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(authenticated ? { authorization: 'Bearer fixture-auth' } : {}),
|
||||
'x-test-request': 'recovery-join',
|
||||
},
|
||||
body: '{}',
|
||||
});
|
||||
expect((await post(false)).status).toBe(401);
|
||||
expect(await loadCurrentGameTime(db)).toMatchObject({
|
||||
phase: 'RUNNING',
|
||||
running: false,
|
||||
runtimeReady: true,
|
||||
});
|
||||
// Fixture worker: production queue/lease and real PostgreSQL transaction; no auto turns.
|
||||
timer = setInterval(() => {
|
||||
if (workerBusy) return;
|
||||
workerBusy = true;
|
||||
void (async () => {
|
||||
for (const command of await queue.drain()) {
|
||||
if (command.type !== 'adjustGeneralResources' || !command.requestId)
|
||||
throw new Error('Unexpected fixture command');
|
||||
await db.$transaction(async (tx) => {
|
||||
await lease.assertActive(tx);
|
||||
const adjustment = command.adjustments[0]!;
|
||||
await tx.general.update({
|
||||
where: { id: adjustment.generalId },
|
||||
data: { gold: { increment: adjustment.goldDelta ?? 0 } },
|
||||
});
|
||||
await tx.inputEvent.update({
|
||||
where: { requestId: command.requestId },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: {
|
||||
type: 'adjustGeneralResources',
|
||||
ok: true,
|
||||
processed: 1,
|
||||
missing: 0,
|
||||
totalGoldDelta: adjustment.goldDelta ?? 0,
|
||||
totalRiceDelta: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
workerError = error;
|
||||
})
|
||||
.finally(() => {
|
||||
workerBusy = false;
|
||||
});
|
||||
}, 10);
|
||||
expect((await post()).status).toBe(200);
|
||||
expect((await post()).status).toBe(200);
|
||||
expect(workerError).toBeUndefined();
|
||||
expect((await db.general.findUniqueOrThrow({ where: { id: 901 } })).gold).toBe(900);
|
||||
expect(JSON.parse((await redis.get(`${prefix}:tournament:participants`))!)).toHaveLength(1);
|
||||
const events = await db.inputEvent.findMany({ where: { eventType: 'adjustGeneralResources' } });
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ status: 'SUCCEEDED', acceptedGameTick: 0n, acceptedClockRevision: 1n });
|
||||
await redis.set(`${prefix}:clock:active-revision`, '2');
|
||||
expect((await post()).status).toBe(412);
|
||||
await redis.set(`${prefix}:clock:active-revision`, '1');
|
||||
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RECONCILING' } });
|
||||
expect((await post()).status).toBe(412);
|
||||
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } });
|
||||
await lease.release();
|
||||
expect((await post()).status).toBe(412);
|
||||
expect((await db.general.findUniqueOrThrow({ where: { id: 901 } })).gold).toBe(900);
|
||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: world.id } })).clockTick).toBe(0n);
|
||||
} finally {
|
||||
if (timer) clearInterval(timer);
|
||||
while (workerBusy) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await app.close();
|
||||
await lease.close();
|
||||
await connector.disconnect();
|
||||
await redisConnector.disconnect();
|
||||
}
|
||||
},
|
||||
15000
|
||||
);
|
||||
@@ -153,6 +153,7 @@ const buildContext = (options: {
|
||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
|
||||
requestId?: string;
|
||||
clockWallAnchor?: Date;
|
||||
}): GameApiContext => {
|
||||
const db = {
|
||||
general: {
|
||||
@@ -169,7 +170,7 @@ const buildContext = (options: {
|
||||
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
clockWallAnchor: options.clockWallAnchor ?? new Date('2026-01-01T00:00:00.000Z'),
|
||||
clockPhase: options.clockPhase ?? 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
@@ -261,7 +262,21 @@ describe('tournament router permissions and mutations', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('charges the authenticated general once when joining', async () => {
|
||||
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)(
|
||||
'rejects joining in %s without charging',
|
||||
async (clockPhase) => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1');
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-1', clockPhase })
|
||||
);
|
||||
await expect(caller.tournament.join()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])('charges the authenticated general once when joining; recovery wait=%s', async (waiting) => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1');
|
||||
@@ -284,6 +299,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
userId: 'user-1',
|
||||
develCost: 200,
|
||||
requestId: 'http:tournament-join',
|
||||
...(waiting ? { clockWallAnchor: new Date(Date.now() + 1_800_000) } : {}),
|
||||
});
|
||||
const outerApiTransaction = vi.fn(async () => {
|
||||
throw new Error('tournament join must not hold an API transaction while waiting for the daemon');
|
||||
|
||||
Reference in New Issue
Block a user