feat(contracts): p2 realtime, likes, visits and internal sync contracts
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// packages/contracts/src/__tests__/realtime.test.ts
|
||||
// WS 消息契约类型测试(同 events.test.ts 的 @ts-expect-error 手法):判别联合
|
||||
// 按 type 收窄、非法 type/缺字段/错类型编译期拒绝、联合成员 1:1 锁定、
|
||||
// 心跳常量锁定。ts-jest 为全类型检查模式,编译期断言随测试生效。
|
||||
import {
|
||||
HEARTBEAT_MS,
|
||||
type ClientMessage,
|
||||
type ServerMessage,
|
||||
} from '../realtime.js';
|
||||
|
||||
// 类型恒等辅助:仅当 X 与 Y 是完全相同的类型(双向兼容,多/少一个成员都为 false)。
|
||||
type Equal<X, Y> =
|
||||
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
|
||||
const burst: ServerMessage = {
|
||||
type: 'likes.burst',
|
||||
photoId: 'photo-1',
|
||||
albumId: 'album-1',
|
||||
delta: 3,
|
||||
total: 128,
|
||||
countVersion: 42,
|
||||
burstId: 'burst-1',
|
||||
originBatchIds: ['batch-a', 'batch-b'],
|
||||
};
|
||||
|
||||
const subscribe: ClientMessage = { type: 'subscribe', albumToken: 'tok' };
|
||||
const subscribeSince: ClientMessage = {
|
||||
type: 'subscribe',
|
||||
albumToken: 'tok',
|
||||
sinceVersion: 7,
|
||||
};
|
||||
|
||||
describe('ServerMessage', () => {
|
||||
it('合法的 likes.burst 消息通过类型检查且字段完整', () => {
|
||||
if (burst.type !== 'likes.burst') throw new Error('unreachable');
|
||||
expect(burst.originBatchIds).toEqual(['batch-a', 'batch-b']);
|
||||
expect(burst.total).toBe(128);
|
||||
});
|
||||
|
||||
it('判别联合按 type 收窄(photo.published 才有 publishedAt)', () => {
|
||||
const msg: ServerMessage = {
|
||||
type: 'photo.published',
|
||||
photoId: 'photo-1',
|
||||
albumId: 'album-1',
|
||||
publishedAt: '2026-09-18T08:00:00.000Z',
|
||||
entityVersion: 3,
|
||||
};
|
||||
if (msg.type === 'photo.published') {
|
||||
expect(msg.publishedAt).toContain('T');
|
||||
} else {
|
||||
// publishedAt 仅存在于 photo.published 分支:其它分支访问须编译失败。
|
||||
// @ts-expect-error photo.hidden 分支没有 publishedAt
|
||||
const absent = msg.publishedAt;
|
||||
expect(absent).toBeUndefined();
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
});
|
||||
|
||||
it('pong 为服务端消息(回显 ping 的 ts)', () => {
|
||||
const pong: ServerMessage = { type: 'pong', ts: 1758187200000 };
|
||||
expect(pong.type).toBe('pong');
|
||||
});
|
||||
|
||||
it('非法 type 在编译期被拒绝', () => {
|
||||
// @ts-expect-error type 必须严格属于联合成员
|
||||
const invalid: ServerMessage = { ...burst, type: 'likes.update' };
|
||||
expect(invalid.type).toBe('likes.update');
|
||||
});
|
||||
|
||||
it('缺少必填字段在编译期被拒绝', () => {
|
||||
// @ts-expect-error likes.burst 缺少 burstId
|
||||
const missing: ServerMessage = { ...burst, burstId: undefined };
|
||||
expect(missing.type).toBe('likes.burst');
|
||||
});
|
||||
|
||||
it('字段类型错误在编译期被拒绝', () => {
|
||||
// @ts-expect-error originBatchIds 必须为 string[]
|
||||
const wrongTypes: ServerMessage = { ...burst, originBatchIds: [1, 2] };
|
||||
expect(wrongTypes.type).toBe('likes.burst');
|
||||
});
|
||||
|
||||
it('type 联合与 6 个消息成员 1:1(穷尽锁定)', () => {
|
||||
const exhaustive: Equal<
|
||||
ServerMessage['type'],
|
||||
| 'photo.published'
|
||||
| 'photo.hidden'
|
||||
| 'photo.deleted'
|
||||
| 'likes.burst'
|
||||
| 'stats.updated'
|
||||
| 'pong'
|
||||
> = true;
|
||||
expect(exhaustive).toBe(true);
|
||||
});
|
||||
|
||||
it('switch 可穷尽处理全部服务端消息', () => {
|
||||
const messages: ServerMessage[] = [
|
||||
burst,
|
||||
{ type: 'photo.published', photoId: 'p', albumId: 'a', publishedAt: '2026-09-18T08:00:00.000Z', entityVersion: 1 },
|
||||
{ type: 'photo.hidden', photoId: 'p', albumId: 'a', entityVersion: 2 },
|
||||
{ type: 'photo.deleted', photoId: 'p', albumId: 'a', entityVersion: 3 },
|
||||
{ type: 'stats.updated', albumId: 'a', viewCount: 99, countVersion: 9 },
|
||||
{ type: 'pong', ts: 1758187200000 },
|
||||
];
|
||||
const kinds = messages.map((m) => {
|
||||
switch (m.type) {
|
||||
case 'photo.published':
|
||||
case 'photo.hidden':
|
||||
case 'photo.deleted':
|
||||
return `photo:${m.entityVersion}`;
|
||||
case 'likes.burst':
|
||||
return `likes:${m.delta}`;
|
||||
case 'stats.updated':
|
||||
return `stats:${m.viewCount}`;
|
||||
case 'pong':
|
||||
return `pong:${m.ts}`;
|
||||
}
|
||||
});
|
||||
expect(kinds).toEqual([
|
||||
'likes:3',
|
||||
'photo:1',
|
||||
'photo:2',
|
||||
'photo:3',
|
||||
'stats:99',
|
||||
'pong:1758187200000',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClientMessage', () => {
|
||||
it('subscribe 的 sinceVersion 可选(缺省不补播历史)', () => {
|
||||
expect(subscribe.sinceVersion).toBeUndefined();
|
||||
expect(subscribeSince.sinceVersion).toBe(7);
|
||||
});
|
||||
|
||||
it('unsubscribe / ping 均合法', () => {
|
||||
const messages: ClientMessage[] = [
|
||||
subscribe,
|
||||
subscribeSince,
|
||||
{ type: 'unsubscribe', albumToken: 'tok' },
|
||||
{ type: 'ping', ts: 1758187200000 },
|
||||
];
|
||||
expect(messages).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('ping 缺少 ts 在编译期被拒绝', () => {
|
||||
// @ts-expect-error ping 必须携带 ts
|
||||
const invalid: ClientMessage = { type: 'ping' };
|
||||
expect(invalid.type).toBe('ping');
|
||||
});
|
||||
|
||||
it('type 联合与 3 个消息成员 1:1(穷尽锁定)', () => {
|
||||
const exhaustive: Equal<
|
||||
ClientMessage['type'],
|
||||
'subscribe' | 'unsubscribe' | 'ping'
|
||||
> = true;
|
||||
expect(exhaustive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HEARTBEAT_MS', () => {
|
||||
it('心跳间隔锁定为 20s(P2 决策初值,改动须回计划登记)', () => {
|
||||
expect(HEARTBEAT_MS).toBe(20000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user