토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다.
254 lines
9.3 KiB
TypeScript
254 lines
9.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 {
|
|
shouldForwardRealtimeEvent,
|
|
shouldReloadRealtimeViewerIdentity,
|
|
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
|
} from '../src/realtime/publicEvent.js';
|
|
|
|
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
|
|
const refreshGrant = 'opaque-grant';
|
|
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
|
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
|
|
|
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',
|
|
refreshGrant,
|
|
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',
|
|
refreshGrant,
|
|
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',
|
|
refreshGrant,
|
|
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('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',
|
|
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', refreshGrant });
|
|
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', refreshGrant });
|
|
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,
|
|
},
|
|
});
|
|
});
|
|
});
|