merge: 최신 main을 브라우저 전투 시뮬레이터 작업에 통합
This commit is contained in:
@@ -383,6 +383,10 @@ integration('general access tracking persistence', () => {
|
||||
} as unknown as GameApiContext;
|
||||
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
|
||||
|
||||
const dashboardCaller = appRouter.createCaller(context);
|
||||
await expect(dashboardCaller.general.getFrontStatus()).resolves.toBeDefined();
|
||||
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
|
||||
|
||||
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
|
||||
@@ -101,7 +101,6 @@ describe('general access tracking', () => {
|
||||
'diplomacy.getLetters': 2,
|
||||
'battle.getGeneralDetail': 1,
|
||||
'betting.getList': 1,
|
||||
'general.getFrontStatus': 1,
|
||||
'yearbook.getHistory': 1,
|
||||
'world.getGlobalInfo': 1,
|
||||
'nation.getBattleCenter': 1,
|
||||
@@ -131,6 +130,7 @@ describe('general access tracking', () => {
|
||||
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1);
|
||||
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1);
|
||||
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull();
|
||||
expect(resolveGeneralAccessEndpointWeight('general.getFrontStatus', {}, 'che')).toBeUndefined();
|
||||
expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('public realtime event privacy boundary', () => {
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: false,
|
||||
tournament: false,
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(publicEvent);
|
||||
@@ -108,10 +109,33 @@ describe('public realtime event privacy boundary', () => {
|
||||
reservedTurns: true,
|
||||
records: true,
|
||||
frontStatus: true,
|
||||
tournament: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts tournament state changes to one global boolean invalidation', () => {
|
||||
const publicEvent = toPublicRealtimeEvent({ type: 'tournamentChanged' }, [viewer]);
|
||||
|
||||
expect(publicEvent).toEqual({
|
||||
type: 'readModelInvalidated',
|
||||
invalidation: {
|
||||
context: false,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: false,
|
||||
contacts: false,
|
||||
boardAccess: false,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
tournament: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId/u);
|
||||
expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).toBe(false);
|
||||
});
|
||||
|
||||
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
|
||||
const event: RealtimeEvent = {
|
||||
type: 'messageCreated',
|
||||
|
||||
@@ -42,6 +42,12 @@ describe('parseRealtimeEvent', () => {
|
||||
expect(parseRealtimeEvent('not-json')).toBeNull();
|
||||
expect(parseRealtimeEvent(JSON.stringify({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the minimal tournament state wake-up', () => {
|
||||
expect(parseRealtimeEvent(JSON.stringify({ type: 'tournamentChanged' }))).toEqual({
|
||||
type: 'tournamentChanged',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGameEventChannel', () => {
|
||||
|
||||
@@ -10,12 +10,27 @@ 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 () => {
|
||||
@@ -27,6 +42,11 @@ integration('TournamentStore Redis source revision', () => {
|
||||
keys.bettingKey,
|
||||
keys.sourceRevisionKey,
|
||||
]);
|
||||
if (subscriber) {
|
||||
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
|
||||
await subscriber.client.unsubscribe(keys.realtimeEventChannel);
|
||||
await subscriber.disconnect();
|
||||
}
|
||||
await connector.disconnect();
|
||||
});
|
||||
|
||||
@@ -44,4 +64,37 @@ integration('TournamentStore Redis source revision', () => {
|
||||
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 + 1);
|
||||
|
||||
await store.setState({ ...baseState, phase: 1 });
|
||||
await waitForLength(sourceMessages, sourceBefore + 2);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(realtimeMessages).toHaveLength(realtimeBefore + 1);
|
||||
|
||||
await store.setState({ ...baseState, stage: 2, phase: 0 });
|
||||
await waitForLength(sourceMessages, sourceBefore + 3);
|
||||
await waitForLength(realtimeMessages, realtimeBefore + 2);
|
||||
expect(realtimeMessages.slice(realtimeBefore)).toEqual([
|
||||
JSON.stringify({ type: 'tournamentChanged' }),
|
||||
JSON.stringify({ type: 'tournamentChanged' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,8 @@ class AtomicMemoryRedis {
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<number> {
|
||||
this.events.push(`publish:${JSON.parse(message).sourceRevision as string}`);
|
||||
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;
|
||||
}
|
||||
@@ -71,6 +72,29 @@ describe('TournamentStore source revision', () => {
|
||||
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'));
|
||||
|
||||
Reference in New Issue
Block a user