170 lines
5.7 KiB
TypeScript
170 lines
5.7 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,
|
|
},
|
|
});
|
|
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,
|
|
},
|
|
});
|
|
});
|
|
|
|
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('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,
|
|
},
|
|
});
|
|
});
|
|
});
|