fix: align realtime event contracts
This commit is contained in:
1 parent
6f803245d4
commit
aef4cbb656
2 files changed
+151
-18
No files matched your search
@@ -3,33 +3,151 @@ import { SyncProgressSchema } from './chats.js';
|
|||||||
|
|
||||||
const EventBase = {
|
const EventBase = {
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
occurredAt: z.number(),
|
occurredAt: z.string().datetime({ offset: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BotRuntimeStatusSchema = z.union([
|
||||||
|
z.literal('starting'),
|
||||||
|
z.literal('running'),
|
||||||
|
z.literal('stopping'),
|
||||||
|
z.literal('stopped'),
|
||||||
|
]);
|
||||||
|
export const ConversationStatusSchema = z.union([
|
||||||
|
z.literal('new'),
|
||||||
|
z.literal('awaiting-reply'),
|
||||||
|
z.literal('replied'),
|
||||||
|
z.literal('closed'),
|
||||||
|
]);
|
||||||
|
export const RealtimeSyncKindSchema = z.union([
|
||||||
|
z.literal('media'),
|
||||||
|
z.literal('messages'),
|
||||||
|
z.literal('conversations'),
|
||||||
|
z.literal('full'),
|
||||||
|
]);
|
||||||
|
export const RealtimeRedditSessionStatusSchema = z.union([
|
||||||
|
z.literal('unknown'),
|
||||||
|
z.literal('authenticating'),
|
||||||
|
z.literal('authenticated'),
|
||||||
|
z.literal('expired'),
|
||||||
|
z.literal('logged-out'),
|
||||||
|
z.literal('error'),
|
||||||
|
]);
|
||||||
|
|
||||||
export const DashboardEventSchema = z.discriminatedUnion('type', [
|
export const DashboardEventSchema = z.discriminatedUnion('type', [
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
...EventBase,
|
...EventBase,
|
||||||
type: z.literal('stream.connected'),
|
type: z.literal('bot.status.changed'),
|
||||||
payload: z.strictObject({}),
|
payload: z.strictObject({
|
||||||
|
status: BotRuntimeStatusSchema,
|
||||||
|
dryRun: z.boolean(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
...EventBase,
|
...EventBase,
|
||||||
type: z.literal('log.created'),
|
type: z.literal('message.sent'),
|
||||||
payload: z.strictObject({
|
payload: z.strictObject({
|
||||||
level: z.union([z.literal('info'), z.literal('warn'), z.literal('error'), z.literal('debug')]),
|
messageId: z.string(),
|
||||||
message: z.string(),
|
recipient: z.string(),
|
||||||
data: z.unknown().optional(),
|
conversationId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...EventBase,
|
||||||
|
type: z.literal('message.failed'),
|
||||||
|
payload: z.strictObject({
|
||||||
|
recipient: z.string(),
|
||||||
|
reason: z.string(),
|
||||||
|
messageId: z.string().optional(),
|
||||||
|
conversationId: z.string().optional(),
|
||||||
|
code: z.string().optional(),
|
||||||
|
retryable: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...EventBase,
|
||||||
|
type: z.literal('conversation.updated'),
|
||||||
|
payload: z.strictObject({
|
||||||
|
conversationId: z.string(),
|
||||||
|
status: ConversationStatusSchema,
|
||||||
|
unreadCount: z.number().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...EventBase,
|
||||||
|
type: z.literal('reply.received'),
|
||||||
|
payload: z.strictObject({
|
||||||
|
conversationId: z.string(),
|
||||||
|
replyId: z.string(),
|
||||||
|
sender: z.string(),
|
||||||
|
preview: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...EventBase,
|
||||||
|
type: z.literal('sync.started'),
|
||||||
|
payload: z.strictObject({
|
||||||
|
syncId: z.string(),
|
||||||
|
kind: RealtimeSyncKindSchema,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
...EventBase,
|
...EventBase,
|
||||||
type: z.literal('sync.progress'),
|
type: z.literal('sync.progress'),
|
||||||
payload: SyncProgressSchema,
|
payload: z.strictObject({
|
||||||
|
syncId: z.string(),
|
||||||
|
kind: RealtimeSyncKindSchema,
|
||||||
|
completed: z.number(),
|
||||||
|
total: z.number().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
...EventBase,
|
...EventBase,
|
||||||
type: z.literal('sync.completed'),
|
type: z.literal('sync.completed'),
|
||||||
payload: z.strictObject({ kind: z.union([z.literal('full'), z.literal('delta')]) }),
|
payload: z.strictObject({
|
||||||
|
syncId: z.string(),
|
||||||
|
kind: RealtimeSyncKindSchema,
|
||||||
|
processed: z.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...EventBase,
|
||||||
|
type: z.literal('sync.failed'),
|
||||||
|
payload: z.strictObject({
|
||||||
|
syncId: z.string(),
|
||||||
|
kind: RealtimeSyncKindSchema,
|
||||||
|
reason: z.string(),
|
||||||
|
code: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...EventBase,
|
||||||
|
type: z.literal('rate-limit.changed'),
|
||||||
|
payload: z.strictObject({
|
||||||
|
limited: z.boolean(),
|
||||||
|
remaining: z.number().optional(),
|
||||||
|
resetsAt: z.string().optional(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...EventBase,
|
||||||
|
type: z.literal('reddit.session.changed'),
|
||||||
|
payload: z.strictObject({
|
||||||
|
status: RealtimeRedditSessionStatusSchema,
|
||||||
|
username: z.string().optional(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...EventBase,
|
||||||
|
type: z.literal('log.created'),
|
||||||
|
payload: z.strictObject({
|
||||||
|
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
|
||||||
|
message: z.string(),
|
||||||
|
data: z.unknown().optional(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -181,17 +181,32 @@ describe('dashboard endpoint contracts', () => {
|
|||||||
|
|
||||||
it('validates the common error response and typed realtime event envelope', () => {
|
it('validates the common error response and typed realtime event envelope', () => {
|
||||||
expect(ErrorResponseSchema.safeParse({ error: 'Conversation not found' }).success).toBe(true);
|
expect(ErrorResponseSchema.safeParse({ error: 'Conversation not found' }).success).toBe(true);
|
||||||
|
const occurredAt = '2026-08-08T13:55:00.000Z';
|
||||||
|
const events = [
|
||||||
|
{ id: '1', occurredAt, type: 'bot.status.changed', payload: { status: 'running', dryRun: false, reason: 'startup complete' } },
|
||||||
|
{ id: '2', occurredAt, type: 'message.sent', payload: { messageId: 'm1', recipient: 'alice', conversationId: 'c1' } },
|
||||||
|
{ id: '3', occurredAt, type: 'message.failed', payload: { recipient: 'alice', reason: 'rate limited', messageId: 'm2', conversationId: 'c1', code: 'limited', retryable: true } },
|
||||||
|
{ id: '4', occurredAt, type: 'conversation.updated', payload: { conversationId: 'c1', status: 'replied', unreadCount: 1 } },
|
||||||
|
{ id: '5', occurredAt, type: 'reply.received', payload: { conversationId: 'c1', replyId: 'r1', sender: 'alice', preview: 'Hi' } },
|
||||||
|
{ id: '6', occurredAt, type: 'sync.started', payload: { syncId: 's1', kind: 'media' } },
|
||||||
|
{ id: '7', occurredAt, type: 'sync.progress', payload: { syncId: 's1', kind: 'media', completed: 3, total: 10, message: 'Downloading' } },
|
||||||
|
{ id: '8', occurredAt, type: 'sync.completed', payload: { syncId: 's1', kind: 'media', processed: 10 } },
|
||||||
|
{ id: '9', occurredAt, type: 'sync.failed', payload: { syncId: 's1', kind: 'media', reason: 'network', code: 'offline' } },
|
||||||
|
{ id: '10', occurredAt, type: 'rate-limit.changed', payload: { limited: true, remaining: 0, resetsAt: occurredAt, reason: 'hourly' } },
|
||||||
|
{ id: '11', occurredAt, type: 'reddit.session.changed', payload: { status: 'authenticated', username: 'bot-user', reason: 'login completed' } },
|
||||||
|
{ id: '12', occurredAt, type: 'log.created', payload: { level: 'info', message: 'Started', data: { port: 3000 } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
expect(DashboardEventSchema.safeParse(event).success, event.type).toBe(true);
|
||||||
|
}
|
||||||
expect(DashboardEventSchema.safeParse({
|
expect(DashboardEventSchema.safeParse({
|
||||||
id: 'event-1',
|
id: 'event-13', type: 'log.created', occurredAt: 1_700_000_000_000,
|
||||||
type: 'sync.progress',
|
payload: { level: 'info', message: 'Started' },
|
||||||
occurredAt: 1_700_000_000_000,
|
}).success).toBe(false);
|
||||||
payload: {
|
|
||||||
kind: 'delta', phase: 'done', current: 1, total: 1,
|
|
||||||
startedAt: 1_699_999_999_000,
|
|
||||||
},
|
|
||||||
}).success).toBe(true);
|
|
||||||
expect(DashboardEventSchema.safeParse({
|
expect(DashboardEventSchema.safeParse({
|
||||||
id: 'event-2', type: 'sync.progress', occurredAt: 'now', payload: null,
|
id: 'event-14', type: 'sync.completed', occurredAt,
|
||||||
|
payload: { syncId: 's1', kind: 'media' },
|
||||||
}).success).toBe(false);
|
}).success).toBe(false);
|
||||||
expect(LegacyDashboardStreamEventSchema.safeParse({
|
expect(LegacyDashboardStreamEventSchema.safeParse({
|
||||||
type: 'connected', timestamp: 1_700_000_000_000,
|
type: 'connected', timestamp: 1_700_000_000_000,
|
||||||
|
|||||||
Reference in new issue
Block a user