feat(contracts): p2 realtime, likes, visits and internal sync contracts

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 13:34:35 +08:00
parent 06111e1466
commit 24327c5147
9 changed files with 1472 additions and 4 deletions
@@ -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);
});
});