Files
core2026/app/game-api/test/tournamentStoreRevision.integration.test.ts
T
Hide_D 985f627499 feat: 토너먼트 페이지군 자동 갱신을 분리한다
토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다.
2026-08-22 06:00:22 +00:00

128 lines
5.2 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createRedisConnector, resolveRedisConfigFromEnv, type RedisConnector } from '@sammo-ts/infra';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import { TournamentStore } from '../src/tournament/store.js';
const integration = describe.skipIf(!process.env.REDIS_URL);
integration('TournamentStore Redis source revision', () => {
let connector: RedisConnector;
let subscriber: RedisConnector;
const profile = `test:tournament-revision:${randomUUID()}`;
const keys = buildTournamentKeys(profile);
const sourceMessages: string[] = [];
const realtimeMessages: string[] = [];
const waitForLength = async (values: readonly string[], length: number): Promise<void> => {
const deadline = Date.now() + 1_000;
while (values.length < length && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(values).toHaveLength(length);
};
beforeAll(async () => {
connector = createRedisConnector(resolveRedisConfigFromEnv());
subscriber = createRedisConnector(resolveRedisConfigFromEnv());
await connector.connect();
await subscriber.connect();
await subscriber.client.subscribe(keys.sourceRevisionChannel, (message) => sourceMessages.push(message));
await subscriber.client.subscribe(keys.realtimeEventChannel, (message) => realtimeMessages.push(message));
});
afterAll(async () => {
if (!connector) return;
await connector.client.del([
keys.stateKey,
keys.participantsKey,
keys.matchesKey,
keys.bettingKey,
keys.sourceRevisionKey,
]);
if (subscriber) {
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
await subscriber.client.unsubscribe(keys.realtimeEventChannel);
await subscriber.disconnect();
}
await connector.disconnect();
});
it('commits concurrent payload writes with unique monotonic revisions', async () => {
const store = new TournamentStore(connector.client, keys);
const revisions = await Promise.all(
Array.from({ length: 20 }, (_, index) =>
store.setMatches([{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 }])
)
);
expect(new Set(revisions).size).toBe(20);
await expect(store.getSourceRevision()).resolves.toBe('20');
await expect(store.getMatches()).resolves.toHaveLength(1);
});
it('publishes the main wake-up only when the atomic state write changes stage', async () => {
const store = new TournamentStore(connector.client, keys);
const sourceBefore = sourceMessages.length;
const realtimeBefore = realtimeMessages.length;
const baseState = {
stage: 1,
phase: 0,
type: 0 as const,
auto: true,
openYear: 185,
openMonth: 2,
termSeconds: 10,
nextAt: '2026-08-17T00:00:00.000Z',
};
await store.setState(baseState);
await waitForLength(sourceMessages, sourceBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 2);
await store.setState({ ...baseState, phase: 1 });
await waitForLength(sourceMessages, sourceBefore + 2);
await waitForLength(realtimeMessages, realtimeBefore + 3);
await store.setState({ ...baseState, stage: 2, phase: 0 });
await waitForLength(sourceMessages, sourceBefore + 3);
await waitForLength(realtimeMessages, realtimeBefore + 5);
expect(
realtimeMessages
.slice(realtimeBefore)
.filter((message) => (JSON.parse(message) as { type?: string }).type === 'tournamentChanged')
).toEqual([JSON.stringify({ type: 'tournamentChanged' }), JSON.stringify({ type: 'tournamentChanged' })]);
});
it('selects rankings only on the first committed reward settlement', async () => {
const store = new TournamentStore(connector.client, keys);
const realtimeBefore = realtimeMessages.length;
const state = await store.getState();
expect(state).not.toBeNull();
await store.setState({ ...state!, rewardSettled: true, bettingSettled: false });
await store.setState({ ...state!, rewardSettled: true, bettingSettled: true });
await waitForLength(realtimeMessages, realtimeBefore + 2);
const pageEvents = realtimeMessages.slice(realtimeBefore).map(
(message) =>
JSON.parse(message) as {
type: string;
invalidation?: { rankings?: boolean };
}
);
expect(pageEvents).toEqual([
expect.objectContaining({
type: 'tournamentProjectionChanged',
invalidation: expect.objectContaining({ rankings: true }),
}),
expect.objectContaining({
type: 'tournamentProjectionChanged',
invalidation: expect.objectContaining({ rankings: false }),
}),
]);
});
});