feat: 토너먼트 페이지군 자동 갱신을 분리한다
토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다.
This commit is contained in:
@@ -6,6 +6,7 @@ import type { GameApiContext } from '../src/context.js';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
|
||||
import {
|
||||
accessAuthedProcedure,
|
||||
accessAuthedInputProcedure,
|
||||
accessLimitAuthedProcedure,
|
||||
deferredAccessLimitAuthedProcedure,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
resolveGeneralAccessEndpointWeight,
|
||||
resolveAccessWindows,
|
||||
resolveGeneralScoreStartedAt,
|
||||
shouldRecordGeneralAccessEndpoint,
|
||||
} from '../src/services/generalAccess.js';
|
||||
|
||||
const profile = { id: 'che', name: 'che:default', scenario: 'default' };
|
||||
@@ -144,6 +146,27 @@ describe('general access tracking', () => {
|
||||
expect(generalAccessEndpointWeights['world.getGeneralDirectory']).toBe(2);
|
||||
});
|
||||
|
||||
it('waives score only for a server-proven tournament snapshot refresh', () => {
|
||||
expect(shouldRecordGeneralAccessEndpoint('tournament.getSnapshot', true)).toBe(false);
|
||||
expect(shouldRecordGeneralAccessEndpoint('tournament.getSnapshot', false)).toBe(true);
|
||||
expect(shouldRecordGeneralAccessEndpoint('world.getGeneralDirectory', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the existing hard limit gate on a granted tournament refresh', async () => {
|
||||
const now = new Date();
|
||||
const fixture = buildDb({ lastTurnTime: now.toISOString() }, { lastRefresh: now, refreshScore: 999 });
|
||||
const tournamentBoundary = router({
|
||||
tournament: router({ getSnapshot: accessAuthedProcedure.query(() => ({ ok: true })) }),
|
||||
});
|
||||
const caller = tournamentBoundary.createCaller({
|
||||
...accessContext(fixture.db),
|
||||
realtimeAccessGranted: true,
|
||||
} as unknown as GameApiContext);
|
||||
|
||||
await expect(caller.tournament.getSnapshot()).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
||||
expect(fixture.executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the latest processed game turn as the traffic period and score window', () => {
|
||||
expect(
|
||||
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
||||
@@ -197,7 +220,6 @@ describe('general access tracking', () => {
|
||||
expect(accessStatement.values).toContain(2);
|
||||
expect(accessStatement.values).toContain(now);
|
||||
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
|
||||
|
||||
});
|
||||
|
||||
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
|
||||
@@ -248,9 +270,7 @@ describe('general access tracking', () => {
|
||||
const fixture = buildDb();
|
||||
const resolver = vi.fn(() => ({ ok: true }));
|
||||
const limitedRouter = router({ read: deferredAccessLimitAuthedProcedure.query(resolver) });
|
||||
const get = vi.fn(async () =>
|
||||
JSON.stringify({ nextAccessAt: '2099-07-26T03:10:00.000Z' })
|
||||
);
|
||||
const get = vi.fn(async () => JSON.stringify({ nextAccessAt: '2099-07-26T03:10:00.000Z' }));
|
||||
|
||||
await expect(
|
||||
limitedRouter
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-
|
||||
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
||||
|
||||
import {
|
||||
shouldForwardRealtimeEvent,
|
||||
shouldReloadRealtimeViewerIdentity,
|
||||
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
||||
} from '../src/realtime/publicEvent.js';
|
||||
@@ -145,6 +146,44 @@ describe('public realtime event privacy boundary', () => {
|
||||
expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes tournament page refresh selection without projection identity or revision', () => {
|
||||
const invalidation = { snapshot: true, betting: false, rankings: false, generalId: 7 };
|
||||
const publicEvent = toPublicRealtimeEvent(
|
||||
{
|
||||
type: 'tournamentProjectionChanged',
|
||||
invalidation,
|
||||
},
|
||||
[viewer]
|
||||
);
|
||||
|
||||
expect(publicEvent).toEqual({
|
||||
type: 'tournamentViewInvalidated',
|
||||
refreshGrant,
|
||||
invalidation: { snapshot: true, betting: false, rankings: false },
|
||||
});
|
||||
expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId|matchId/u);
|
||||
expect(
|
||||
shouldReloadRealtimeViewerIdentity(
|
||||
{
|
||||
type: 'tournamentProjectionChanged',
|
||||
invalidation: { snapshot: false, betting: true, rankings: false },
|
||||
},
|
||||
viewer
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('routes tournament projection traffic only to the dedicated page-family subscription', () => {
|
||||
const projectionEvent: RealtimeEvent = {
|
||||
type: 'tournamentProjectionChanged',
|
||||
invalidation: { snapshot: true, betting: false, rankings: false },
|
||||
};
|
||||
expect(shouldForwardRealtimeEvent(projectionEvent, 'dashboard')).toBe(false);
|
||||
expect(shouldForwardRealtimeEvent(projectionEvent, 'tournament')).toBe(true);
|
||||
expect(shouldForwardRealtimeEvent({ type: 'tournamentChanged' }, 'dashboard')).toBe(true);
|
||||
expect(shouldForwardRealtimeEvent({ type: 'tournamentChanged' }, 'tournament')).toBe(false);
|
||||
});
|
||||
|
||||
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
|
||||
const event: RealtimeEvent = {
|
||||
type: 'messageCreated',
|
||||
|
||||
@@ -54,9 +54,7 @@ integration('TournamentStore Redis source revision', () => {
|
||||
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 },
|
||||
])
|
||||
store.setMatches([{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 }])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -82,19 +80,48 @@ integration('TournamentStore Redis source revision', () => {
|
||||
|
||||
await store.setState(baseState);
|
||||
await waitForLength(sourceMessages, sourceBefore + 1);
|
||||
await waitForLength(realtimeMessages, realtimeBefore + 1);
|
||||
await waitForLength(realtimeMessages, realtimeBefore + 2);
|
||||
|
||||
await store.setState({ ...baseState, phase: 1 });
|
||||
await waitForLength(sourceMessages, sourceBefore + 2);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(realtimeMessages).toHaveLength(realtimeBefore + 1);
|
||||
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);
|
||||
expect(realtimeMessages.slice(realtimeBefore)).toEqual([
|
||||
JSON.stringify({ type: 'tournamentChanged' }),
|
||||
JSON.stringify({ type: 'tournamentChanged' }),
|
||||
|
||||
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 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,14 +48,22 @@ describe('TournamentStore source revision', () => {
|
||||
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.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.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentProjectionChanged']);
|
||||
expect(redis.published).toEqual([
|
||||
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
|
||||
{
|
||||
channel: keys.realtimeEventChannel,
|
||||
message: JSON.stringify({
|
||||
type: 'tournamentProjectionChanged',
|
||||
invalidation: { snapshot: true, betting: false, rankings: false },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -64,8 +72,9 @@ describe('TournamentStore source revision', () => {
|
||||
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.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();
|
||||
@@ -88,9 +97,21 @@ describe('TournamentStore source revision', () => {
|
||||
nextAt: '2026-08-17T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']);
|
||||
expect(redis.events).toEqual([
|
||||
'commit:1',
|
||||
'publish:1',
|
||||
'publish:tournamentProjectionChanged',
|
||||
'publish:tournamentChanged',
|
||||
]);
|
||||
expect(redis.published).toEqual([
|
||||
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
|
||||
{
|
||||
channel: keys.realtimeEventChannel,
|
||||
message: JSON.stringify({
|
||||
type: 'tournamentProjectionChanged',
|
||||
invalidation: { snapshot: true, betting: true, rankings: false },
|
||||
}),
|
||||
},
|
||||
{ channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) },
|
||||
]);
|
||||
});
|
||||
@@ -101,14 +122,12 @@ describe('TournamentStore source revision', () => {
|
||||
|
||||
const revisions = await Promise.all(
|
||||
Array.from({ length: 50 }, (_, index) =>
|
||||
store.setMatches([
|
||||
{ id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 },
|
||||
])
|
||||
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);
|
||||
expect(redis.published).toHaveLength(100);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user