Files
core2026/app/game-api/test/tournamentStoreRevision.test.ts
T
Hide_D 50068224c8 fix: 토너먼트 단계의 메인 실시간 갱신을 연결
토너먼트 state의 stage 전이만 공용 실시간 채널에 발행하고 공개 invalidation으로 변환한다.
메인 dashboard store가 단계 값을 소유해 상단 문구와 메뉴 강조를 단일/다중 탭에서 함께 갱신한다.
Redis 통합 테스트와 production Chromium 회귀 검증을 추가한다.
2026-08-17 01:41:37 +00:00

115 lines
4.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import { TournamentStore } from '../src/tournament/store.js';
class AtomicMemoryRedis {
readonly events: string[] = [];
readonly published: Array<{ channel: string; message: string }> = [];
failNextEval = false;
private readonly values = new Map<string, string>();
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async set(key: string, value: string): Promise<string> {
this.values.set(key, value);
return 'OK';
}
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
if (this.failNextEval) {
this.failNextEval = false;
throw new Error('injected Redis write failure');
}
const [valueKey, revisionKey] = options.keys;
const [value] = options.arguments;
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
this.values.set(valueKey, value);
this.values.set(revisionKey, String(revision));
this.events.push(`commit:${revision}`);
return String(revision);
}
async publish(channel: string, message: string): Promise<number> {
const payload = JSON.parse(message) as { sourceRevision?: string; type?: string };
this.events.push(`publish:${payload.sourceRevision ?? payload.type ?? 'unknown'}`);
this.published.push({ channel, message });
return 1;
}
}
describe('TournamentStore source revision', () => {
it('publishes only after the payload and source revision commit atomically', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('che:default');
const store = new TournamentStore(redis, keys);
await expect(store.setParticipants([{ id: 7, name: '관우', leadership: 90, strength: 97, intel: 75, level: 5 }]))
.resolves.toBe('1');
await expect(store.getSourceRevision()).resolves.toBe('1');
await expect(store.getParticipants()).resolves.toHaveLength(1);
expect(redis.events).toEqual(['commit:1', 'publish:1']);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
]);
});
it('does not advance the source revision or publish when the atomic write fails', async () => {
const redis = new AtomicMemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('hwe:default'));
redis.failNextEval = true;
await expect(store.setParticipants([{ id: 1, name: '실패', leadership: 1, strength: 1, intel: 1, level: 1 }]))
.rejects.toThrow('injected Redis write failure');
await expect(store.getParticipants()).resolves.toEqual([]);
await expect(store.getSourceRevision()).resolves.toBeNull();
expect(redis.published).toEqual([]);
});
it('wakes the main realtime channel only for shared state writes', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('che:default');
const store = new TournamentStore(redis, keys);
await store.setState({
stage: 1,
phase: 0,
type: 0,
auto: true,
openYear: 185,
openMonth: 2,
termSeconds: 10,
nextAt: '2026-08-17T00:00:00.000Z',
});
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
{ channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
it('serializes concurrent writes into monotonic per-profile revisions', async () => {
const redis = new AtomicMemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('pwe:default'));
const revisions = await Promise.all(
Array.from({ length: 50 }, (_, index) =>
store.setMatches([
{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 },
])
)
);
expect(new Set(revisions).size).toBe(50);
await expect(store.getSourceRevision()).resolves.toBe('50');
expect(redis.published).toHaveLength(50);
});
});