Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.
Replace the boolean with an explicit three-way mode:
running — discover, screen, send, automate replies
scan — refresh chats, detect replies, check responses; send nothing
and discover nothing; actionable replies surface as backlog
stopped — everything halts, including background media sync
scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.
The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
13 KiB
TypeScript
286 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
ActivityResponseSchema,
|
|
ChatSyncStatusSchema,
|
|
ChatsResponseSchema,
|
|
ChatSyncTriggerResponseSchema,
|
|
ConfigResponseSchema,
|
|
ConfigUpdateRequestSchema,
|
|
ControlResponseSchema,
|
|
ConversationDetailResponseSchema,
|
|
ConversationsResponseSchema,
|
|
DashboardEventSchema,
|
|
ErrorResponseSchema,
|
|
LoginStatusResponseSchema,
|
|
LegacyDashboardStreamEventSchema,
|
|
MediaResponseSchema,
|
|
MediaLibraryAssetSchema,
|
|
MediaSyncStatusSchema,
|
|
MessagesResponseSchema,
|
|
ProcessedPostsResponseSchema,
|
|
StatsResponseSchema,
|
|
StrategyPerformanceResponseSchema,
|
|
SubredditPerformanceResponseSchema,
|
|
DailyTrendResponseSchema,
|
|
FunnelResponseSchema,
|
|
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,
|
|
mode: 'running',
|
|
dryRunMode: false,
|
|
totalProcessed: 10,
|
|
totalMessagesSent: 8,
|
|
totalResponses: 3,
|
|
responseRate: 38,
|
|
messagesThisHour: 2,
|
|
maxMessagesPerHour: 8,
|
|
subreddits: [{ name: 'example', type: 'image' }],
|
|
targetGenders: ['female'],
|
|
recentActivity: [activity],
|
|
accounts: [{
|
|
accountId: '@t2_owner:reddit.com', redditUsername: 'owner', runtimeState: 'running',
|
|
authenticated: true, sendingState: 'ready', messagesThisHour: 2, maxMessagesPerHour: 8,
|
|
nextMessageAvailableAt: null, hourlyResetAt: 1_700_003_600_000, messagesRemaining: 6,
|
|
actionableReplies: 0, invitationCooldown: null, failure: null,
|
|
}],
|
|
invitationCooldown: null,
|
|
};
|
|
expect(StatsResponseSchema.parse(payload)).toEqual(payload);
|
|
expect(StatsResponseSchema.safeParse({ ...payload, isRunning: 'yes' }).success).toBe(false);
|
|
});
|
|
|
|
it('validates authoritative start, scan and stop responses', () => {
|
|
expect(ControlResponseSchema.safeParse({ success: true, running: true, mode: 'running' }).success).toBe(true);
|
|
expect(ControlResponseSchema.safeParse({ success: true, running: false, mode: 'scan' }).success).toBe(true);
|
|
expect(ControlResponseSchema.safeParse({ success: true, running: false, mode: 'stopped' }).success).toBe(true);
|
|
expect(ControlResponseSchema.safeParse({ success: true, running: true }).success).toBe(false);
|
|
expect(ControlResponseSchema.safeParse({ success: true, running: false, mode: 'paused' }).success).toBe(false);
|
|
expect(ControlResponseSchema.safeParse({ success: true }).success).toBe(false);
|
|
expect(ControlResponseSchema.safeParse({ success: false, running: true, mode: 'running' }).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,
|
|
experimentId: 'message:variant-a:abc123',
|
|
promptVersion: 'abc123',
|
|
modelId: 'openai/gpt-5.2',
|
|
selectionReason: 'exploit',
|
|
sourceType: 'image',
|
|
postCreatedAt: 1_699_999_900_000,
|
|
}]).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(FunnelResponseSchema.safeParse({
|
|
responseWindowHours: 48, processed: 20, sent: 10, pending: 2, mature: 8,
|
|
responded: 4, engaged: 3, declined: 1, boundary: 0, upsell: 0, images: 2,
|
|
}).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,
|
|
maxReadyCandidates: 25,
|
|
physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
|
|
},
|
|
subreddits: [{ name: 'example', enabled: true, type: 'chat', weight: 2, tone: 'direct' }],
|
|
tones: ['default', 'direct'],
|
|
};
|
|
expect(ConfigResponseSchema.parse(payload)).toEqual({
|
|
...payload,
|
|
subreddits: [{
|
|
...payload.subreddits[0],
|
|
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
|
}],
|
|
});
|
|
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',
|
|
postedAt: 1_699_999_000_000, sentAt: 1_700_000_000_000,
|
|
},
|
|
automation: { turnsUsed: 1, remainingTurns: 2, status: 'active', handoffReason: null },
|
|
syncInProgress: null,
|
|
}).success).toBe(true);
|
|
expect(ConversationDetailResponseSchema.safeParse({
|
|
...chat,
|
|
triggeringPost: null,
|
|
automation: { turnsUsed: 4, remainingTurns: 2, status: 'active', handoffReason: null },
|
|
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 exchange-library classifications without exposing local paths', () => {
|
|
const payload = {
|
|
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie', 'topless'], safety: 'nsfw',
|
|
classificationStatus: 'ready', description: 'A self portrait', confidence: 0.9,
|
|
fileName: 'portrait.jpg', mimeType: 'image/jpeg', sizeBytes: 100, width: 10, height: 20,
|
|
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1, sendCount: 0, lastSentAt: null,
|
|
urlPath: '/library-media/id.jpg',
|
|
};
|
|
expect(MediaLibraryAssetSchema.safeParse(payload).success).toBe(true);
|
|
expect(MediaLibraryAssetSchema.safeParse({ ...payload, relativePath: 'private/id.jpg' }).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);
|
|
});
|
|
});
|