fix: 설문과 장수 선택 기한 및 토너먼트 개최를 게임 시각으로 통일

This commit is contained in:
2026-09-16 01:38:21 +00:00
parent a51574d20c
commit f4aabec1fa
9 changed files with 388 additions and 56 deletions
@@ -154,6 +154,7 @@ const buildContext = (options: {
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
requestId?: string;
clockWallAnchor?: Date;
recovery?: boolean;
}): GameApiContext => {
const db = {
general: {
@@ -165,10 +166,20 @@ const buildContext = (options: {
rankData: {
findMany: async () => options.rankRows ?? [],
},
$queryRaw: async () => [{ ready: true }],
worldState: {
findFirst: async () => ({
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
currentYear: 193,
currentMonth: 7,
clockTick: 0n,
...(options.recovery
? {
clockRecoveryStartTick: 0n,
clockRecoveryEndTick: 720_000_000n,
clockRecoveryStartWallAt: options.clockWallAnchor,
}
: {}),
clockMode: 'realtime',
clockWallAnchor: options.clockWallAnchor ?? new Date('2026-01-01T00:00:00.000Z'),
clockPhase: options.clockPhase ?? 'RUNNING',
@@ -215,6 +226,88 @@ const setTournamentFixture = async (redis: MemoryRedis, state: Record<string, un
};
describe('tournament router permissions and mutations', () => {
it.each([false, true])('starts from the server GAME time (recovery=%s)', async (recovery) => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-01-01T12:00:10Z'));
try {
const redis = new MemoryRedis();
const context = buildContext({
redis,
transport: new TournamentTransport(),
generals: [],
userId: 'admin',
roles: ['admin.tournament:che:default'],
clockWallAnchor: new Date('2026-01-01T12:00:00Z'),
recovery,
});
const caller = appRouter.createCaller(context);
// @ts-expect-error 클라이언트가 보낸 WALL 일정은 입력 단계에서 거부한다.
await expect(caller.tournament.start({ nextAt: '2099-01-01T00:00:00Z' })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(await redis.get('sammo:che:default:tournament:state')).toBeNull();
await expect(caller.tournament.start()).resolves.toEqual({ ok: true });
const state = JSON.parse((await redis.get('sammo:che:default:tournament:state'))!);
expect(state).toMatchObject({
stage: 1,
openYear: 193,
openMonth: 7,
termSeconds: 60,
nextAt: recovery ? '2026-01-01T00:01:20.000Z' : '2026-01-01T00:01:10.000Z',
nextTick: recovery ? 48_000_000 : 42_000_000,
clockRevision: 1,
deadlineGeneration: 1,
});
} finally {
vi.useRealTimers();
}
});
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)('rejects admin start in %s', async (clockPhase) => {
const redis = new MemoryRedis();
const caller = appRouter.createCaller(
buildContext({
redis,
transport: new TournamentTransport(),
generals: [],
userId: 'admin',
roles: ['admin'],
clockPhase,
})
);
await expect(caller.tournament.start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(await redis.get('sammo:che:default:tournament:state')).toBeNull();
});
it('does not start against a stale Redis clock revision', async () => {
const redis = new MemoryRedis();
await redis.set('sammo:che:default:clock:active-revision', '2');
await redis.set('sammo:che:default:clock:deadline-generation', '1');
await redis.set('sammo:che:default:clock:phase', 'RUNNING');
const caller = appRouter.createCaller(
buildContext({
redis,
transport: new TournamentTransport(),
generals: [],
userId: 'admin',
roles: ['admin'],
})
);
await expect(caller.tournament.start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(await redis.get('sammo:che:default:tournament:state')).toBeNull();
});
it('requires authentication to start a tournament', async () => {
const context = buildContext({
redis: new MemoryRedis(),
transport: new TournamentTransport(),
generals: [],
userId: 'guest',
});
const caller = appRouter.createCaller({ ...context, auth: null });
await expect(caller.tournament.start()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('returns persisted group fight logs to an authenticated tournament viewer', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
@@ -560,6 +653,7 @@ describe('tournament router permissions and mutations', () => {
nextAt: '2026-07-26T01:00:00.000Z',
};
await expect(caller.tournament.start()).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setState(state)).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.patchState({ phase: 1 })).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setParticipants([])).rejects.toMatchObject({ code: 'FORBIDDEN' });