feat: add shared dashboard API contracts

This commit is contained in:
jjbrunton committed 2026-08-08 14:52:06 +01:00
1 parent 939cf408a0
commit 59c3a61a88
14 files changed
+648

No files matched your search

@@ -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);
});
});