feat: add shared dashboard API contracts
This commit is contained in:
1 parent
316dc3dd3e
commit
6f803245d4
14 files changed
+648
No files matched your search
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof ActivityItemSchema>;
|
||||||
@@ -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<typeof ActiveChatSchema>;
|
||||||
|
export type ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
|
||||||
|
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
|
||||||
@@ -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<typeof ErrorResponseSchema>;
|
||||||
|
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
|
||||||
@@ -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<typeof RuntimeConfigSchema>;
|
||||||
|
export type ConfigResponse = z.infer<typeof ConfigResponseSchema>;
|
||||||
|
export type ConfigUpdateRequest = z.infer<typeof ConfigUpdateRequestSchema>;
|
||||||
@@ -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<typeof DashboardEventSchema>;
|
||||||
|
export type LegacyDashboardStreamEvent = z.infer<typeof LegacyDashboardStreamEventSchema>;
|
||||||
@@ -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';
|
||||||
@@ -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<typeof MediaItemSchema>;
|
||||||
|
export type MediaSyncStatus = z.infer<typeof MediaSyncStatusSchema>;
|
||||||
@@ -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<typeof SentMessageSchema>;
|
||||||
|
export type SuccessfulExample = z.infer<typeof SuccessfulExampleSchema>;
|
||||||
@@ -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<typeof RedditSessionStateSchema>;
|
||||||
|
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
|
||||||
@@ -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<typeof StatsResponseSchema>;
|
||||||
|
export type TimersResponse = z.infer<typeof TimersResponseSchema>;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: import.meta.dirname,
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user