Files
core2026/app/game-api/test/publicRealtimeEvent.test.ts
T
Hide_D 19629fe3cc fix: 갱신 비용에 따라 접속 점수를 기록
revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
2026-08-17 11:10:38 +00:00

215 lines
7.6 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 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('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,
},
});
});
});