233 lines
11 KiB
TypeScript
233 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
ActivityResponseSchema,
|
|
ChatSyncStatusSchema,
|
|
ChatsResponseSchema,
|
|
ChatSyncTriggerResponseSchema,
|
|
ConfigResponseSchema,
|
|
ConfigUpdateRequestSchema,
|
|
ControlResponseSchema,
|
|
ConversationDetailResponseSchema,
|
|
ConversationsResponseSchema,
|
|
DashboardEventSchema,
|
|
ErrorResponseSchema,
|
|
LoginStatusResponseSchema,
|
|
LegacyDashboardStreamEventSchema,
|
|
MediaResponseSchema,
|
|
MediaSyncStatusSchema,
|
|
MessagesResponseSchema,
|
|
ProcessedPostsResponseSchema,
|
|
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 authoritative start and stop responses', () => {
|
|
expect(ControlResponseSchema.safeParse({ success: true, running: true }).success).toBe(true);
|
|
expect(ControlResponseSchema.safeParse({ success: true, running: false }).success).toBe(true);
|
|
expect(ControlResponseSchema.safeParse({ success: true }).success).toBe(false);
|
|
expect(ControlResponseSchema.safeParse({ success: false, running: true }).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,
|
|
gotImage: 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);
|
|
const processedPost = {
|
|
id: 'post-1', subreddit: 'example', author: 'alice', title: 'Example',
|
|
imageUrl: null, imageAnalysis: null, generatedMessage: 'Hello',
|
|
postUrl: 'https://www.reddit.com/r/example/comments/post-1/',
|
|
processedAt: 1_700_000_000_000, messageSent: true, error: null,
|
|
};
|
|
expect(ProcessedPostsResponseSchema.safeParse([processedPost]).success).toBe(true);
|
|
expect(ProcessedPostsResponseSchema.safeParse([{ ...processedPost, messageSent: 'yes' }]).success).toBe(false);
|
|
});
|
|
|
|
it('validates each analytics response shape', () => {
|
|
expect(SubredditPerformanceResponseSchema.safeParse([{
|
|
subreddit: 'example', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20,
|
|
}]).success).toBe(true);
|
|
expect(StrategyPerformanceResponseSchema.safeParse([{
|
|
strategyId: 'direct', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20, configured: true,
|
|
}]).success).toBe(true);
|
|
expect(DailyTrendResponseSchema.safeParse([{
|
|
date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20,
|
|
}]).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 }, advanced: { media: { syncEnabled: false } },
|
|
}).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/', imageUrl: 'https://i.redd.it/example.jpg', 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 every current login-status branch', () => {
|
|
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);
|
|
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({
|
|
id: 'event-13', type: 'log.created', occurredAt: 1_700_000_000_000,
|
|
payload: { level: 'info', message: 'Started' },
|
|
}).success).toBe(false);
|
|
expect(DashboardEventSchema.safeParse({
|
|
id: 'event-14', type: 'sync.completed', occurredAt,
|
|
payload: { syncId: 's1', kind: 'media' },
|
|
}).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);
|
|
});
|
|
});
|