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

211 lines
7.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from '../src/realtime/publicEvent.js';
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({
type: 'turnCompleted',
at: '2026-08-12T12:34:56.789Z',
lastTurnTime: '0185-02-01T00:00:00.000Z',
changes,
revision: 42,
});
describe('public realtime event privacy boundary', () => {
it('suppresses clock-only and unrelated private general turns', () => {
expect(toPublicRealtimeEvent(turnEvent(), [viewer])).toBeNull();
expect(
toPublicRealtimeEvent(
turnEvent({
...createEmptyRealtimeReadModelChanges(),
generalIds: [99],
}),
[viewer]
)
).toBeNull();
});
it('publishes only viewer-specific boolean invalidations', () => {
const publicEvent = toPublicRealtimeEvent(
turnEvent({
...createEmptyRealtimeReadModelChanges(),
generalIds: [7, 99],
reservedGeneralIds: [7],
recordGeneralIds: [7],
}),
[viewer]
);
expect(publicEvent).toEqual({
type: 'readModelInvalidated',
invalidation: {
context: true,
lobby: false,
map: false,
commands: true,
contacts: false,
boardAccess: true,
reservedTurns: true,
records: true,
frontStatus: false,
tournament: false,
},
});
const serialized = JSON.stringify(publicEvent);
expect(publicEvent).not.toHaveProperty('at');
expect(publicEvent).not.toHaveProperty('lastTurnTime');
expect(publicEvent).not.toHaveProperty('revision');
for (const forbidden of ['generalIds', 'cityIds', 'nationIds', '99']) {
expect(serialized).not.toContain(forbidden);
}
});
it('keeps global refresh meaning without exposing its source identity or time', () => {
const publicEvent = toPublicRealtimeEvent(
{
type: 'readModelChanged',
at: '2026-08-12T12:34:56.789Z',
revision: 43,
changes: {
...createEmptyRealtimeReadModelChanges(),
worldChanged: true,
globalRecordsChanged: true,
worldHistoryChanged: true,
},
},
[viewer]
);
expect(publicEvent).toMatchObject({
type: 'readModelInvalidated',
invalidation: { lobby: true, map: true, commands: true, records: true },
});
expect(JSON.stringify(publicEvent)).not.toMatch(/2026|0185|revision|Ids/u);
});
it('uses a conservative identifier-free fallback for an older daemon', () => {
expect(
toPublicRealtimeEvent(
{
type: 'turnCompleted',
at: '2026-08-12T12:34:56.789Z',
lastTurnTime: '0185-02-01T00:00:00.000Z',
},
[viewer]
)
).toEqual({
type: 'readModelInvalidated',
invalidation: {
context: true,
lobby: true,
map: true,
commands: true,
contacts: true,
boardAccess: true,
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',
at: '2026-08-12T12:34:56.789Z',
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId,
msgType: 'national',
messageId: 123,
senderId: 99,
};
expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' });
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
});
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
const event: RealtimeEvent = {
type: 'messagesChanged',
mailboxes: [7, MESSAGE_MAILBOX_NATIONAL_BASE + 8],
};
const publicEvent = toPublicRealtimeEvent(event, [viewer]);
expect(publicEvent).toEqual({ type: 'messagesInvalidated' });
expect(JSON.stringify(publicEvent)).not.toMatch(/7|9008|mailbox|revision|time/u);
expect(
toPublicRealtimeEvent(
{ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] },
[viewer]
)
).toBeNull();
});
it('requests an identity refresh only when the viewer general may have changed', () => {
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] }),
viewer
)
).toBe(true);
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [99] }),
viewer
)
).toBe(false);
});
it('merges previous and committed identities across an ownership transition', () => {
const event: RealtimeEvent = {
type: 'readModelChanged',
at: '2026-08-12T12:34:56.789Z',
revision: 44,
changes: {
...createEmptyRealtimeReadModelChanges(),
generalIds: [7],
nationIds: [3],
frontStatusNationIds: [3],
},
};
expect(
toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])
).toMatchObject({
type: 'readModelInvalidated',
invalidation: {
context: true,
commands: true,
boardAccess: true,
frontStatus: true,
},
});
});
});