feat(contracts): outbox event contract types

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 18:18:06 +08:00
parent b4ee92de96
commit f77ab5ae4b
4 changed files with 139 additions and 2 deletions
@@ -0,0 +1,95 @@
// packages/contracts/src/__tests__/events.test.ts
// 事件契约测试:合法事件编译通过、非法 type 编译期拒绝、OUTBOX_EVENT_TYPES
// 与接口 union 1:1,以及 openapi Error.code enum 与 errors.ts ErrorCodes 1:1
// (Task 4 审查遗留的枚举同步守护)。ts-jest 为全类型检查模式,@ts-expect-error
// 与 Equal 断言在编译期生效。
import { ErrorCodes, type ErrorCode } from '../errors.js';
import type { components } from '../generated/openapi.js';
import {
OUTBOX_EVENT_TYPES,
type OutboxEvent,
type PhotoPublishedPayload,
} from '../events.js';
// 类型恒等辅助:仅当 X 与 Y 是完全相同的类型(双向兼容,多/少一个成员都为 false)。
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
? true
: false;
// openapi.yaml 生成的 Error.code enum(src/generated/openapi.d.ts)。
type ApiErrorCode = components['schemas']['Error']['code'];
const publishedEvent: OutboxEvent<PhotoPublishedPayload> = {
eventId: 'cxxxxxxxxxxxxxxxxxxxxxx',
type: 'photo.published',
albumId: 'album-1',
photoId: 'photo-1',
entityVersion: 1,
payload: {
photoId: 'photo-1',
albumId: 'album-1',
objectKey: 'albums/album-1/photo-1.webp',
thumbKey: 'thumbs/album-1/photo-1.webp',
browseKey: 'browse/album-1/photo-1.webp',
width: 4032,
height: 3024,
publishedAt: '2026-09-17T08:00:00.000Z',
entityVersion: 1,
},
occurredAt: '2026-09-17T08:00:00.000Z',
};
describe('OutboxEvent', () => {
it('合法的 photo.published 事件通过类型检查且字段完整', () => {
expect(publishedEvent.type).toBe('photo.published');
expect(publishedEvent.payload).toMatchObject({
photoId: 'photo-1',
albumId: 'album-1',
width: 4032,
height: 3024,
});
});
it('非法 type 在编译期被拒绝', () => {
// @ts-expect-error 事件类型必须严格属于 §8.2 定义的 5 种之一
const invalidType: OutboxEvent<PhotoPublishedPayload> = { ...publishedEvent, type: 'photo.uploaded' };
expect(invalidType.type).toBe('photo.uploaded');
});
});
describe('OUTBOX_EVENT_TYPES', () => {
it('恰好包含全部 5 个事件类型字符串', () => {
expect([...OUTBOX_EVENT_TYPES]).toEqual([
'photo.published',
'photo.hidden',
'photo.deleted',
'photo.likes.updated',
'album.updated',
]);
});
it('数组与接口 union 类型层 1:1(satisfies 管数组⊆union,Equal 管 union⊆数组)', () => {
const arrayEqualsUnion: Equal<
(typeof OUTBOX_EVENT_TYPES)[number],
OutboxEvent['type']
> = true;
expect(arrayEqualsUnion).toBe(true);
});
});
describe('openapi Error.code enum ↔ errors.ts ErrorCodes 同步', () => {
it('两侧 code 联合类型双向恒等(1:1)', () => {
const apiEqualsErrors: Equal<ApiErrorCode, ErrorCode> = true;
const errorsEqualsApi: Equal<ErrorCode, ApiErrorCode> = true;
expect(apiEqualsErrors && errorsEqualsApi).toBe(true);
});
it('运行时兜底:ErrorCodes 值唯一且与 key 同名', () => {
const entries = Object.entries(ErrorCodes);
expect(new Set(entries.map(([, code]) => code)).size).toBe(entries.length);
for (const [key, code] of entries) {
expect(key).toBe(code);
}
});
});