From 59c3a61a8877dad10f7b2ef89c33dc4dc222eec2 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 14:50:58 +0100 Subject: [PATCH] feat: add shared dashboard API contracts --- packages/api-contracts/package.json | 17 ++ packages/api-contracts/src/activity.ts | 21 ++ packages/api-contracts/src/chats.ts | 95 ++++++++ packages/api-contracts/src/common.ts | 15 ++ packages/api-contracts/src/config.ts | 53 +++++ packages/api-contracts/src/events.ts | 56 +++++ packages/api-contracts/src/index.ts | 9 + packages/api-contracts/src/media.ts | 32 +++ packages/api-contracts/src/messages.ts | 34 +++ packages/api-contracts/src/session.ts | 34 +++ packages/api-contracts/src/stats.ts | 59 +++++ .../api-contracts/tests/contracts.test.ts | 203 ++++++++++++++++++ packages/api-contracts/tsconfig.json | 12 ++ packages/api-contracts/vitest.config.ts | 8 + 14 files changed, 648 insertions(+) create mode 100644 packages/api-contracts/package.json create mode 100644 packages/api-contracts/src/activity.ts create mode 100644 packages/api-contracts/src/chats.ts create mode 100644 packages/api-contracts/src/common.ts create mode 100644 packages/api-contracts/src/config.ts create mode 100644 packages/api-contracts/src/events.ts create mode 100644 packages/api-contracts/src/index.ts create mode 100644 packages/api-contracts/src/media.ts create mode 100644 packages/api-contracts/src/messages.ts create mode 100644 packages/api-contracts/src/session.ts create mode 100644 packages/api-contracts/src/stats.ts create mode 100644 packages/api-contracts/tests/contracts.test.ts create mode 100644 packages/api-contracts/tsconfig.json create mode 100644 packages/api-contracts/vitest.config.ts diff --git a/packages/api-contracts/package.json b/packages/api-contracts/package.json new file mode 100644 index 0000000..5b70ff5 --- /dev/null +++ b/packages/api-contracts/package.json @@ -0,0 +1,17 @@ +{ + "name": "@reddit-bot/api-contracts", + "version": "0.1.0", + "private": true, + "type": "module", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { + "zod": "^4.3.5" + } +} diff --git a/packages/api-contracts/src/activity.ts b/packages/api-contracts/src/activity.ts new file mode 100644 index 0000000..99a659b --- /dev/null +++ b/packages/api-contracts/src/activity.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const ActivityTypeSchema = z.union([ + z.literal('sent'), + z.literal('skipped'), + z.literal('response'), + z.literal('error'), + z.literal('info'), + z.literal('debug'), +]); + +export const ActivityItemSchema = z.strictObject({ + timestamp: z.number(), + type: ActivityTypeSchema, + message: z.string(), + recipient: z.string().optional(), + imageUrl: z.string().optional(), +}); + +export const ActivityResponseSchema = z.array(ActivityItemSchema); +export type ActivityItem = z.infer; diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts new file mode 100644 index 0000000..f053ff4 --- /dev/null +++ b/packages/api-contracts/src/chats.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; +import { ErrorResponseSchema, RejectedActionResponseSchema } from './common.js'; + +export const ChatMediaSchema = z.strictObject({ + originalUrl: z.string(), + localPath: z.string(), + fileName: z.string(), +}); +export const ChatMessageSchema = z.strictObject({ + sender: z.string(), + text: z.string(), + timestamp: z.number(), + isSelf: z.boolean(), + media: z.array(ChatMediaSchema).optional(), +}); +export const ActiveChatSchema = z.strictObject({ + username: z.string(), + roomName: z.string(), + lastMessageTime: z.number(), + lastMessageSnippet: z.string(), + isFromUs: z.boolean(), + unread: z.boolean(), + url: z.string().optional(), + roomId: z.string().optional(), + messages: z.array(ChatMessageSchema).optional(), + originMessage: z.string().optional(), +}); +export const ChatsResponseSchema = z.array(ActiveChatSchema); +export const ConversationsResponseSchema = ChatsResponseSchema; + +export const SyncKindSchema = z.union([z.literal('full'), z.literal('delta')]); +export const SyncPhaseSchema = z.union([ + z.literal('starting'), + z.literal('seeding'), + z.literal('listing-rooms'), + z.literal('resolving-usernames'), + z.literal('loading-details'), + z.literal('finalizing'), + z.literal('done'), +]); +export const SyncProgressSchema = z.strictObject({ + kind: SyncKindSchema, + phase: SyncPhaseSchema, + current: z.number(), + total: z.number(), + label: z.string().optional(), + startedAt: z.number(), +}); +export const ChatSyncStatusSchema = z.strictObject({ + syncing: z.boolean(), + syncingKind: SyncKindSchema.nullable(), + lastFullSyncAt: z.number().nullable(), + lastDeltaSyncAt: z.number().nullable(), + hasEverFullSynced: z.boolean(), + cachedChats: z.number(), + progress: SyncProgressSchema.nullable(), +}); + +export const ConversationDetailResponseSchema = ActiveChatSchema.extend({ + triggeringPost: z.strictObject({ + id: z.string(), + subreddit: z.string().optional(), + title: z.string().optional(), + url: z.string(), + sentAt: z.number(), + }).nullable(), + syncInProgress: z.strictObject({ + kind: SyncKindSchema.nullable(), + progress: SyncProgressSchema.nullable(), + staleMessages: z.boolean(), + }).nullable(), +}); +export const SuggestedReplyResponseSchema = z.strictObject({ suggestedReply: z.string() }); +export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) }); +export const StartConversationRequestSchema = z.strictObject({ + username: z.string().min(1), + message: z.string().min(1), +}); +export const StartConversationResponseSchema = z.strictObject({ + success: z.literal(true), + chat: ActiveChatSchema, +}); +export const ChatSyncTriggerResponseSchema = z.union([ + z.strictObject({ success: z.literal(true), kind: SyncKindSchema }), + RejectedActionResponseSchema, + ErrorResponseSchema, +]); +export const FetchAllChatsResponseSchema = z.strictObject({ + success: z.literal(true), + count: z.number(), +}); + +export type ActiveChat = z.infer; +export type ChatSyncStatus = z.infer; +export type ConversationDetailResponse = z.infer; diff --git a/packages/api-contracts/src/common.ts b/packages/api-contracts/src/common.ts new file mode 100644 index 0000000..2fcbbec --- /dev/null +++ b/packages/api-contracts/src/common.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const ErrorResponseSchema = z.strictObject({ + error: z.string(), + detail: z.unknown().optional(), +}); + +export const SuccessResponseSchema = z.strictObject({ success: z.literal(true) }); +export const RejectedActionResponseSchema = z.strictObject({ + success: z.literal(false), + reason: z.string(), +}); + +export type ErrorResponse = z.infer; +export type SuccessResponse = z.infer; diff --git a/packages/api-contracts/src/config.ts b/packages/api-contracts/src/config.ts new file mode 100644 index 0000000..99382fe --- /dev/null +++ b/packages/api-contracts/src/config.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +export const ToneKeySchema = z.union([ + z.literal('default'), + z.literal('dominant'), + z.literal('submissive'), + z.literal('playful'), + z.literal('direct'), + z.literal('gentle'), + z.literal('kink-friendly'), + z.literal('teasing'), +]); + +export const RateLimitConfigSchema = z.strictObject({ + minDelayBetweenMessagesMs: z.number(), + maxMessagesPerHour: z.number(), + jitterMs: z.number(), +}); +export const RuntimeRedditConfigSchema = z.strictObject({ + prioritizeRecentPosts: z.boolean(), + recentWindowMinutes: z.number(), +}); +export const RuntimeConfigSchema = z.strictObject({ + rateLimit: RateLimitConfigSchema, + browser: z.strictObject({ dryRun: z.boolean() }), + reddit: RuntimeRedditConfigSchema, +}); +export const SubredditConfigSchema = z.strictObject({ + name: z.string(), + type: z.union([z.literal('image'), z.literal('chat')]), + weight: z.number().optional(), + tone: ToneKeySchema, + scripted: z.string().optional(), +}); +export const ConfigResponseSchema = RuntimeConfigSchema.extend({ + subreddits: z.array(SubredditConfigSchema), + tones: z.array(ToneKeySchema), +}); +export const ConfigUpdateRequestSchema = z.strictObject({ + rateLimit: RateLimitConfigSchema.partial().optional(), + browser: z.strictObject({ dryRun: z.boolean().optional() }).optional(), + reddit: RuntimeRedditConfigSchema.partial().optional(), + subreddits: z.array(SubredditConfigSchema).min(1).optional(), +}); +export const ConfigUpdateResponseSchema = z.strictObject({ + success: z.literal(true), + config: RuntimeConfigSchema, + subreddits: z.array(SubredditConfigSchema), +}); + +export type RuntimeConfig = z.infer; +export type ConfigResponse = z.infer; +export type ConfigUpdateRequest = z.infer; diff --git a/packages/api-contracts/src/events.ts b/packages/api-contracts/src/events.ts new file mode 100644 index 0000000..146c052 --- /dev/null +++ b/packages/api-contracts/src/events.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; +import { SyncProgressSchema } from './chats.js'; + +const EventBase = { + id: z.string(), + occurredAt: z.number(), +}; + +export const DashboardEventSchema = z.discriminatedUnion('type', [ + z.strictObject({ + ...EventBase, + type: z.literal('stream.connected'), + payload: z.strictObject({}), + }), + z.strictObject({ + ...EventBase, + type: z.literal('log.created'), + payload: z.strictObject({ + level: z.union([z.literal('info'), z.literal('warn'), z.literal('error'), z.literal('debug')]), + message: z.string(), + data: z.unknown().optional(), + }), + }), + z.strictObject({ + ...EventBase, + type: z.literal('sync.progress'), + payload: SyncProgressSchema, + }), + z.strictObject({ + ...EventBase, + type: z.literal('sync.completed'), + payload: z.strictObject({ kind: z.union([z.literal('full'), z.literal('delta')]) }), + }), +]); + +/** Existing `/api/logs/stream` frames, retained for an incremental migration. */ +export const LegacyDashboardStreamEventSchema = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('connected'), + timestamp: z.number(), + }), + z.strictObject({ + type: z.literal('log'), + timestamp: z.number(), + level: z.union([z.literal('info'), z.literal('warn'), z.literal('error'), z.literal('debug')]), + message: z.string(), + data: z.unknown().optional(), + }), + z.strictObject({ + type: z.literal('sync-progress'), + progress: SyncProgressSchema.nullable(), + }), +]); + +export type DashboardEvent = z.infer; +export type LegacyDashboardStreamEvent = z.infer; diff --git a/packages/api-contracts/src/index.ts b/packages/api-contracts/src/index.ts new file mode 100644 index 0000000..9e2b99c --- /dev/null +++ b/packages/api-contracts/src/index.ts @@ -0,0 +1,9 @@ +export * from './activity.js'; +export * from './chats.js'; +export * from './common.js'; +export * from './config.js'; +export * from './events.js'; +export * from './media.js'; +export * from './messages.js'; +export * from './session.js'; +export * from './stats.js'; diff --git a/packages/api-contracts/src/media.ts b/packages/api-contracts/src/media.ts new file mode 100644 index 0000000..90c708e --- /dev/null +++ b/packages/api-contracts/src/media.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { RejectedActionResponseSchema, SuccessResponseSchema } from './common.js'; + +export const MediaItemSchema = z.strictObject({ + id: z.string(), + chatUsername: z.string().nullable(), + chatRoomId: z.string().nullable(), + sender: z.string(), + timestamp: z.number(), + originalUrl: z.string(), + fileName: z.string(), + relativePath: z.string(), + createdAt: z.number(), + urlPath: z.string(), +}); +export const MediaResponseSchema = z.array(MediaItemSchema); +export const MediaSyncStatusSchema = z.strictObject({ + isSyncing: z.boolean(), + lastSyncAt: z.number().nullable(), + lastSyncDurationMs: z.number().nullable(), + lastSyncChats: z.number().nullable(), + lastError: z.string().nullable(), + nextSyncAt: z.number().nullable(), +}); +export const MediaSyncRequestSchema = z.strictObject({ full: z.boolean().optional() }); +export const MediaSyncTriggerResponseSchema = z.union([ + SuccessResponseSchema, + RejectedActionResponseSchema, +]); + +export type MediaItem = z.infer; +export type MediaSyncStatus = z.infer; diff --git a/packages/api-contracts/src/messages.ts b/packages/api-contracts/src/messages.ts new file mode 100644 index 0000000..21a92c8 --- /dev/null +++ b/packages/api-contracts/src/messages.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const SentMessageSchema = z.strictObject({ + id: z.number(), + postId: z.string().nullable(), + recipient: z.string(), + subject: z.string(), + message: z.string(), + sentAt: z.number(), + gotResponse: z.boolean(), + responseCheckedAt: z.number().nullable(), + strategyId: z.string().nullable(), + chatRoomId: z.string().nullable(), + subreddit: z.string().optional(), + postTitle: z.string().optional(), + postImageUrl: z.string().optional(), + postImageAnalysis: z.string().optional(), +}); +export const MessagesResponseSchema = z.array(SentMessageSchema); + +export const SuccessfulExampleSchema = z.strictObject({ + id: z.number(), + sentMessageId: z.number().nullable(), + originalMessage: z.string(), + imageAnalysis: z.string().nullable(), + postTitle: z.string().nullable(), + responseSnippet: z.string().nullable(), + subreddit: z.string().nullable(), + createdAt: z.number(), +}); +export const SuccessfulExamplesResponseSchema = z.array(SuccessfulExampleSchema); + +export type SentMessage = z.infer; +export type SuccessfulExample = z.infer; diff --git a/packages/api-contracts/src/session.ts b/packages/api-contracts/src/session.ts new file mode 100644 index 0000000..faf8d21 --- /dev/null +++ b/packages/api-contracts/src/session.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const RedditSessionStateSchema = z.union([ + z.literal('unknown'), + z.literal('authenticated'), + z.literal('login-required'), + z.literal('unreachable'), +]); +export const LoginInfoResponseSchema = z.strictObject({ + vncEnabled: z.boolean(), + novncPort: z.number(), + publicUrl: z.string().nullable(), + passwordRequired: z.boolean(), +}); +export const LoginStatusResponseSchema = z.union([ + z.strictObject({ ready: z.literal(false), error: z.string() }), + z.strictObject({ + ready: z.literal(true), + loggedIn: z.boolean(), + status: RedditSessionStateSchema, + }), + z.strictObject({ + ready: z.literal(true), + loggedIn: z.literal(false), + error: z.string(), + }), +]); +export const LoginStartResponseSchema = z.union([ + z.strictObject({ ok: z.literal(true) }), + z.strictObject({ ok: z.literal(false), error: z.string() }), +]); + +export type RedditSessionState = z.infer; +export type LoginStatusResponse = z.infer; diff --git a/packages/api-contracts/src/stats.ts b/packages/api-contracts/src/stats.ts new file mode 100644 index 0000000..4a0504d --- /dev/null +++ b/packages/api-contracts/src/stats.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import { ActivityItemSchema } from './activity.js'; + +const PerformanceFields = { + totalSent: z.number(), + totalResponses: z.number(), + responseRate: z.number(), +}; + +export const StatsResponseSchema = z.strictObject({ + isRunning: z.boolean(), + dryRunMode: z.boolean(), + totalProcessed: z.number(), + totalMessagesSent: z.number(), + totalResponses: z.number(), + responseRate: z.number(), + messagesThisHour: z.number(), + maxMessagesPerHour: z.number(), + subreddits: z.array(z.strictObject({ + name: z.string(), + type: z.union([z.literal('image'), z.literal('chat')]), + })), + targetGenders: z.array(z.string()), + recentActivity: z.array(ActivityItemSchema), + invitationCooldown: z.strictObject({ + until: z.number(), + reason: z.string(), + redditCode: z.string().nullable(), + }).nullable(), +}); + +export const TimersResponseSchema = z.strictObject({ + nextMessageAvailableAt: z.number().nullable(), + hourlyResetAt: z.number(), + isHourlyLimitReached: z.boolean(), + messagesRemaining: z.number(), +}); + +export const SubredditPerformanceSchema = z.strictObject({ + subreddit: z.string(), + ...PerformanceFields, +}); +export const SubredditPerformanceResponseSchema = z.array(SubredditPerformanceSchema); + +export const StrategyPerformanceSchema = z.strictObject({ + strategyId: z.string(), + ...PerformanceFields, + configured: z.boolean().optional(), +}); +export const StrategyPerformanceResponseSchema = z.array(StrategyPerformanceSchema); + +export const DailyTrendSchema = z.strictObject({ + date: z.string(), + ...PerformanceFields, +}); +export const DailyTrendResponseSchema = z.array(DailyTrendSchema); + +export type StatsResponse = z.infer; +export type TimersResponse = z.infer; diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts new file mode 100644 index 0000000..f32fb31 --- /dev/null +++ b/packages/api-contracts/tests/contracts.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { + ActivityResponseSchema, + ChatSyncStatusSchema, + ChatsResponseSchema, + ChatSyncTriggerResponseSchema, + ConfigResponseSchema, + ConfigUpdateRequestSchema, + ConversationDetailResponseSchema, + ConversationsResponseSchema, + DashboardEventSchema, + ErrorResponseSchema, + LoginInfoResponseSchema, + LoginStatusResponseSchema, + LegacyDashboardStreamEventSchema, + MediaResponseSchema, + MediaSyncStatusSchema, + MessagesResponseSchema, + StatsResponseSchema, + StrategyPerformanceResponseSchema, + SubredditPerformanceResponseSchema, + DailyTrendResponseSchema, + SuccessfulExamplesResponseSchema, + TimersResponseSchema, +} from '../src/index.js'; + +const activity = { + timestamp: 1_700_000_000_000, + type: 'sent', + message: 'Sent message', + recipient: 'alice', +}; + +const chat = { + username: 'alice', + roomName: 'Alice', + lastMessageTime: 1_700_000_000_000, + lastMessageSnippet: 'hello', + isFromUs: false, + unread: true, + roomId: '!room:reddit.com', + messages: [{ sender: 'alice', text: 'hello', timestamp: 1_700_000_000_000, isSelf: false }], +}; + +describe('dashboard endpoint contracts', () => { + it('accepts current stats and rejects malformed stats', () => { + const payload = { + isRunning: true, + dryRunMode: false, + totalProcessed: 10, + totalMessagesSent: 8, + totalResponses: 3, + responseRate: 38, + messagesThisHour: 2, + maxMessagesPerHour: 8, + subreddits: [{ name: 'example', type: 'image' }], + targetGenders: ['female'], + recentActivity: [activity], + invitationCooldown: null, + }; + expect(StatsResponseSchema.parse(payload)).toEqual(payload); + expect(StatsResponseSchema.safeParse({ ...payload, isRunning: 'yes' }).success).toBe(false); + }); + + it('validates timer, activity, message, and successful-example payloads', () => { + expect(TimersResponseSchema.safeParse({ + nextMessageAvailableAt: null, + hourlyResetAt: 1_700_000_100_000, + isHourlyLimitReached: false, + messagesRemaining: 6, + }).success).toBe(true); + expect(ActivityResponseSchema.safeParse([activity]).success).toBe(true); + expect(MessagesResponseSchema.safeParse([{ + id: 1, + postId: 'post-1', + recipient: 'alice', + subject: 'Hi', + message: 'Hello', + sentAt: 1_700_000_000_000, + gotResponse: false, + responseCheckedAt: null, + strategyId: null, + chatRoomId: null, + }]).success).toBe(true); + expect(SuccessfulExamplesResponseSchema.safeParse([{ + id: 1, + sentMessageId: null, + originalMessage: 'Hello', + imageAnalysis: null, + postTitle: null, + responseSnippet: 'Hi', + subreddit: 'example', + createdAt: 1_700_000_000_000, + }]).success).toBe(true); + expect(TimersResponseSchema.safeParse({ hourlyResetAt: 'later' }).success).toBe(false); + expect(ActivityResponseSchema.safeParse([{ ...activity, type: 'bogus' }]).success).toBe(false); + expect(MessagesResponseSchema.safeParse([{ id: '1' }]).success).toBe(false); + }); + + it('validates each analytics response shape', () => { + expect(SubredditPerformanceResponseSchema.safeParse([{ + subreddit: 'example', totalSent: 10, totalResponses: 4, responseRate: 40, + }]).success).toBe(true); + expect(StrategyPerformanceResponseSchema.safeParse([{ + strategyId: 'direct', totalSent: 10, totalResponses: 4, responseRate: 40, configured: true, + }]).success).toBe(true); + expect(DailyTrendResponseSchema.safeParse([{ + date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40, + }]).success).toBe(true); + expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: '10' }]).success).toBe(false); + }); + + it('validates runtime config returned by the dashboard', () => { + const payload = { + rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 }, + browser: { dryRun: false }, + reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 60 }, + subreddits: [{ name: 'example', type: 'chat', weight: 2, tone: 'direct' }], + tones: ['default', 'direct'], + }; + expect(ConfigResponseSchema.parse(payload)).toEqual(payload); + expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false); + expect(ConfigUpdateRequestSchema.safeParse({ + rateLimit: { maxMessagesPerHour: 10 }, browser: { dryRun: true }, + }).success).toBe(true); + expect(ConfigUpdateRequestSchema.safeParse({ browser: { dryRun: 'yes' } }).success).toBe(false); + }); + + it('validates chats, sync state, conversation detail, and malformed messages', () => { + expect(ChatsResponseSchema.safeParse([chat]).success).toBe(true); + expect(ConversationsResponseSchema.safeParse([chat]).success).toBe(true); + expect(ChatSyncStatusSchema.safeParse({ + syncing: true, + syncingKind: 'full', + lastFullSyncAt: null, + lastDeltaSyncAt: 1_700_000_000_000, + hasEverFullSynced: false, + cachedChats: 12, + progress: { + kind: 'full', phase: 'loading-details', current: 3, total: 12, + label: 'alice', startedAt: 1_700_000_000_000, + }, + }).success).toBe(true); + expect(ConversationDetailResponseSchema.safeParse({ + ...chat, + triggeringPost: { + id: 'post-1', subreddit: 'example', title: 'Example', + url: 'https://www.reddit.com/r/example/comments/post-1/', sentAt: 1_700_000_000_000, + }, + syncInProgress: null, + }).success).toBe(true); + expect(ChatsResponseSchema.safeParse([{ ...chat, messages: [{ sender: 'alice' }] }]).success).toBe(false); + expect(ChatSyncTriggerResponseSchema.safeParse({ success: true, kind: 'delta' }).success).toBe(true); + expect(ChatSyncTriggerResponseSchema.safeParse({ success: false, reason: 'Already running full sync' }).success).toBe(true); + }); + + it('validates media items and media sync status', () => { + expect(MediaResponseSchema.safeParse([{ + id: 'sha1', chatUsername: 'alice', chatRoomId: '!room:reddit.com', sender: 'alice', + timestamp: 1_700_000_000_000, originalUrl: 'https://example.com/a.jpg', fileName: 'a.jpg', + relativePath: 'a.jpg', createdAt: 1_700_000_000_100, urlPath: '/media/a.jpg', + }]).success).toBe(true); + expect(MediaSyncStatusSchema.safeParse({ + isSyncing: false, lastSyncAt: null, lastSyncDurationMs: null, + lastSyncChats: null, lastError: null, nextSyncAt: null, + }).success).toBe(true); + expect(MediaResponseSchema.safeParse([{ id: 'sha1', timestamp: 'now' }]).success).toBe(false); + }); + + it('validates login info and every current login-status branch', () => { + expect(LoginInfoResponseSchema.safeParse({ + vncEnabled: true, novncPort: 6080, publicUrl: null, passwordRequired: true, + }).success).toBe(true); + expect(LoginStatusResponseSchema.safeParse({ + ready: true, loggedIn: true, status: 'authenticated', + }).success).toBe(true); + expect(LoginStatusResponseSchema.safeParse({ ready: false, error: 'browser not initialised' }).success).toBe(true); + expect(LoginStatusResponseSchema.safeParse({ ready: true, loggedIn: false, error: 'check failed' }).success).toBe(true); + expect(LoginStatusResponseSchema.safeParse({ ready: true, loggedIn: true, status: 'expired' }).success).toBe(false); + }); + + it('validates the common error response and typed realtime event envelope', () => { + expect(ErrorResponseSchema.safeParse({ error: 'Conversation not found' }).success).toBe(true); + expect(DashboardEventSchema.safeParse({ + id: 'event-1', + type: 'sync.progress', + occurredAt: 1_700_000_000_000, + payload: { + kind: 'delta', phase: 'done', current: 1, total: 1, + startedAt: 1_699_999_999_000, + }, + }).success).toBe(true); + expect(DashboardEventSchema.safeParse({ + id: 'event-2', type: 'sync.progress', occurredAt: 'now', payload: null, + }).success).toBe(false); + expect(LegacyDashboardStreamEventSchema.safeParse({ + type: 'connected', timestamp: 1_700_000_000_000, + }).success).toBe(true); + expect(LegacyDashboardStreamEventSchema.safeParse({ + type: 'sync-progress', progress: null, + }).success).toBe(true); + }); +}); diff --git a/packages/api-contracts/tsconfig.json b/packages/api-contracts/tsconfig.json new file mode 100644 index 0000000..926385e --- /dev/null +++ b/packages/api-contracts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/api-contracts/vitest.config.ts b/packages/api-contracts/vitest.config.ts new file mode 100644 index 0000000..2f1f3b0 --- /dev/null +++ b/packages/api-contracts/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + root: import.meta.dirname, + test: { + include: ['tests/**/*.test.ts'], + }, +});