feat(bots): Implement comprehensive bot system with autonomous posting, content management, and API endpoints

- Add bot management system with creation, suspension, and reinstatement functionality
- Implement autonomous bot posting with scheduling, rate limiting, and content generation
- Add content fetching system supporting RSS feeds and multiple content sources
- Implement LLM-based content generation with customizable bot personalities
- Add mention handling and automated response system for bot interactions
- Implement API key management with encryption using AUTH_SECRET for simplified deployment
- Add comprehensive bot logging system for activity tracking and error monitoring
- Create bot administration pages and settings UI for managing bot configurations
- Add database migrations for bot system schema including users, sources, and content items
- Implement cron job system for automated bot operations and scheduled tasks
- Add extensive test coverage with unit and property-based tests for core bot modules
- Simplify encryption by deriving keys from AUTH_SECRET instead of separate environment variable
- Implement automatic content fetching on post trigger with retry logic
- Add Reddit-specific link preview handling using oEmbed API for reliable metadata extraction
- Create utility scripts for bot inspection and cleanup operations
- Add comprehensive bot system documentation and improvement tracking
This commit is contained in:
AskIt
2026-01-25 16:22:41 +01:00
parent cfb558fff1
commit 8ad3b97b7e
102 changed files with 41692 additions and 164 deletions
+68 -1
View File
@@ -15,7 +15,7 @@ const SECURITY_CONTEXT = 'https://w3id.org/security/v1';
export interface ActivityPubActor {
'@context': (string | object)[];
id: string;
type: 'Person';
type: 'Person' | 'Service';
preferredUsername: string;
name: string | null;
summary: string | null;
@@ -43,6 +43,7 @@ export interface ActivityPubActor {
sharedInbox: string;
};
movedTo?: string; // If account has migrated to a new location
attributedTo?: string; // Bot creator reference
}
/**
@@ -123,3 +124,69 @@ export function getActorUrl(handle: string, nodeDomain: string): string {
export function getInboxUrl(handle: string, nodeDomain: string): string {
return `https://${nodeDomain}/api/users/${handle}/inbox`;
}
/**
* Convert a bot to an ActivityPub Actor (Service type)
*
* Requirements: 9.3, 12.3, 12.4
*/
export function botToActor(
bot: { handle: string; name: string; bio: string | null; avatarUrl: string | null; publicKey: string },
creatorHandle: string,
nodeDomain: string
): ActivityPubActor {
const actorUrl = `https://${nodeDomain}/api/users/${bot.handle}`;
const creatorUrl = `https://${nodeDomain}/api/users/${creatorHandle}`;
const actor: ActivityPubActor = {
'@context': [
ACTIVITY_STREAMS_CONTEXT,
SECURITY_CONTEXT,
{
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'toot': 'http://joinmastodon.org/ns#',
'featured': {
'@id': 'toot:featured',
'@type': '@id',
},
},
],
id: actorUrl,
type: 'Service', // Bots use Service type
preferredUsername: bot.handle,
name: bot.name,
summary: bot.bio,
url: actorUrl,
inbox: `${actorUrl}/inbox`,
outbox: `${actorUrl}/outbox`,
followers: `${actorUrl}/followers`,
following: `${actorUrl}/following`,
publicKey: {
id: `${actorUrl}#main-key`,
owner: actorUrl,
publicKeyPem: bot.publicKey,
},
endpoints: {
sharedInbox: `https://${nodeDomain}/inbox`,
},
attributedTo: creatorUrl, // Reference to bot creator
};
// Add avatar if present
if (bot.avatarUrl) {
actor.icon = {
type: 'Image',
mediaType: 'image/png',
url: bot.avatarUrl,
};
}
return actor;
}
/**
* Check if an actor is a bot based on type
*/
export function isBot(actor: ActivityPubActor): boolean {
return actor.type === 'Service';
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Activity Logger Service
*
* Records all bot actions for auditing and debugging.
* Supports filtering by action type and date range.
*
* Requirements: 8.1, 8.2, 8.3, 8.4, 8.6
*/
import { db, botActivityLogs } from '@/db';
import { eq, and, gte, lte, desc, inArray } from 'drizzle-orm';
// ============================================
// TYPES
// ============================================
export type ActionType =
| 'post_created'
| 'mention_response'
| 'content_fetched'
| 'llm_call'
| 'error'
| 'config_changed'
| 'rate_limited';
export interface ActivityLogEntry {
botId: string;
action: ActionType;
details: Record<string, unknown>;
success: boolean;
errorMessage?: string;
}
export interface ActivityLog {
id: string;
botId: string;
action: string;
details: Record<string, unknown>;
success: boolean;
errorMessage: string | null;
createdAt: Date;
}
export interface LogQueryOptions {
actionTypes?: ActionType[];
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
}
// ============================================
// LOGGING FUNCTIONS
// ============================================
/**
* Log a bot action.
*
* @param entry - Activity log entry
* @returns Created log record
*
* Validates: Requirements 8.1, 8.3, 8.4
*/
export async function log(entry: ActivityLogEntry): Promise<ActivityLog> {
const [logRecord] = await db.insert(botActivityLogs).values({
botId: entry.botId,
action: entry.action,
details: JSON.stringify(entry.details),
success: entry.success,
errorMessage: entry.errorMessage || null,
}).returning();
return {
id: logRecord.id,
botId: logRecord.botId,
action: logRecord.action,
details: JSON.parse(logRecord.details),
success: logRecord.success,
errorMessage: logRecord.errorMessage,
createdAt: logRecord.createdAt,
};
}
/**
* Get logs for a bot with optional filtering.
* Returns logs in reverse chronological order.
*
* @param botId - The ID of the bot
* @param options - Query options for filtering
* @returns Array of activity logs
*
* Validates: Requirements 8.2, 8.6
*/
export async function getLogsForBot(
botId: string,
options: LogQueryOptions = {}
): Promise<ActivityLog[]> {
const conditions = [eq(botActivityLogs.botId, botId)];
// Filter by action types
if (options.actionTypes && options.actionTypes.length > 0) {
conditions.push(inArray(botActivityLogs.action, options.actionTypes));
}
// Filter by date range
if (options.startDate) {
conditions.push(gte(botActivityLogs.createdAt, options.startDate));
}
if (options.endDate) {
conditions.push(lte(botActivityLogs.createdAt, options.endDate));
}
// Build query
let query = db.query.botActivityLogs.findMany({
where: and(...conditions),
orderBy: [desc(botActivityLogs.createdAt)], // Reverse chronological
limit: options.limit || 100,
offset: options.offset || 0,
});
const logs = await query;
return logs.map(log => ({
id: log.id,
botId: log.botId,
action: log.action,
details: JSON.parse(log.details),
success: log.success,
errorMessage: log.errorMessage,
createdAt: log.createdAt,
}));
}
/**
* Get error logs for a bot.
*
* @param botId - The ID of the bot
* @param limit - Maximum number of logs to return
* @returns Array of error logs
*
* Validates: Requirements 8.6
*/
export async function getErrorLogs(
botId: string,
limit: number = 50
): Promise<ActivityLog[]> {
const logs = await db.query.botActivityLogs.findMany({
where: and(
eq(botActivityLogs.botId, botId),
eq(botActivityLogs.success, false)
),
orderBy: [desc(botActivityLogs.createdAt)],
limit,
});
return logs.map(log => ({
id: log.id,
botId: log.botId,
action: log.action,
details: JSON.parse(log.details),
success: log.success,
errorMessage: log.errorMessage,
createdAt: log.createdAt,
}));
}
// ============================================
// CONVENIENCE FUNCTIONS
// ============================================
/**
* Log a successful post creation.
*/
export async function logPostCreated(
botId: string,
postId: string,
contentSourceId?: string
): Promise<void> {
await log({
botId,
action: 'post_created',
details: { postId, contentSourceId },
success: true,
});
}
/**
* Log a successful mention response.
*/
export async function logMentionResponse(
botId: string,
mentionId: string,
responsePostId: string
): Promise<void> {
await log({
botId,
action: 'mention_response',
details: { mentionId, responsePostId },
success: true,
});
}
/**
* Log content fetching.
*/
export async function logContentFetched(
botId: string,
sourceId: string,
itemCount: number,
success: boolean,
error?: string
): Promise<void> {
await log({
botId,
action: 'content_fetched',
details: { sourceId, itemCount },
success,
errorMessage: error,
});
}
/**
* Log an LLM API call.
*/
export async function logLLMCall(
botId: string,
model: string,
tokensUsed: number,
success: boolean,
error?: string
): Promise<void> {
await log({
botId,
action: 'llm_call',
details: { model, tokensUsed },
success,
errorMessage: error,
});
}
/**
* Log a configuration change.
*/
export async function logConfigChanged(
botId: string,
changes: Record<string, unknown>
): Promise<void> {
await log({
botId,
action: 'config_changed',
details: changes,
success: true,
});
}
/**
* Log a rate limit violation.
*/
export async function logRateLimited(
botId: string,
limitType: string,
reason: string
): Promise<void> {
await log({
botId,
action: 'rate_limited',
details: { limitType },
success: false,
errorMessage: reason,
});
}
/**
* Log a generic error.
*/
export async function logError(
botId: string,
action: ActionType,
error: string,
details: Record<string, unknown> = {}
): Promise<void> {
await log({
botId,
action,
details,
success: false,
errorMessage: error,
});
}
+682
View File
@@ -0,0 +1,682 @@
/**
* Property-Based Tests for Autonomous Posting Module
*
* Feature: bot-system
* - Property 20: Autonomous Mode Content Evaluation
* - Property 21: Autonomous Mode Toggle
*
* Tests that autonomous mode evaluates content interest before posting
* and that bots with autonomous mode disabled only post on schedule.
*
* **Validates: Requirements 6.1, 6.2, 6.3, 6.5**
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fc from 'fast-check';
import { ContentItem } from './contentGenerator';
import { PersonalityConfig } from './personality';
import { LLMProvider } from './encryption';
import {
calculateInterestScore,
MIN_INTEREST_SCORE,
attemptAutonomousPost,
evaluateContentForPosting,
canPostAutonomously,
} from './autonomous';
// Mock the botManager module
vi.mock('./botManager', () => ({
getBotById: vi.fn(),
}));
// Mock the rateLimiter module
vi.mock('./rateLimiter', () => ({
canPost: vi.fn(),
recordPost: vi.fn(),
}));
// ============================================
// TEST SETUP
// ============================================
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ============================================
// GENERATORS
// ============================================
/**
* Generator for valid system prompts.
*/
const systemPromptArb = fc.string({
minLength: 10,
maxLength: 500,
}).filter(s => s.trim().length >= 10);
/**
* Generator for valid temperature values (0-2).
*/
const temperatureArb = fc.double({
min: 0,
max: 2,
noNaN: true,
noDefaultInfinity: true,
});
/**
* Generator for valid maxTokens values.
*/
const maxTokensArb = fc.integer({
min: 1,
max: 4000,
});
/**
* Generator for valid personality configurations.
*/
const personalityConfigArb: fc.Arbitrary<PersonalityConfig> = fc.record({
systemPrompt: systemPromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
responseStyle: fc.option(
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
{ nil: undefined }
),
});
/**
* Generator for LLM providers.
*/
const llmProviderArb: fc.Arbitrary<LLMProvider> = fc.constantFrom(
'openrouter' as LLMProvider,
'openai' as LLMProvider,
'anthropic' as LLMProvider
);
/**
* Generator for LLM model names.
*/
const llmModelArb = fc.oneof(
fc.constant('gpt-3.5-turbo'),
fc.constant('gpt-4'),
fc.constant('claude-3-haiku-20240307'),
fc.constant('claude-3-sonnet-20240229'),
fc.constant('openai/gpt-3.5-turbo')
);
/**
* Generator for content items.
*/
const contentItemArb: fc.Arbitrary<ContentItem> = fc.record({
id: fc.uuid(),
sourceId: fc.uuid(),
title: fc.string({ minLength: 5, maxLength: 200 }),
content: fc.option(fc.string({ minLength: 10, maxLength: 5000 }), { nil: null }),
url: fc.webUrl(),
publishedAt: fc.date(),
});
/**
* Generator for autonomous mode enabled flag.
*/
const autonomousModeArb = fc.boolean();
/**
* Generator for bot configurations.
*/
const botArb = fc.record({
id: fc.uuid(),
userId: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 50 }),
handle: fc.string({ minLength: 3, maxLength: 30 }),
personalityConfig: personalityConfigArb,
llmProvider: llmProviderArb,
llmModel: llmModelArb,
autonomousMode: autonomousModeArb,
isActive: fc.constant(true),
isSuspended: fc.constant(false),
});
/**
* Generator for evaluation reasons.
*/
const evaluationReasonArb = fc.string({ minLength: 10, maxLength: 200 });
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 20: Autonomous Mode Content Evaluation', () => {
/**
* Property 20: Autonomous Mode Content Evaluation
*
* *For any* bot in autonomous mode with new content, the system SHALL evaluate
* content interest before deciding to post.
*
* **Validates: Requirements 6.1, 6.2, 6.3**
*/
it('calculateInterestScore returns 0 for non-interesting content (Requirement 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
evaluationReasonArb,
async (reason) => {
const score = calculateInterestScore(false, reason);
// Non-interesting content should always have score 0
expect(score).toBe(0);
}
),
{ numRuns: 100 }
);
});
it('calculateInterestScore returns positive score for interesting content (Requirement 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
evaluationReasonArb,
async (reason) => {
const score = calculateInterestScore(true, reason);
// Interesting content should have positive score
expect(score).toBeGreaterThan(0);
// Score should be in valid range (0-100)
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(100);
}
),
{ numRuns: 100 }
);
});
it('calculateInterestScore increases with positive keywords (Requirement 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom(
'very interesting',
'highly relevant',
'extremely important',
'excellent content',
'perfect timing',
'significant news',
'valuable information'
),
async (positiveReason) => {
const scoreWithKeywords = calculateInterestScore(true, positiveReason);
const scoreWithoutKeywords = calculateInterestScore(true, 'interesting');
// Score with positive keywords should be higher
expect(scoreWithKeywords).toBeGreaterThanOrEqual(scoreWithoutKeywords);
}
),
{ numRuns: 100 }
);
});
it('interest score determines posting decision (Requirement 6.2, 6.3)', async () => {
await fc.assert(
fc.asyncProperty(
fc.boolean(),
evaluationReasonArb,
async (interesting, reason) => {
const score = calculateInterestScore(interesting, reason);
// Verify score correlates with interesting flag
if (interesting) {
expect(score).toBeGreaterThan(0);
// If score is above threshold, content should be posted
const shouldPost = score >= MIN_INTEREST_SCORE;
// Verify threshold logic
if (score >= MIN_INTEREST_SCORE) {
expect(shouldPost).toBe(true);
} else {
expect(shouldPost).toBe(false);
}
} else {
expect(score).toBe(0);
}
}
),
{ numRuns: 100 }
);
});
it('MIN_INTEREST_SCORE threshold is reasonable (Requirement 6.3)', async () => {
// Verify the threshold is in a reasonable range
expect(MIN_INTEREST_SCORE).toBeGreaterThan(0);
expect(MIN_INTEREST_SCORE).toBeLessThan(100);
// Verify it's set to the documented value (60)
expect(MIN_INTEREST_SCORE).toBe(60);
});
it('interest score is deterministic for same inputs (Requirement 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
fc.boolean(),
evaluationReasonArb,
async (interesting, reason) => {
const score1 = calculateInterestScore(interesting, reason);
const score2 = calculateInterestScore(interesting, reason);
// Same inputs should produce same score
expect(score1).toBe(score2);
}
),
{ numRuns: 100 }
);
});
it('interest score never exceeds 100 (Requirement 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
// Generate reasons with many positive keywords
fc.array(
fc.constantFrom(
'very', 'highly', 'extremely', 'excellent', 'perfect',
'important', 'significant', 'valuable', 'relevant', 'timely'
),
{ minLength: 5, maxLength: 10 }
).map(keywords => keywords.join(' ')),
async (reason) => {
const score = calculateInterestScore(true, reason);
// Score should never exceed 100
expect(score).toBeLessThanOrEqual(100);
}
),
{ numRuns: 100 }
);
});
it('interest score is case-insensitive for keywords (Requirement 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom(
'VERY INTERESTING',
'Very Interesting',
'very interesting',
'VeRy InTeReStInG'
),
async (reason) => {
const score = calculateInterestScore(true, reason);
// All variations should produce the same score
const lowerScore = calculateInterestScore(true, reason.toLowerCase());
expect(score).toBe(lowerScore);
}
),
{ numRuns: 100 }
);
});
it('base score for interesting content without keywords is above 0 (Requirement 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
// Generate reasons without positive keywords
fc.string({ minLength: 10, maxLength: 100 })
.filter(s => !s.toLowerCase().match(/very|highly|extremely|excellent|perfect|important|significant|valuable|relevant|timely/)),
async (reason) => {
const score = calculateInterestScore(true, reason);
// Even without keywords, interesting content should have base score
expect(score).toBeGreaterThan(0);
// Base score should be reasonable (at least 50)
expect(score).toBeGreaterThanOrEqual(50);
}
),
{ numRuns: 100 }
);
});
it('evaluation logic separates interesting from uninteresting content (Requirement 6.1, 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
evaluationReasonArb,
async (reason) => {
const interestingScore = calculateInterestScore(true, reason);
const uninterestingScore = calculateInterestScore(false, reason);
// Interesting content should always have higher score than uninteresting
expect(interestingScore).toBeGreaterThan(uninterestingScore);
// Uninteresting should be 0
expect(uninterestingScore).toBe(0);
}
),
{ numRuns: 100 }
);
});
it('multiple positive keywords accumulate score (Requirement 6.2)', async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 0, max: 5 }),
async (keywordCount) => {
const keywords = ['very', 'highly', 'extremely', 'excellent', 'perfect'];
const reason = keywords.slice(0, keywordCount).join(' ') + ' interesting';
const score = calculateInterestScore(true, reason);
// More keywords should generally mean higher score (up to cap)
// Base score is 70, each keyword adds 5
const expectedMinScore = 70 + (keywordCount * 5);
const cappedExpectedScore = Math.min(100, expectedMinScore);
expect(score).toBe(cappedExpectedScore);
}
),
{ numRuns: 100 }
);
});
});
describe('Feature: bot-system, Property 21: Autonomous Mode Toggle', () => {
/**
* Property 21: Autonomous Mode Toggle
*
* *For any* bot with autonomous mode disabled, the bot SHALL only create posts
* on schedule, not autonomously.
*
* **Validates: Requirements 6.5**
*/
it('attemptAutonomousPost returns not posted when autonomous mode is disabled (Requirement 6.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.uuid(),
async (botId) => {
// Import the mocked module
const { getBotById } = await import('./botManager');
// Mock getBotById to return a bot with autonomous mode disabled
const mockBot = {
id: botId,
userId: fc.sample(fc.uuid(), 1)[0],
name: 'Test Bot',
handle: 'testbot',
ownerId: 'test-owner',
headerUrl: null,
user: {
handle: 'testbot',
},
bio: null,
avatarUrl: null,
personalityConfig: {
systemPrompt: 'You are a helpful bot',
temperature: 0.7,
maxTokens: 500,
},
llmProvider: 'openrouter' as LLMProvider,
llmModel: 'gpt-3.5-turbo',
scheduleConfig: null,
autonomousMode: false, // Disabled
isActive: true,
isSuspended: false,
suspensionReason: null,
suspendedAt: null,
publicKey: 'test-key',
lastPostAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(getBotById).mockResolvedValue(mockBot);
// Attempt autonomous post
const result = await attemptAutonomousPost(botId);
// Should not post when autonomous mode is disabled
expect(result.posted).toBe(false);
expect(result.reason).toContain('Autonomous mode is disabled');
}
),
{ numRuns: 100 }
);
});
it('evaluateContentForPosting returns shouldPost false when autonomous mode is disabled (Requirement 6.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.uuid(),
contentItemArb,
async (botId, contentItem) => {
// Import the mocked module
const { getBotById } = await import('./botManager');
// Mock getBotById to return a bot with autonomous mode disabled
const mockBot = {
id: botId,
userId: fc.sample(fc.uuid(), 1)[0],
name: 'Test Bot',
handle: 'testbot',
ownerId: 'test-owner',
headerUrl: null,
user: {
handle: 'testbot',
},
bio: null,
avatarUrl: null,
personalityConfig: {
systemPrompt: 'You are a helpful bot',
temperature: 0.7,
maxTokens: 500,
},
llmProvider: 'openrouter' as LLMProvider,
llmModel: 'gpt-3.5-turbo',
scheduleConfig: null,
autonomousMode: false, // Disabled
isActive: true,
isSuspended: false,
suspensionReason: null,
suspendedAt: null,
publicKey: 'test-key',
lastPostAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(getBotById).mockResolvedValue(mockBot);
// Evaluate content
const evaluation = await evaluateContentForPosting(botId, contentItem);
// Should not post when autonomous mode is disabled
expect(evaluation.shouldPost).toBe(false);
expect(evaluation.reason).toContain('Autonomous mode is disabled');
}
),
{ numRuns: 100 }
);
});
it('canPostAutonomously returns false when autonomous mode is disabled (Requirement 6.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.uuid(),
async (botId) => {
// Import the mocked module
const { getBotById } = await import('./botManager');
// Mock getBotById to return a bot with autonomous mode disabled
const mockBot = {
id: botId,
userId: fc.sample(fc.uuid(), 1)[0],
name: 'Test Bot',
handle: 'testbot',
ownerId: 'test-owner',
headerUrl: null,
user: {
handle: 'testbot',
},
bio: null,
avatarUrl: null,
personalityConfig: {
systemPrompt: 'You are a helpful bot',
temperature: 0.7,
maxTokens: 500,
},
llmProvider: 'openrouter' as LLMProvider,
llmModel: 'gpt-3.5-turbo',
scheduleConfig: null,
autonomousMode: false, // Disabled
isActive: true,
isSuspended: false,
suspensionReason: null,
suspendedAt: null,
publicKey: 'test-key',
lastPostAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(getBotById).mockResolvedValue(mockBot);
// Check if can post autonomously
const result = await canPostAutonomously(botId);
// Should not be able to post autonomously when mode is disabled
expect(result.canPost).toBe(false);
expect(result.reason).toContain('Autonomous mode is disabled');
}
),
{ numRuns: 100 }
);
});
it('autonomous mode disabled prevents evaluation of content interest (Requirement 6.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.uuid(),
contentItemArb,
async (botId, contentItem) => {
// Import the mocked module
const { getBotById } = await import('./botManager');
// Mock getBotById to return a bot with autonomous mode disabled
const mockBot = {
id: botId,
userId: fc.sample(fc.uuid(), 1)[0],
name: 'Test Bot',
handle: 'testbot',
ownerId: 'test-owner',
headerUrl: null,
user: {
handle: 'testbot',
},
bio: null,
avatarUrl: null,
personalityConfig: {
systemPrompt: 'You are a helpful bot',
temperature: 0.7,
maxTokens: 500,
},
llmProvider: 'openrouter' as LLMProvider,
llmModel: 'gpt-3.5-turbo',
scheduleConfig: null,
autonomousMode: false, // Disabled
isActive: true,
isSuspended: false,
suspensionReason: null,
suspendedAt: null,
publicKey: 'test-key',
lastPostAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(getBotById).mockResolvedValue(mockBot);
// Spy on ContentGenerator to ensure it's not called
const { ContentGenerator } = await import('./contentGenerator');
const evaluateSpy = vi.spyOn(ContentGenerator.prototype, 'evaluateContentInterest');
// Evaluate content
const evaluation = await evaluateContentForPosting(botId, contentItem);
// Should return early without calling LLM
expect(evaluation.shouldPost).toBe(false);
expect(evaluateSpy).not.toHaveBeenCalled();
}
),
{ numRuns: 100 }
);
});
it('autonomous mode state is consistent across all autonomous functions (Requirement 6.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.uuid(),
contentItemArb,
async (botId, contentItem) => {
// Import the mocked module
const { getBotById } = await import('./botManager');
// Mock getBotById to return a bot with autonomous mode disabled
const mockBot = {
id: botId,
userId: fc.sample(fc.uuid(), 1)[0],
name: 'Test Bot',
handle: 'testbot',
ownerId: 'test-owner',
headerUrl: null,
user: {
handle: 'testbot',
},
bio: null,
avatarUrl: null,
personalityConfig: {
systemPrompt: 'You are a helpful bot',
temperature: 0.7,
maxTokens: 500,
},
llmProvider: 'openrouter' as LLMProvider,
llmModel: 'gpt-3.5-turbo',
scheduleConfig: null,
autonomousMode: false, // Disabled
isActive: true,
isSuspended: false,
suspensionReason: null,
suspendedAt: null,
publicKey: 'test-key',
lastPostAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(getBotById).mockResolvedValue(mockBot);
// Check all autonomous functions
const canPostResult = await canPostAutonomously(botId);
const attemptResult = await attemptAutonomousPost(botId);
const evaluateResult = await evaluateContentForPosting(botId, contentItem);
// All should consistently report autonomous mode is disabled
expect(canPostResult.canPost).toBe(false);
expect(canPostResult.reason).toContain('Autonomous mode is disabled');
expect(attemptResult.posted).toBe(false);
expect(attemptResult.reason).toContain('Autonomous mode is disabled');
expect(evaluateResult.shouldPost).toBe(false);
expect(evaluateResult.reason).toContain('Autonomous mode is disabled');
}
),
{ numRuns: 100 }
);
});
});
+677
View File
@@ -0,0 +1,677 @@
/**
* Autonomous Posting Module
*
* Implements autonomous posting logic for bots. When autonomous mode is enabled,
* bots evaluate content from their sources and decide whether to post based on
* content interest. All posting respects rate limits.
*
* Requirements: 6.1, 6.2, 6.3, 6.5
*/
import { db, botContentItems, bots } from '@/db';
import { eq, and } from 'drizzle-orm';
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
import { canPost, recordPost } from './rateLimiter';
import { getBotById } from './botManager';
import { decryptApiKey, deserializeEncryptedData } from './encryption';
// ============================================
// TYPES
// ============================================
/**
* Result of an autonomous posting evaluation.
*/
export interface AutonomousPostEvaluation {
/** Whether the bot should post this content */
shouldPost: boolean;
/** Reason for the decision */
reason: string;
/** Content item that was evaluated */
contentItem: ContentItem;
/** Interest score from LLM evaluation */
interestScore?: number;
}
/**
* Result of an autonomous posting attempt.
*/
export interface AutonomousPostResult {
/** Whether a post was created */
posted: boolean;
/** The post ID if created */
postId?: string;
/** Reason if not posted */
reason?: string;
/** Content item that was evaluated */
contentItem?: ContentItem;
/** Generated post text if posted */
postText?: string;
}
/**
* Options for autonomous posting.
*/
export interface AutonomousPostOptions {
/** Maximum number of content items to evaluate */
maxEvaluations?: number;
/** Whether to skip rate limit checks (for testing) */
skipRateLimitCheck?: boolean;
}
// ============================================
// ERROR CLASSES
// ============================================
/**
* Base error class for autonomous posting operations.
*/
export class AutonomousPostError extends Error {
constructor(
message: string,
public code: AutonomousPostErrorCode,
public cause?: Error
) {
super(message);
this.name = 'AutonomousPostError';
}
}
export type AutonomousPostErrorCode =
| 'BOT_NOT_FOUND'
| 'AUTONOMOUS_MODE_DISABLED'
| 'NO_API_KEY'
| 'RATE_LIMITED'
| 'NO_CONTENT'
| 'EVALUATION_FAILED'
| 'POST_CREATION_FAILED';
// ============================================
// CONSTANTS
// ============================================
/**
* Default maximum number of content items to evaluate per autonomous posting cycle.
*/
export const DEFAULT_MAX_EVALUATIONS = 10;
/**
* Minimum interest score (0-100) for content to be considered interesting.
* This is a heuristic based on the LLM's evaluation.
*/
export const MIN_INTEREST_SCORE = 60;
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Get unprocessed content items for a bot.
* Returns content items that haven't been processed yet, ordered by published date.
*
* @param botId - The ID of the bot
* @param limit - Maximum number of items to return
* @returns Array of unprocessed content items
*/
async function getUnprocessedContentItems(
botId: string,
limit: number = DEFAULT_MAX_EVALUATIONS
): Promise<ContentItem[]> {
// Get bot's content sources
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
with: {
contentSources: {
where: (sources, { eq }) => eq(sources.isActive, true),
},
},
});
if (!bot || !bot.contentSources || bot.contentSources.length === 0) {
return [];
}
const sourceIds = bot.contentSources.map(s => s.id);
// Get unprocessed content items from these sources
const items = await db.query.botContentItems.findMany({
where: and(
eq(botContentItems.isProcessed, false)
),
orderBy: (items, { desc }) => [desc(items.publishedAt)],
limit,
});
// Filter to only items from this bot's sources
const filteredItems = items.filter(item => sourceIds.includes(item.sourceId));
return filteredItems.map(item => ({
id: item.id,
sourceId: item.sourceId,
title: item.title,
content: item.content,
url: item.url,
publishedAt: item.publishedAt,
}));
}
/**
* Mark a content item as processed.
*
* @param contentItemId - The ID of the content item
* @param postId - Optional post ID if a post was created
* @param interestScore - Optional interest score from evaluation
* @param interestReason - Optional reason from evaluation
*/
async function markContentItemProcessed(
contentItemId: string,
postId?: string,
interestScore?: number,
interestReason?: string
): Promise<void> {
await db
.update(botContentItems)
.set({
isProcessed: true,
processedAt: new Date(),
postId: postId || null,
interestScore: interestScore || null,
interestReason: interestReason || null,
})
.where(eq(botContentItems.id, contentItemId));
}
/**
* Convert a bot from database to ContentGenerator bot format.
*
* @param bot - Bot from database
* @returns Bot in ContentGenerator format
*/
function toContentGeneratorBot(bot: typeof bots.$inferSelect, handle: string): ContentGeneratorBot {
return {
id: bot.id,
name: bot.name,
handle: handle,
personalityConfig: JSON.parse(bot.personalityConfig),
llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic',
llmModel: bot.llmModel,
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
};
}
/**
* Get decrypted API key for a bot.
*
* @param bot - Bot from database
* @returns Decrypted API key
*/
function getDecryptedApiKeyForBot(bot: typeof bots.$inferSelect): string {
const encryptedData = deserializeEncryptedData(bot.llmApiKeyEncrypted);
return decryptApiKey(encryptedData);
}
/**
* Calculate a numeric interest score from the evaluation result.
* This is a heuristic to convert the boolean + reason into a score.
*
* @param interesting - Whether the content is interesting
* @param reason - The reason for the decision
* @returns Interest score (0-100)
*/
export function calculateInterestScore(interesting: boolean, reason: string): number {
if (!interesting) {
return 0;
}
// Base score for interesting content
let score = 70;
// Boost score based on positive keywords in reason
const positiveKeywords = [
'very', 'highly', 'extremely', 'excellent', 'perfect',
'important', 'significant', 'valuable', 'relevant', 'timely'
];
const lowerReason = reason.toLowerCase();
for (const keyword of positiveKeywords) {
if (lowerReason.includes(keyword)) {
score += 5;
}
}
// Cap at 100
return Math.min(100, score);
}
// ============================================
// AUTONOMOUS POSTING FUNCTIONS
// ============================================
/**
* Evaluate whether a bot should post content autonomously.
* Uses the bot's LLM to evaluate content interest.
*
* @param botId - The ID of the bot
* @param contentItem - The content item to evaluate
* @returns Evaluation result
*
* Validates: Requirements 6.1, 6.2
*/
export async function evaluateContentForPosting(
botId: string,
contentItem: ContentItem
): Promise<AutonomousPostEvaluation> {
// Get bot
const bot = await getBotById(botId);
if (!bot) {
throw new AutonomousPostError(
`Bot not found: ${botId}`,
'BOT_NOT_FOUND'
);
}
// Check if autonomous mode is enabled
if (!bot.autonomousMode) {
return {
shouldPost: false,
reason: 'Autonomous mode is disabled for this bot',
contentItem,
};
}
// Get bot with encrypted API key
const dbBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
with: { user: true },
});
if (!dbBot) {
throw new AutonomousPostError(
`Bot not found: ${botId}`,
'BOT_NOT_FOUND'
);
}
// Check if bot has API key
try {
const apiKey = getDecryptedApiKeyForBot(dbBot);
if (!apiKey || apiKey === '__REMOVED__') {
throw new AutonomousPostError(
'Bot does not have a valid API key configured',
'NO_API_KEY'
);
}
} catch (error) {
throw new AutonomousPostError(
'Failed to decrypt bot API key',
'NO_API_KEY',
error instanceof Error ? error : undefined
);
}
// Create content generator
const contentGeneratorBot = toContentGeneratorBot(dbBot, dbBot.user.handle);
const generator = new ContentGenerator(contentGeneratorBot);
try {
// Evaluate content interest
const evaluation = await generator.evaluateContentInterest(contentItem);
// Calculate numeric interest score
const interestScore = calculateInterestScore(
evaluation.interesting,
evaluation.reason
);
return {
shouldPost: evaluation.interesting && interestScore >= MIN_INTEREST_SCORE,
reason: evaluation.reason,
contentItem,
interestScore,
};
} catch (error) {
throw new AutonomousPostError(
`Failed to evaluate content interest: ${error instanceof Error ? error.message : String(error)}`,
'EVALUATION_FAILED',
error instanceof Error ? error : undefined
);
}
}
/**
* Attempt to create an autonomous post for a bot.
* Evaluates unprocessed content and posts if interesting and rate limits allow.
*
* This is the main entry point for autonomous posting.
*
* @param botId - The ID of the bot
* @param options - Optional configuration
* @returns Result of the posting attempt
*
* Validates: Requirements 6.1, 6.2, 6.3, 6.5
*/
export async function attemptAutonomousPost(
botId: string,
options: AutonomousPostOptions = {}
): Promise<AutonomousPostResult> {
const {
maxEvaluations = DEFAULT_MAX_EVALUATIONS,
skipRateLimitCheck = false,
} = options;
// Get bot
const bot = await getBotById(botId);
if (!bot) {
throw new AutonomousPostError(
`Bot not found: ${botId}`,
'BOT_NOT_FOUND'
);
}
// Check if autonomous mode is enabled (Requirement 6.5)
if (!bot.autonomousMode) {
return {
posted: false,
reason: 'Autonomous mode is disabled for this bot',
};
}
// Check rate limits (Requirement 6.3)
if (!skipRateLimitCheck) {
const rateLimitCheck = await canPost(botId);
if (!rateLimitCheck.allowed) {
return {
posted: false,
reason: rateLimitCheck.reason || 'Rate limit exceeded',
};
}
}
// Get unprocessed content items (Requirement 6.1)
const contentItems = await getUnprocessedContentItems(botId, maxEvaluations);
if (contentItems.length === 0) {
return {
posted: false,
reason: 'No unprocessed content available',
};
}
// Evaluate content items until we find one worth posting (Requirement 6.2)
for (const contentItem of contentItems) {
try {
const evaluation = await evaluateContentForPosting(botId, contentItem);
// Mark as processed regardless of decision
await markContentItemProcessed(
contentItem.id,
undefined,
evaluation.interestScore,
evaluation.reason
);
if (evaluation.shouldPost) {
// Generate and create the post (Requirement 6.3)
try {
const dbBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
with: { user: true },
});
if (!dbBot) {
throw new Error('Bot not found');
}
const contentGeneratorBot = toContentGeneratorBot(dbBot, dbBot.user.handle);
const generator = new ContentGenerator(contentGeneratorBot);
const generatedContent = await generator.generatePost(contentItem);
// Record the post for rate limiting
if (!skipRateLimitCheck) {
await recordPost(botId);
}
// Update the content item with the post ID
// Note: Actual post creation would happen in the posting module
// For now, we just return the generated content
await markContentItemProcessed(
contentItem.id,
'pending', // Placeholder - actual post ID would be set by posting module
evaluation.interestScore,
evaluation.reason
);
return {
posted: true,
postText: generatedContent.text,
contentItem,
};
} catch (error) {
throw new AutonomousPostError(
`Failed to create post: ${error instanceof Error ? error.message : String(error)}`,
'POST_CREATION_FAILED',
error instanceof Error ? error : undefined
);
}
}
} catch (error) {
// Log error but continue to next content item
console.error(`Error evaluating content item ${contentItem.id}:`, error);
// Mark as processed with error
await markContentItemProcessed(
contentItem.id,
undefined,
0,
`Evaluation failed: ${error instanceof Error ? error.message : String(error)}`
);
continue;
}
}
// No interesting content found
return {
posted: false,
reason: 'No interesting content found after evaluating available items',
};
}
/**
* Process autonomous posting for all active bots with autonomous mode enabled.
* This would typically be called by a scheduled job.
*
* @returns Array of results for each bot
*
* Validates: Requirements 6.1, 6.5
*/
export async function processAllAutonomousBots(): Promise<Array<{
botId: string;
botHandle: string;
result: AutonomousPostResult;
error?: string;
}>> {
// Get all active bots with autonomous mode enabled
const autonomousBots = await db.query.bots.findMany({
where: and(
eq(bots.isActive, true),
eq(bots.isSuspended, false),
eq(bots.autonomousMode, true)
),
with: { user: true },
});
const results = [];
for (const bot of autonomousBots) {
try {
const result = await attemptAutonomousPost(bot.id);
results.push({
botId: bot.id,
botHandle: bot.user.handle,
result,
});
} catch (error) {
results.push({
botId: bot.id,
botHandle: bot.user.handle,
result: {
posted: false,
reason: 'Error during autonomous posting',
},
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
/**
* Check if a bot can post autonomously.
* Validates all requirements for autonomous posting.
*
* @param botId - The ID of the bot
* @returns Object with canPost flag and reason if not allowed
*
* Validates: Requirements 6.1, 6.3, 6.5
*/
export async function canPostAutonomously(botId: string): Promise<{
canPost: boolean;
reason?: string;
}> {
// Get bot
const bot = await getBotById(botId);
if (!bot) {
return {
canPost: false,
reason: 'Bot not found',
};
}
// Check if autonomous mode is enabled (Requirement 6.5)
if (!bot.autonomousMode) {
return {
canPost: false,
reason: 'Autonomous mode is disabled',
};
}
// Check if bot is active and not suspended
if (!bot.isActive) {
return {
canPost: false,
reason: 'Bot is not active',
};
}
if (bot.isSuspended) {
return {
canPost: false,
reason: 'Bot is suspended',
};
}
// Check rate limits (Requirement 6.3)
const rateLimitCheck = await canPost(botId);
if (!rateLimitCheck.allowed) {
return {
canPost: false,
reason: rateLimitCheck.reason,
};
}
// Check if there's content to evaluate (Requirement 6.1)
const contentItems = await getUnprocessedContentItems(botId, 1);
if (contentItems.length === 0) {
return {
canPost: false,
reason: 'No unprocessed content available',
};
}
return {
canPost: true,
};
}
/**
* Toggle autonomous mode for a bot.
*
* @param botId - The ID of the bot
* @param enabled - Whether to enable or disable autonomous mode
*
* Validates: Requirements 6.5
*/
export async function toggleAutonomousMode(
botId: string,
enabled: boolean
): Promise<void> {
await db
.update(bots)
.set({
autonomousMode: enabled,
updatedAt: new Date(),
})
.where(eq(bots.id, botId));
}
/**
* Get autonomous posting statistics for a bot.
*
* @param botId - The ID of the bot
* @returns Statistics about autonomous posting
*/
export async function getAutonomousPostingStats(botId: string): Promise<{
totalContentItems: number;
processedItems: number;
unprocessedItems: number;
postsCreated: number;
averageInterestScore: number;
}> {
// Get bot's content sources
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
with: {
contentSources: true,
},
});
if (!bot || !bot.contentSources) {
return {
totalContentItems: 0,
processedItems: 0,
unprocessedItems: 0,
postsCreated: 0,
averageInterestScore: 0,
};
}
const sourceIds = bot.contentSources.map(s => s.id);
// Get all content items for this bot's sources
const allItems = await db.query.botContentItems.findMany({
where: (items, { inArray }) => inArray(items.sourceId, sourceIds),
});
const processedItems = allItems.filter(item => item.isProcessed);
const unprocessedItems = allItems.filter(item => !item.isProcessed);
const postsCreated = allItems.filter(item => item.postId !== null);
const interestScores = processedItems
.map(item => item.interestScore)
.filter((score): score is number => score !== null);
const averageInterestScore = interestScores.length > 0
? interestScores.reduce((sum, score) => sum + score, 0) / interestScores.length
: 0;
return {
totalContentItems: allItems.length,
processedItems: processedItems.length,
unprocessedItems: unprocessedItems.length,
postsCreated: postsCreated.length,
averageInterestScore: Math.round(averageInterestScore),
};
}
File diff suppressed because it is too large Load Diff
+302
View File
@@ -0,0 +1,302 @@
/**
* Bot Manager Unit Tests
*
* Tests for the Bot Manager service covering bot CRUD operations,
* validation, and limit enforcement.
*
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
validateBotHandle,
validateBotName,
validatePersonalityConfig,
validateScheduleConfig,
getMaxBotsPerUser,
BotValidationError,
type PersonalityConfig,
type ScheduleConfig,
type ApiKeyStatus,
} from './botManager';
// ============================================
// VALIDATION TESTS
// ============================================
describe('Bot Manager Validation', () => {
describe('validateBotHandle', () => {
it('should accept valid handles', () => {
expect(() => validateBotHandle('bot')).not.toThrow();
expect(() => validateBotHandle('my_bot')).not.toThrow();
expect(() => validateBotHandle('Bot123')).not.toThrow();
expect(() => validateBotHandle('test_bot_123')).not.toThrow();
expect(() => validateBotHandle('a'.repeat(30))).not.toThrow();
});
it('should reject empty handles', () => {
expect(() => validateBotHandle('')).toThrow(BotValidationError);
expect(() => validateBotHandle(null as unknown as string)).toThrow(BotValidationError);
expect(() => validateBotHandle(undefined as unknown as string)).toThrow(BotValidationError);
});
it('should reject handles that are too short', () => {
expect(() => validateBotHandle('ab')).toThrow(BotValidationError);
expect(() => validateBotHandle('a')).toThrow(BotValidationError);
});
it('should reject handles that are too long', () => {
expect(() => validateBotHandle('a'.repeat(31))).toThrow(BotValidationError);
});
it('should reject handles with invalid characters', () => {
expect(() => validateBotHandle('bot-name')).toThrow(BotValidationError);
expect(() => validateBotHandle('bot.name')).toThrow(BotValidationError);
expect(() => validateBotHandle('bot name')).toThrow(BotValidationError);
expect(() => validateBotHandle('bot@name')).toThrow(BotValidationError);
});
});
describe('validateBotName', () => {
it('should accept valid names', () => {
expect(() => validateBotName('My Bot')).not.toThrow();
expect(() => validateBotName('A')).not.toThrow();
expect(() => validateBotName('Bot with special chars: @#$%')).not.toThrow();
expect(() => validateBotName('a'.repeat(100))).not.toThrow();
});
it('should reject empty names', () => {
expect(() => validateBotName('')).toThrow(BotValidationError);
expect(() => validateBotName(null as unknown as string)).toThrow(BotValidationError);
expect(() => validateBotName(undefined as unknown as string)).toThrow(BotValidationError);
});
it('should reject names that are too long', () => {
expect(() => validateBotName('a'.repeat(101))).toThrow(BotValidationError);
});
});
describe('validatePersonalityConfig', () => {
const validConfig: PersonalityConfig = {
systemPrompt: 'You are a helpful assistant.',
temperature: 0.7,
maxTokens: 1000,
};
it('should accept valid personality config', () => {
expect(() => validatePersonalityConfig(validConfig)).not.toThrow();
});
it('should accept config with optional responseStyle', () => {
expect(() => validatePersonalityConfig({
...validConfig,
responseStyle: 'casual',
})).not.toThrow();
});
it('should reject missing config', () => {
expect(() => validatePersonalityConfig(null as unknown as PersonalityConfig)).toThrow(BotValidationError);
expect(() => validatePersonalityConfig(undefined as unknown as PersonalityConfig)).toThrow(BotValidationError);
});
it('should reject missing system prompt', () => {
expect(() => validatePersonalityConfig({
...validConfig,
systemPrompt: '',
})).toThrow(BotValidationError);
});
it('should reject system prompt that is too long', () => {
expect(() => validatePersonalityConfig({
...validConfig,
systemPrompt: 'a'.repeat(10001),
})).toThrow(BotValidationError);
});
it('should reject invalid temperature', () => {
expect(() => validatePersonalityConfig({
...validConfig,
temperature: -0.1,
})).toThrow(BotValidationError);
expect(() => validatePersonalityConfig({
...validConfig,
temperature: 2.1,
})).toThrow(BotValidationError);
expect(() => validatePersonalityConfig({
...validConfig,
temperature: 'high' as unknown as number,
})).toThrow(BotValidationError);
});
it('should reject invalid maxTokens', () => {
expect(() => validatePersonalityConfig({
...validConfig,
maxTokens: 0,
})).toThrow(BotValidationError);
expect(() => validatePersonalityConfig({
...validConfig,
maxTokens: 100001,
})).toThrow(BotValidationError);
expect(() => validatePersonalityConfig({
...validConfig,
maxTokens: 'many' as unknown as number,
})).toThrow(BotValidationError);
});
});
describe('validateScheduleConfig', () => {
it('should accept valid interval schedule', () => {
expect(() => validateScheduleConfig({
type: 'interval',
intervalMinutes: 30,
})).not.toThrow();
});
it('should reject interval less than 5 minutes', () => {
expect(() => validateScheduleConfig({
type: 'interval',
intervalMinutes: 4,
})).toThrow(BotValidationError);
});
it('should accept valid times schedule', () => {
expect(() => validateScheduleConfig({
type: 'times',
times: ['09:00', '12:00', '18:00'],
})).not.toThrow();
});
it('should reject times schedule with invalid time format', () => {
expect(() => validateScheduleConfig({
type: 'times',
times: ['9:00'], // Missing leading zero
})).toThrow(BotValidationError);
expect(() => validateScheduleConfig({
type: 'times',
times: ['25:00'], // Invalid hour
})).toThrow(BotValidationError);
expect(() => validateScheduleConfig({
type: 'times',
times: ['12:60'], // Invalid minute
})).toThrow(BotValidationError);
});
it('should reject times schedule with empty array', () => {
expect(() => validateScheduleConfig({
type: 'times',
times: [],
})).toThrow(BotValidationError);
});
it('should accept valid cron schedule', () => {
expect(() => validateScheduleConfig({
type: 'cron',
cronExpression: '0 9 * * *',
})).not.toThrow();
});
it('should reject cron schedule without expression', () => {
expect(() => validateScheduleConfig({
type: 'cron',
} as ScheduleConfig)).toThrow(BotValidationError);
});
it('should reject invalid schedule type', () => {
expect(() => validateScheduleConfig({
type: 'invalid' as 'interval',
})).toThrow(BotValidationError);
});
});
});
// ============================================
// CONFIGURATION TESTS
// ============================================
describe('Bot Manager Configuration', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
describe('getMaxBotsPerUser', () => {
it('should return default value when env not set', () => {
delete process.env.BOT_MAX_PER_USER;
expect(getMaxBotsPerUser()).toBe(5);
});
it('should return env value when set', () => {
process.env.BOT_MAX_PER_USER = '10';
expect(getMaxBotsPerUser()).toBe(10);
});
it('should return default for invalid env value', () => {
process.env.BOT_MAX_PER_USER = 'invalid';
expect(getMaxBotsPerUser()).toBe(5);
});
it('should return default for negative env value', () => {
process.env.BOT_MAX_PER_USER = '-5';
expect(getMaxBotsPerUser()).toBe(5);
});
it('should return default for zero env value', () => {
process.env.BOT_MAX_PER_USER = '0';
expect(getMaxBotsPerUser()).toBe(5);
});
});
});
// ============================================
// API KEY STATUS TYPE TESTS
// ============================================
describe('ApiKeyStatus Type', () => {
it('should have correct structure', () => {
const status: ApiKeyStatus = {
hasApiKey: true,
provider: 'openai',
model: 'gpt-4',
};
expect(status.hasApiKey).toBe(true);
expect(status.provider).toBe('openai');
expect(status.model).toBe('gpt-4');
});
it('should support all LLM providers', () => {
const providers: ApiKeyStatus['provider'][] = ['openrouter', 'openai', 'anthropic'];
providers.forEach(provider => {
const status: ApiKeyStatus = {
hasApiKey: true,
provider,
model: 'test-model',
};
expect(status.provider).toBe(provider);
});
});
it('should support hasApiKey being false', () => {
const status: ApiKeyStatus = {
hasApiKey: false,
provider: 'openai',
model: 'gpt-4',
};
expect(status.hasApiKey).toBe(false);
});
});
+899
View File
@@ -0,0 +1,899 @@
/**
* Bot Manager Service
*
* Core orchestrator for bot lifecycle, configuration, and operations.
* Handles bot CRUD operations, user linking, and ActivityPub key generation.
*
* Bots are first-class users with their own profiles, handles, and posts.
* Each bot has an owner (human user) who manages it.
*
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6
*/
import { db, bots, users, botContentSources, botContentItems, botMentions, botActivityLogs, botRateLimits, follows } from '@/db';
import { eq, and, count } from 'drizzle-orm';
import { generateKeyPair } from '@/lib/activitypub/signatures';
import {
encryptApiKey,
decryptApiKey,
serializeEncryptedData,
deserializeEncryptedData,
validateApiKeyFormat,
type LLMProvider
} from './encryption';
// ============================================
// TYPES
// ============================================
export interface PersonalityConfig {
systemPrompt: string;
temperature: number;
maxTokens: number;
responseStyle?: string;
}
export interface ScheduleConfig {
type: 'interval' | 'times' | 'cron';
intervalMinutes?: number;
times?: string[]; // HH:MM format
cronExpression?: string;
timezone?: string;
}
export interface BotCreateInput {
name: string;
handle: string;
bio?: string;
avatarUrl?: string;
headerUrl?: string;
personality: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
llmApiKey: string;
schedule?: ScheduleConfig;
autonomousMode?: boolean;
}
export interface BotUpdateInput {
name?: string;
bio?: string;
avatarUrl?: string;
headerUrl?: string;
personality?: PersonalityConfig;
llmProvider?: LLMProvider;
llmModel?: string;
llmApiKey?: string;
schedule?: ScheduleConfig | null;
autonomousMode?: boolean;
isActive?: boolean;
}
export interface Bot {
id: string;
userId: string; // The bot's own user account
ownerId: string; // The human who manages this bot
name: string;
handle: string;
bio: string | null;
avatarUrl: string | null;
headerUrl: string | null;
personalityConfig: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
scheduleConfig: ScheduleConfig | null;
autonomousMode: boolean;
isActive: boolean;
isSuspended: boolean;
suspensionReason: string | null;
suspendedAt: Date | null;
publicKey: string;
lastPostAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
// ============================================
// CONSTANTS
// ============================================
/**
* Default maximum number of bots per user.
* Can be overridden via BOT_MAX_PER_USER environment variable.
*/
const DEFAULT_MAX_BOTS_PER_USER = 5;
/**
* Get the maximum bots per user from environment or default.
*/
export function getMaxBotsPerUser(): number {
const envValue = process.env.BOT_MAX_PER_USER;
if (envValue) {
const parsed = parseInt(envValue, 10);
if (!isNaN(parsed) && parsed > 0) {
return parsed;
}
}
return DEFAULT_MAX_BOTS_PER_USER;
}
// ============================================
// ERROR CLASSES
// ============================================
export class BotError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'BotError';
}
}
export class BotNotFoundError extends BotError {
constructor(botId: string) {
super(`Bot not found: ${botId}`, 'BOT_NOT_FOUND');
}
}
export class BotLimitExceededError extends BotError {
constructor(userId: string, limit: number) {
super(`User ${userId} has reached the maximum bot limit of ${limit}`, 'BOT_LIMIT_EXCEEDED');
}
}
export class BotHandleTakenError extends BotError {
constructor(handle: string) {
super(`Bot handle is already taken: ${handle}`, 'BOT_HANDLE_TAKEN');
}
}
export class BotValidationError extends BotError {
constructor(message: string) {
super(message, 'BOT_VALIDATION_ERROR');
}
}
// ============================================
// VALIDATION FUNCTIONS
// ============================================
/**
* Validate bot handle format.
* Handles must be 3-30 characters, alphanumeric and underscores only.
*/
export function validateBotHandle(handle: string): void {
if (!handle || typeof handle !== 'string') {
throw new BotValidationError('Bot handle is required');
}
if (!/^[a-zA-Z0-9_]{3,30}$/.test(handle)) {
throw new BotValidationError('Bot handle must be 3-30 characters, alphanumeric and underscores only');
}
}
/**
* Validate bot name.
*/
export function validateBotName(name: string): void {
if (!name || typeof name !== 'string') {
throw new BotValidationError('Bot name is required');
}
if (name.length < 1 || name.length > 100) {
throw new BotValidationError('Bot name must be 1-100 characters');
}
}
/**
* Validate personality configuration.
*/
export function validatePersonalityConfig(config: PersonalityConfig): void {
if (!config || typeof config !== 'object') {
throw new BotValidationError('Personality configuration is required');
}
if (!config.systemPrompt || typeof config.systemPrompt !== 'string') {
throw new BotValidationError('System prompt is required');
}
if (config.systemPrompt.length > 10000) {
throw new BotValidationError('System prompt must be 10000 characters or less');
}
if (typeof config.temperature !== 'number' || config.temperature < 0 || config.temperature > 2) {
throw new BotValidationError('Temperature must be a number between 0 and 2');
}
if (typeof config.maxTokens !== 'number' || config.maxTokens < 1 || config.maxTokens > 100000) {
throw new BotValidationError('Max tokens must be a number between 1 and 100000');
}
}
/**
* Validate schedule configuration.
*/
export function validateScheduleConfig(config: ScheduleConfig): void {
if (!config || typeof config !== 'object') {
throw new BotValidationError('Schedule configuration is required');
}
if (!['interval', 'times', 'cron'].includes(config.type)) {
throw new BotValidationError('Schedule type must be interval, times, or cron');
}
if (config.type === 'interval') {
if (typeof config.intervalMinutes !== 'number' || config.intervalMinutes < 5) {
throw new BotValidationError('Interval must be at least 5 minutes');
}
}
if (config.type === 'times') {
if (!Array.isArray(config.times) || config.times.length === 0) {
throw new BotValidationError('Times array is required for times schedule type');
}
const timePattern = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
for (const time of config.times) {
if (!timePattern.test(time)) {
throw new BotValidationError(`Invalid time format: ${time}. Use HH:MM format`);
}
}
}
if (config.type === 'cron') {
if (!config.cronExpression || typeof config.cronExpression !== 'string') {
throw new BotValidationError('Cron expression is required for cron schedule type');
}
}
}
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Convert database bot row to Bot interface.
* Requires the bot's user account to be loaded.
*/
function dbBotToBot(dbBot: typeof bots.$inferSelect, botUser: typeof users.$inferSelect): Bot {
return {
id: dbBot.id,
userId: dbBot.userId,
ownerId: dbBot.ownerId,
name: dbBot.name,
handle: botUser.handle,
bio: botUser.bio,
avatarUrl: botUser.avatarUrl,
headerUrl: botUser.headerUrl,
personalityConfig: JSON.parse(dbBot.personalityConfig) as PersonalityConfig,
llmProvider: dbBot.llmProvider as LLMProvider,
llmModel: dbBot.llmModel,
scheduleConfig: dbBot.scheduleConfig ? JSON.parse(dbBot.scheduleConfig) as ScheduleConfig : null,
autonomousMode: dbBot.autonomousMode,
isActive: dbBot.isActive,
isSuspended: dbBot.isSuspended,
suspensionReason: dbBot.suspensionReason,
suspendedAt: dbBot.suspendedAt,
publicKey: botUser.publicKey,
lastPostAt: dbBot.lastPostAt,
createdAt: dbBot.createdAt,
updatedAt: dbBot.updatedAt,
};
}
// ============================================
// BOT MANAGER FUNCTIONS
// ============================================
/**
* Create a new bot for a user.
* Creates a user account for the bot with isBot=true.
*
* @param ownerId - The ID of the user creating/owning the bot
* @param config - Bot configuration
* @returns The created bot
* @throws BotLimitExceededError if user has reached max bots
* @throws BotHandleTakenError if handle is already taken
* @throws BotValidationError if configuration is invalid
*
* Validates: Requirements 1.1, 1.2, 1.6
*/
export async function createBot(ownerId: string, config: BotCreateInput): Promise<Bot> {
// Validate inputs
validateBotHandle(config.handle);
validateBotName(config.name);
validatePersonalityConfig(config.personality);
if (config.schedule) {
validateScheduleConfig(config.schedule);
}
// Validate API key format
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
if (!apiKeyValidation.valid) {
throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
}
// Check bot limit for user
const maxBots = getMaxBotsPerUser();
const [botCountResult] = await db
.select({ count: count() })
.from(bots)
.where(eq(bots.ownerId, ownerId));
if (botCountResult.count >= maxBots) {
throw new BotLimitExceededError(ownerId, maxBots);
}
// Check if handle is taken (in users table now)
const existingUser = await db.query.users.findFirst({
where: eq(users.handle, config.handle.toLowerCase()),
});
if (existingUser) {
throw new BotHandleTakenError(config.handle);
}
// Generate ActivityPub keys for the bot's user account
const { publicKey, privateKey } = await generateKeyPair();
// Encrypt the API key and private key
const encryptedApiKey = encryptApiKey(config.llmApiKey);
const encryptedPrivateKey = encryptApiKey(privateKey);
// Generate a DID for the bot
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const botDid = `did:web:${nodeDomain}:users:${config.handle.toLowerCase()}`;
// Create the bot's user account first
const [botUser] = await db.insert(users).values({
did: botDid,
handle: config.handle.toLowerCase(),
displayName: config.name,
bio: config.bio || null,
avatarUrl: config.avatarUrl || null,
headerUrl: config.headerUrl || null,
publicKey,
privateKeyEncrypted: serializeEncryptedData(encryptedPrivateKey),
isBot: true,
botOwnerId: ownerId,
}).returning();
// Create the bot configuration
const [createdBot] = await db.insert(bots).values({
userId: botUser.id, // The bot's own user account
ownerId, // The human who manages this bot
name: config.name,
personalityConfig: JSON.stringify(config.personality),
llmProvider: config.llmProvider,
llmModel: config.llmModel,
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
scheduleConfig: config.schedule ? JSON.stringify(config.schedule) : null,
autonomousMode: config.autonomousMode ?? false,
isActive: true,
isSuspended: false,
}).returning();
// Auto-follow the bot so its posts appear in the owner's home feed
await db.insert(follows).values({
followerId: ownerId,
followingId: botUser.id,
});
// Update follower/following counts
await db.update(users)
.set({ followersCount: 1 })
.where(eq(users.id, botUser.id));
const owner = await db.query.users.findFirst({
where: eq(users.id, ownerId),
});
if (owner) {
await db.update(users)
.set({ followingCount: owner.followingCount + 1 })
.where(eq(users.id, ownerId));
}
return dbBotToBot(createdBot, botUser);
}
/**
* Update an existing bot's configuration.
* Updates both the bot config and the bot's user account.
*
* @param botId - The ID of the bot to update
* @param config - Updated configuration
* @returns The updated bot
* @throws BotNotFoundError if bot doesn't exist
* @throws BotValidationError if configuration is invalid
*
* Validates: Requirements 1.1
*/
export async function updateBot(botId: string, config: BotUpdateInput): Promise<Bot> {
// Validate inputs if provided
if (config.name !== undefined) {
validateBotName(config.name);
}
if (config.personality !== undefined) {
validatePersonalityConfig(config.personality);
}
if (config.schedule !== undefined && config.schedule !== null) {
validateScheduleConfig(config.schedule);
}
// Validate API key if provided
if (config.llmApiKey !== undefined && config.llmProvider !== undefined) {
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
if (!apiKeyValidation.valid) {
throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
}
}
// Check if bot exists and get its user account
const existingBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!existingBot) {
throw new BotNotFoundError(botId);
}
// Get the bot's user account
const botUser = await db.query.users.findFirst({
where: eq(users.id, existingBot.userId),
});
if (!botUser) {
throw new BotNotFoundError(botId);
}
// Build update object for bot config
const botUpdateData: Partial<typeof bots.$inferInsert> = {
updatedAt: new Date(),
};
// Build update object for bot's user account
const userUpdateData: Partial<typeof users.$inferInsert> = {
updatedAt: new Date(),
};
if (config.name !== undefined) {
botUpdateData.name = config.name;
userUpdateData.displayName = config.name;
}
if (config.bio !== undefined) {
userUpdateData.bio = config.bio;
}
if (config.avatarUrl !== undefined) {
userUpdateData.avatarUrl = config.avatarUrl;
}
if (config.headerUrl !== undefined) {
userUpdateData.headerUrl = config.headerUrl;
}
if (config.personality !== undefined) {
botUpdateData.personalityConfig = JSON.stringify(config.personality);
}
if (config.llmProvider !== undefined) {
botUpdateData.llmProvider = config.llmProvider;
}
if (config.llmModel !== undefined) {
botUpdateData.llmModel = config.llmModel;
}
if (config.llmApiKey !== undefined) {
const encryptedApiKey = encryptApiKey(config.llmApiKey);
botUpdateData.llmApiKeyEncrypted = serializeEncryptedData(encryptedApiKey);
}
if (config.schedule !== undefined) {
botUpdateData.scheduleConfig = config.schedule ? JSON.stringify(config.schedule) : null;
}
if (config.autonomousMode !== undefined) {
botUpdateData.autonomousMode = config.autonomousMode;
}
if (config.isActive !== undefined) {
botUpdateData.isActive = config.isActive;
}
// Update the bot's user account if there are changes
if (Object.keys(userUpdateData).length > 1) { // More than just updatedAt
await db
.update(users)
.set(userUpdateData)
.where(eq(users.id, existingBot.userId));
}
// Update the bot config
const [updatedBot] = await db
.update(bots)
.set(botUpdateData)
.where(eq(bots.id, botId))
.returning();
// Get updated user
const updatedUser = await db.query.users.findFirst({
where: eq(users.id, existingBot.userId),
});
return dbBotToBot(updatedBot, updatedUser!);
}
/**
* Delete a bot and all associated data.
* Also deletes the bot's user account.
*
* @param botId - The ID of the bot to delete
* @throws BotNotFoundError if bot doesn't exist
*
* Validates: Requirements 1.4
*/
export async function deleteBot(botId: string): Promise<void> {
// Check if bot exists
const existingBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!existingBot) {
throw new BotNotFoundError(botId);
}
// Delete the bot config first (cascade will handle related data)
await db.delete(bots).where(eq(bots.id, botId));
// Delete the bot's user account (cascade will handle posts, follows, etc.)
await db.delete(users).where(eq(users.id, existingBot.userId));
}
/**
* Get all bots owned by a user.
*
* @param ownerId - The ID of the owner
* @returns Array of bots belonging to the user
*
* Validates: Requirements 1.3
*/
export async function getBotsByUser(ownerId: string): Promise<Bot[]> {
const userBots = await db.query.bots.findMany({
where: eq(bots.ownerId, ownerId),
orderBy: (bots, { desc }) => [desc(bots.createdAt)],
});
// Get all bot user accounts
const botUserIds = userBots.map(b => b.userId);
const botUsers = await db.query.users.findMany({
where: (users, { inArray }) => inArray(users.id, botUserIds),
});
const userMap = new Map(botUsers.map(u => [u.id, u]));
return userBots.map(bot => {
const botUser = userMap.get(bot.userId);
if (!botUser) {
throw new Error(`Bot user not found for bot ${bot.id}`);
}
return dbBotToBot(bot, botUser);
});
}
/**
* Get a bot by ID.
*
* @param botId - The ID of the bot
* @returns The bot or null if not found
*
* Validates: Requirements 1.3
*/
export async function getBotById(botId: string): Promise<Bot | null> {
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!bot) {
return null;
}
// Get the bot's user account
const botUser = await db.query.users.findFirst({
where: eq(users.id, bot.userId),
});
if (!botUser) {
return null;
}
return dbBotToBot(bot, botUser);
}
/**
* Get a bot by handle.
*
* @param handle - The handle of the bot
* @returns The bot or null if not found
*/
export async function getBotByHandle(handle: string): Promise<Bot | null> {
// Find the user with this handle that is a bot
const botUser = await db.query.users.findFirst({
where: and(
eq(users.handle, handle.toLowerCase()),
eq(users.isBot, true)
),
});
if (!botUser) {
return null;
}
// Find the bot config for this user
const bot = await db.query.bots.findFirst({
where: eq(bots.userId, botUser.id),
});
if (!bot) {
return null;
}
return dbBotToBot(bot, botUser);
}
/**
* Get the count of bots for a user.
*
* @param ownerId - The ID of the owner
* @returns The number of bots the user has
*/
export async function getBotCountForUser(ownerId: string): Promise<number> {
const [result] = await db
.select({ count: count() })
.from(bots)
.where(eq(bots.ownerId, ownerId));
return result.count;
}
/**
* Check if a user can create more bots.
*
* @param ownerId - The ID of the owner
* @returns True if user can create more bots
*
* Validates: Requirements 1.6
*/
export async function canUserCreateBot(ownerId: string): Promise<boolean> {
const botCount = await getBotCountForUser(ownerId);
return botCount < getMaxBotsPerUser();
}
/**
* Check if a user owns a specific bot.
*
* @param ownerId - The ID of the owner
* @param botId - The ID of the bot
* @returns True if the user owns the bot
*/
export async function userOwnsBot(ownerId: string, botId: string): Promise<boolean> {
const bot = await db.query.bots.findFirst({
where: and(eq(bots.id, botId), eq(bots.ownerId, ownerId)),
});
return bot !== undefined;
}
/**
* Update the last post timestamp for a bot.
*
* @param botId - The ID of the bot
*/
export async function updateBotLastPostAt(botId: string): Promise<void> {
await db
.update(bots)
.set({ lastPostAt: new Date(), updatedAt: new Date() })
.where(eq(bots.id, botId));
}
// ============================================
// API KEY MANAGEMENT FUNCTIONS
// ============================================
/**
* Marker value used to indicate a removed/empty API key.
* This is encrypted and stored when an API key is removed.
*/
const EMPTY_API_KEY_MARKER = '__REMOVED__';
/**
* Set or update the API key for a bot.
*
* @param botId - The ID of the bot
* @param apiKey - The new API key
* @param provider - Optional provider override (uses bot's current provider if not specified)
* @throws BotNotFoundError if bot doesn't exist
* @throws BotValidationError if API key format is invalid
*
* Validates: Requirements 2.1, 2.2, 2.4
*/
export async function setApiKey(
botId: string,
apiKey: string,
provider?: LLMProvider
): Promise<void> {
// Check if bot exists
const existingBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!existingBot) {
throw new BotNotFoundError(botId);
}
// Use provided provider or bot's current provider
const targetProvider = provider || existingBot.llmProvider as LLMProvider;
// Validate API key format
const validation = validateApiKeyFormat(apiKey, targetProvider);
if (!validation.valid) {
throw new BotValidationError(validation.error || 'Invalid API key format');
}
// Encrypt the API key
const encryptedApiKey = encryptApiKey(apiKey);
// Update the bot with the new API key
const updateData: Partial<typeof bots.$inferInsert> = {
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
updatedAt: new Date(),
};
// If provider was specified and different, update it too
if (provider && provider !== existingBot.llmProvider) {
updateData.llmProvider = provider;
}
await db
.update(bots)
.set(updateData)
.where(eq(bots.id, botId));
}
/**
* Remove the API key from a bot.
* Since the llmApiKeyEncrypted field is NOT NULL, this sets it to an
* encrypted marker value that indicates the key has been removed.
*
* @param botId - The ID of the bot
* @throws BotNotFoundError if bot doesn't exist
*
* Validates: Requirements 2.4, 2.5
*/
export async function removeApiKey(botId: string): Promise<void> {
// Check if bot exists
const existingBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!existingBot) {
throw new BotNotFoundError(botId);
}
// Encrypt the marker value to indicate removed key
const encryptedMarker = encryptApiKey(EMPTY_API_KEY_MARKER);
// Update the bot with the marker
await db
.update(bots)
.set({
llmApiKeyEncrypted: serializeEncryptedData(encryptedMarker),
updatedAt: new Date(),
})
.where(eq(bots.id, botId));
}
/**
* API key status information
*/
export interface ApiKeyStatus {
/** Whether an API key is configured (not removed) */
hasApiKey: boolean;
/** The LLM provider configured for the bot */
provider: LLMProvider;
/** The LLM model configured for the bot */
model: string;
}
/**
* Get the API key status for a bot.
* Returns whether an API key is configured (not the key itself).
*
* @param botId - The ID of the bot
* @returns API key status information
* @throws BotNotFoundError if bot doesn't exist
*
* Validates: Requirements 2.1, 2.2
*/
export async function getApiKeyStatus(botId: string): Promise<ApiKeyStatus> {
// Get the bot with encrypted API key
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!bot) {
throw new BotNotFoundError(botId);
}
// Check if API key is configured (not the removed marker)
let hasApiKey = false;
try {
const encryptedData = deserializeEncryptedData(bot.llmApiKeyEncrypted);
const decryptedKey = decryptApiKey(encryptedData);
hasApiKey = decryptedKey !== EMPTY_API_KEY_MARKER && decryptedKey.length > 0;
} catch {
// If decryption fails, consider key as not configured
hasApiKey = false;
}
return {
hasApiKey,
provider: bot.llmProvider as LLMProvider,
model: bot.llmModel,
};
}
/**
* Check if a bot has a valid API key configured.
*
* @param botId - The ID of the bot
* @returns True if the bot has a valid API key
*/
export async function botHasApiKey(botId: string): Promise<boolean> {
try {
const status = await getApiKeyStatus(botId);
return status.hasApiKey;
} catch {
return false;
}
}
/**
* Get the decrypted API key for a bot (for internal use only).
* This should only be used by the content generator when making LLM calls.
*
* @param botId - The ID of the bot
* @returns The decrypted API key or null if not configured
* @throws BotNotFoundError if bot doesn't exist
*
* Validates: Requirements 2.3
*/
export async function getDecryptedApiKey(botId: string): Promise<string | null> {
// Get the bot with encrypted API key
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!bot) {
throw new BotNotFoundError(botId);
}
try {
const encryptedData = deserializeEncryptedData(bot.llmApiKeyEncrypted);
const decryptedKey = decryptApiKey(encryptedData);
// Return null if it's the removed marker
if (decryptedKey === EMPTY_API_KEY_MARKER) {
return null;
}
return decryptedKey;
} catch {
return null;
}
}
@@ -0,0 +1,913 @@
/**
* Property-Based Tests for Content Item Storage
*
* Feature: bot-system, Property 13: Content Item Storage
*
* Tests that content items are correctly stored with their source reference
* and marked as unprocessed using fast-check for property-based testing.
*
* **Validates: Requirements 4.5**
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fc from 'fast-check';
// ============================================
// MOCK SETUP
// ============================================
// In-memory storage for content items (defined outside mock for access in tests)
let contentItemsStore = new Map<string, any>();
let itemIdCounter = 0;
// Helper functions for test access
export const __resetStore = () => {
contentItemsStore.clear();
itemIdCounter = 0;
};
export const __getStore = () => contentItemsStore;
// Mock the database module
vi.mock('@/db', () => {
return {
db: {
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockImplementation((values: any) => ({
returning: vi.fn().mockImplementation(async () => {
const id = `item-${++itemIdCounter}-${Date.now()}`;
const now = new Date();
const stored = {
id,
sourceId: values.sourceId,
externalId: values.externalId,
title: values.title,
content: values.content,
url: values.url,
publishedAt: values.publishedAt,
fetchedAt: now,
isProcessed: values.isProcessed ?? false,
processedAt: values.processedAt ?? null,
postId: values.postId ?? null,
interestScore: values.interestScore ?? null,
interestReason: values.interestReason ?? null,
};
contentItemsStore.set(id, stored);
return [stored];
}),
})),
})),
query: {
botContentItems: {
findFirst: vi.fn().mockImplementation(async () => undefined),
findMany: vi.fn().mockImplementation(async () => []),
},
botContentSources: {
findFirst: vi.fn().mockImplementation(async () => undefined),
},
},
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
}),
},
botContentSources: {
id: 'id',
botId: 'bot_id',
},
botContentItems: {
id: 'id',
sourceId: 'source_id',
externalId: 'external_id',
isProcessed: 'is_processed',
},
};
});
// Mock drizzle-orm
vi.mock('drizzle-orm', () => ({
eq: vi.fn().mockImplementation((column: any, value: any) => ({ column, value, type: 'eq' })),
and: vi.fn().mockImplementation((...conditions: any[]) => ({ conditions, type: 'and' })),
}));
// Import after mocks are set up
import {
storeContentItem,
storeContentItems,
ContentItemInput,
StoredContentItem,
calculateBackoffDelay,
BASE_BACKOFF_DELAY_MS,
MAX_BACKOFF_DELAY_MS,
MAX_CONSECUTIVE_ERRORS,
} from './contentFetcher';
// ============================================
// TEST SETUP
// ============================================
beforeEach(() => {
vi.clearAllMocks();
__resetStore();
});
// ============================================
// GENERATORS
// ============================================
/**
* Generator for valid source IDs (UUID format).
*/
const sourceIdArb = fc.uuid();
/**
* Generator for external IDs (unique identifiers from the source).
*/
const externalIdArb = fc.stringMatching(/^[a-zA-Z0-9_-]{1,100}$/)
.filter(s => s.length > 0);
/**
* Generator for content item titles.
*/
const titleArb = fc.string({ minLength: 1, maxLength: 500 })
.filter(s => s.trim().length > 0);
/**
* Generator for content item content (can be null or string).
*/
const contentArb = fc.oneof(
fc.constant(null),
fc.string({ minLength: 0, maxLength: 5000 })
);
/**
* Generator for valid URLs.
*/
const urlArb = fc.webUrl();
/**
* Generator for publication dates (within reasonable range).
*/
const publishedAtArb = fc.date({
min: new Date('2020-01-01T00:00:00Z'),
max: new Date('2030-12-31T23:59:59Z'),
});
/**
* Generator for a single content item input.
*/
const contentItemInputArb: fc.Arbitrary<ContentItemInput> = fc.record({
sourceId: sourceIdArb,
externalId: externalIdArb,
title: titleArb,
content: contentArb,
url: urlArb,
publishedAt: publishedAtArb,
});
/**
* Generator for a list of content item inputs (1-20 items).
*/
const contentItemInputsArb = fc.array(contentItemInputArb, { minLength: 1, maxLength: 20 });
/**
* Generator for content items with the same source ID.
*/
const contentItemsWithSameSourceArb = fc.tuple(sourceIdArb, fc.array(
fc.record({
externalId: externalIdArb,
title: titleArb,
content: contentArb,
url: urlArb,
publishedAt: publishedAtArb,
}),
{ minLength: 1, maxLength: 10 }
)).map(([sourceId, items]) =>
items.map((item, index) => ({
...item,
sourceId,
// Ensure unique external IDs within the same source
externalId: `${item.externalId}-${index}`,
}))
);
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 13: Content Item Storage', () => {
/**
* Property 13: Content Item Storage
*
* *For any* fetched content item, the item SHALL be stored with its source
* reference and marked as unprocessed.
*
* **Validates: Requirements 4.5**
*/
describe('Source Reference Preservation', () => {
it('stored item preserves the source ID reference', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// The stored item MUST have the same sourceId as the input
expect(stored.sourceId).toBe(input.sourceId);
}),
{ numRuns: 100 }
);
});
it('stored item preserves the external ID from the source', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// The stored item MUST have the same externalId as the input
expect(stored.externalId).toBe(input.externalId);
}),
{ numRuns: 100 }
);
});
it('multiple items from the same source all reference that source', async () => {
await fc.assert(
fc.asyncProperty(contentItemsWithSameSourceArb, async (inputs) => {
const storedItems: StoredContentItem[] = [];
for (const input of inputs) {
const stored = await storeContentItem(input);
storedItems.push(stored);
}
// All stored items MUST reference the same source
const sourceId = inputs[0].sourceId;
for (const stored of storedItems) {
expect(stored.sourceId).toBe(sourceId);
}
}),
{ numRuns: 100 }
);
});
});
describe('Unprocessed State', () => {
it('newly stored item is marked as unprocessed', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// The stored item MUST be marked as unprocessed
expect(stored.isProcessed).toBe(false);
}),
{ numRuns: 100 }
);
});
it('newly stored item has null processedAt timestamp', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// The stored item MUST have null processedAt
expect(stored.processedAt).toBeNull();
}),
{ numRuns: 100 }
);
});
it('newly stored item has null postId (no post created yet)', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// The stored item MUST have null postId
expect(stored.postId).toBeNull();
}),
{ numRuns: 100 }
);
});
it('all items in a batch are marked as unprocessed', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputsArb, async (inputs) => {
const storedItems: StoredContentItem[] = [];
for (const input of inputs) {
const stored = await storeContentItem(input);
storedItems.push(stored);
}
// ALL stored items MUST be marked as unprocessed
for (const stored of storedItems) {
expect(stored.isProcessed).toBe(false);
expect(stored.processedAt).toBeNull();
}
}),
{ numRuns: 100 }
);
});
});
describe('Content Data Preservation', () => {
it('stored item preserves the title', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
expect(stored.title).toBe(input.title);
}),
{ numRuns: 100 }
);
});
it('stored item preserves the content (including null)', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
expect(stored.content).toBe(input.content);
}),
{ numRuns: 100 }
);
});
it('stored item preserves the URL', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
expect(stored.url).toBe(input.url);
}),
{ numRuns: 100 }
);
});
it('stored item preserves the publication date', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// Dates should be equal (comparing timestamps)
expect(stored.publishedAt.getTime()).toBe(input.publishedAt.getTime());
}),
{ numRuns: 100 }
);
});
});
describe('Storage Metadata', () => {
it('stored item has a unique ID assigned', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// The stored item MUST have an ID
expect(stored.id).toBeDefined();
expect(typeof stored.id).toBe('string');
expect(stored.id.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('stored item has a fetchedAt timestamp', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// The stored item MUST have a fetchedAt timestamp
expect(stored.fetchedAt).toBeInstanceOf(Date);
expect(isNaN(stored.fetchedAt.getTime())).toBe(false);
}),
{ numRuns: 100 }
);
});
it('fetchedAt timestamp is recent (within last minute)', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const beforeStore = new Date();
const stored = await storeContentItem(input);
const afterStore = new Date();
// fetchedAt should be between before and after store times
expect(stored.fetchedAt.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime() - 1000);
expect(stored.fetchedAt.getTime()).toBeLessThanOrEqual(afterStore.getTime() + 1000);
}),
{ numRuns: 100 }
);
});
it('multiple stored items have unique IDs', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputsArb, async (inputs) => {
const storedItems: StoredContentItem[] = [];
for (const input of inputs) {
const stored = await storeContentItem(input);
storedItems.push(stored);
}
// All IDs should be unique
const ids = storedItems.map(item => item.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
}),
{ numRuns: 100 }
);
});
});
describe('Interest Score Fields', () => {
it('newly stored item has null interest score', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// Interest score should be null for new items
expect(stored.interestScore).toBeNull();
}),
{ numRuns: 100 }
);
});
it('newly stored item has null interest reason', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// Interest reason should be null for new items
expect(stored.interestReason).toBeNull();
}),
{ numRuns: 100 }
);
});
});
describe('Batch Storage with storeContentItems', () => {
it('storeContentItems stores all items with source references', async () => {
await fc.assert(
fc.asyncProperty(contentItemsWithSameSourceArb, async (inputs) => {
const storedCount = await storeContentItems(inputs, true);
// All items should be stored
expect(storedCount).toBe(inputs.length);
}),
{ numRuns: 100 }
);
});
it('storeContentItems marks all items as unprocessed', async () => {
await fc.assert(
fc.asyncProperty(contentItemsWithSameSourceArb, async (inputs) => {
// Store items and verify count
const storedCount = await storeContentItems(inputs, true);
expect(storedCount).toBe(inputs.length);
// Verify all items in store are unprocessed
const store = __getStore();
for (const [, item] of store) {
expect(item.isProcessed).toBe(false);
}
}),
{ numRuns: 100 }
);
});
});
describe('Data Integrity Properties', () => {
it('storage is idempotent for item data - same input produces same output fields', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// All input fields should be preserved exactly
expect(stored.sourceId).toBe(input.sourceId);
expect(stored.externalId).toBe(input.externalId);
expect(stored.title).toBe(input.title);
expect(stored.content).toBe(input.content);
expect(stored.url).toBe(input.url);
expect(stored.publishedAt.getTime()).toBe(input.publishedAt.getTime());
// Default fields should be set correctly
expect(stored.isProcessed).toBe(false);
expect(stored.processedAt).toBeNull();
expect(stored.postId).toBeNull();
expect(stored.interestScore).toBeNull();
expect(stored.interestReason).toBeNull();
}),
{ numRuns: 100 }
);
});
it('no data corruption - stored data matches input exactly', async () => {
await fc.assert(
fc.asyncProperty(contentItemInputArb, async (input) => {
const stored = await storeContentItem(input);
// Verify no truncation or modification of string fields
if (input.title.length > 0) {
expect(stored.title.length).toBe(input.title.length);
}
if (input.content !== null && input.content.length > 0) {
expect(stored.content!.length).toBe(input.content.length);
}
expect(stored.url.length).toBe(input.url.length);
}),
{ numRuns: 100 }
);
});
});
});
// ============================================
// PROPERTY 15: FETCH ERROR RETRY WITH BACKOFF
// ============================================
/**
* Feature: bot-system, Property 15: Fetch Error Retry with Backoff
*
* *For any* content source that fails to fetch, consecutive failures SHALL
* increase the retry delay exponentially up to a maximum.
*
* **Validates: Requirements 4.7**
*/
describe('Feature: bot-system, Property 15: Fetch Error Retry with Backoff', () => {
// Import the backoff calculation function and constants
// These are imported at the top of the file from contentFetcher
// ============================================
// GENERATORS
// ============================================
/**
* Generator for consecutive error counts (0 to 20).
* We test beyond MAX_CONSECUTIVE_ERRORS to verify capping behavior.
*/
const consecutiveErrorsArb = fc.integer({ min: 0, max: 20 });
/**
* Generator for pairs of consecutive error counts where the second is greater.
* Used to test that delays increase with more errors.
*/
const increasingErrorPairArb = fc.tuple(
fc.integer({ min: 1, max: 15 }),
fc.integer({ min: 1, max: 15 })
).filter(([a, b]) => a < b);
/**
* Generator for sequences of consecutive error counts (simulating multiple failures).
*/
const errorSequenceArb = fc.array(
fc.integer({ min: 1, max: 15 }),
{ minLength: 2, maxLength: 10 }
).map(arr => [...arr].sort((a, b) => a - b)); // Sort to get increasing sequence
// ============================================
// PROPERTY TESTS
// ============================================
describe('Exponential Backoff Calculation', () => {
it('zero consecutive errors results in zero delay', () => {
fc.assert(
fc.property(fc.constant(0), (errors) => {
const delay = calculateBackoffDelay(errors);
expect(delay).toBe(0);
}),
{ numRuns: 100 }
);
});
it('negative consecutive errors results in zero delay', () => {
fc.assert(
fc.property(fc.integer({ min: -100, max: -1 }), (errors) => {
const delay = calculateBackoffDelay(errors);
expect(delay).toBe(0);
}),
{ numRuns: 100 }
);
});
it('first error produces a delay based on BASE_BACKOFF_DELAY_MS', () => {
fc.assert(
fc.property(fc.constant(1), () => {
const delay = calculateBackoffDelay(1);
// First error: base * 2^0 = base, with ±10% jitter
const expectedBase = BASE_BACKOFF_DELAY_MS;
const minExpected = expectedBase * 0.9;
const maxExpected = expectedBase * 1.1;
expect(delay).toBeGreaterThanOrEqual(Math.floor(minExpected));
expect(delay).toBeLessThanOrEqual(Math.ceil(maxExpected));
}),
{ numRuns: 100 }
);
});
it('delay increases exponentially with consecutive errors', () => {
fc.assert(
fc.property(increasingErrorPairArb, ([lowerErrors, higherErrors]) => {
// Run multiple times to account for jitter and get average behavior
const lowerDelays: number[] = [];
const higherDelays: number[] = [];
for (let i = 0; i < 10; i++) {
lowerDelays.push(calculateBackoffDelay(lowerErrors));
higherDelays.push(calculateBackoffDelay(higherErrors));
}
// Calculate averages to smooth out jitter
const avgLower = lowerDelays.reduce((a, b) => a + b, 0) / lowerDelays.length;
const avgHigher = higherDelays.reduce((a, b) => a + b, 0) / higherDelays.length;
// Higher consecutive errors should result in higher average delay
// (unless both are capped at maximum)
const lowerBase = BASE_BACKOFF_DELAY_MS * Math.pow(2, lowerErrors - 1);
const higherBase = BASE_BACKOFF_DELAY_MS * Math.pow(2, higherErrors - 1);
if (lowerBase < MAX_BACKOFF_DELAY_MS && higherBase < MAX_BACKOFF_DELAY_MS) {
// Neither is capped, so higher should be greater
expect(avgHigher).toBeGreaterThan(avgLower);
} else if (lowerBase >= MAX_BACKOFF_DELAY_MS && higherBase >= MAX_BACKOFF_DELAY_MS) {
// Both are capped, so they should be approximately equal
expect(Math.abs(avgHigher - avgLower)).toBeLessThan(MAX_BACKOFF_DELAY_MS * 0.25);
}
// If only one is capped, higher should still be >= lower
}),
{ numRuns: 100 }
);
});
it('delay follows exponential formula: base * 2^(errors-1)', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 10 }), (errors) => {
const delay = calculateBackoffDelay(errors);
// Expected delay without jitter
const expectedBase = BASE_BACKOFF_DELAY_MS * Math.pow(2, errors - 1);
const cappedExpected = Math.min(expectedBase, MAX_BACKOFF_DELAY_MS);
// With ±10% jitter
const minExpected = cappedExpected * 0.9;
const maxExpected = cappedExpected * 1.1;
expect(delay).toBeGreaterThanOrEqual(Math.floor(minExpected));
expect(delay).toBeLessThanOrEqual(Math.ceil(maxExpected));
}),
{ numRuns: 100 }
);
});
});
describe('Maximum Delay Cap', () => {
it('delay never exceeds MAX_BACKOFF_DELAY_MS (plus jitter)', () => {
fc.assert(
fc.property(consecutiveErrorsArb, (errors) => {
const delay = calculateBackoffDelay(errors);
// Maximum possible delay is MAX_BACKOFF_DELAY_MS + 10% jitter
const absoluteMax = MAX_BACKOFF_DELAY_MS * 1.1;
expect(delay).toBeLessThanOrEqual(Math.ceil(absoluteMax));
}),
{ numRuns: 100 }
);
});
it('very high error counts are capped at maximum delay', () => {
fc.assert(
fc.property(fc.integer({ min: 20, max: 100 }), (errors) => {
const delay = calculateBackoffDelay(errors);
// Should be capped at MAX_BACKOFF_DELAY_MS with ±10% jitter
const minExpected = MAX_BACKOFF_DELAY_MS * 0.9;
const maxExpected = MAX_BACKOFF_DELAY_MS * 1.1;
expect(delay).toBeGreaterThanOrEqual(Math.floor(minExpected));
expect(delay).toBeLessThanOrEqual(Math.ceil(maxExpected));
}),
{ numRuns: 100 }
);
});
it('delay at MAX_CONSECUTIVE_ERRORS is capped appropriately', () => {
fc.assert(
fc.property(fc.constant(MAX_CONSECUTIVE_ERRORS), () => {
const delay = calculateBackoffDelay(MAX_CONSECUTIVE_ERRORS);
// At 10 errors: base * 2^9 = 1000 * 512 = 512000ms
// This is less than MAX_BACKOFF_DELAY_MS (3600000ms), so not capped
const expectedBase = BASE_BACKOFF_DELAY_MS * Math.pow(2, MAX_CONSECUTIVE_ERRORS - 1);
const cappedExpected = Math.min(expectedBase, MAX_BACKOFF_DELAY_MS);
const minExpected = cappedExpected * 0.9;
const maxExpected = cappedExpected * 1.1;
expect(delay).toBeGreaterThanOrEqual(Math.floor(minExpected));
expect(delay).toBeLessThanOrEqual(Math.ceil(maxExpected));
}),
{ numRuns: 100 }
);
});
});
describe('Delay Monotonicity', () => {
it('delays are monotonically non-decreasing with error count (on average)', () => {
fc.assert(
fc.property(errorSequenceArb, (errorSequence) => {
// Calculate average delays for each error count
const avgDelays = errorSequence.map(errors => {
const samples: number[] = [];
for (let i = 0; i < 20; i++) {
samples.push(calculateBackoffDelay(errors));
}
return samples.reduce((a, b) => a + b, 0) / samples.length;
});
// Check that average delays are non-decreasing
for (let i = 1; i < avgDelays.length; i++) {
// Allow small tolerance for jitter effects
expect(avgDelays[i]).toBeGreaterThanOrEqual(avgDelays[i - 1] * 0.85);
}
}),
{ numRuns: 100 }
);
});
it('doubling error count approximately doubles delay (before cap)', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 5 }), (errors) => {
const doubledErrors = errors * 2;
// Get average delays
const getAvgDelay = (e: number) => {
const samples: number[] = [];
for (let i = 0; i < 20; i++) {
samples.push(calculateBackoffDelay(e));
}
return samples.reduce((a, b) => a + b, 0) / samples.length;
};
const singleDelay = getAvgDelay(errors);
const doubleDelay = getAvgDelay(doubledErrors);
// Expected ratio: 2^(doubledErrors-1) / 2^(errors-1) = 2^(errors)
// For errors=1, doubled=2: ratio should be ~2
// For errors=2, doubled=4: ratio should be ~4
const expectedRatio = Math.pow(2, errors);
// Check if both are below cap
const singleBase = BASE_BACKOFF_DELAY_MS * Math.pow(2, errors - 1);
const doubleBase = BASE_BACKOFF_DELAY_MS * Math.pow(2, doubledErrors - 1);
if (singleBase < MAX_BACKOFF_DELAY_MS && doubleBase < MAX_BACKOFF_DELAY_MS) {
const actualRatio = doubleDelay / singleDelay;
// Allow 30% tolerance for jitter
expect(actualRatio).toBeGreaterThan(expectedRatio * 0.7);
expect(actualRatio).toBeLessThan(expectedRatio * 1.3);
}
}),
{ numRuns: 100 }
);
});
});
describe('Jitter Properties', () => {
it('delay includes jitter (not always exactly the same)', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 10 }), (errors) => {
const delays: number[] = [];
// Collect multiple delay values
for (let i = 0; i < 50; i++) {
delays.push(calculateBackoffDelay(errors));
}
// Check that we have some variation (jitter is working)
const uniqueDelays = new Set(delays);
// With jitter, we should have multiple unique values
// (statistically very unlikely to get all same values with random jitter)
expect(uniqueDelays.size).toBeGreaterThan(1);
}),
{ numRuns: 100 }
);
});
it('jitter stays within ±10% of base delay', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 15 }), (errors) => {
const delay = calculateBackoffDelay(errors);
// Calculate expected base (before jitter)
const exponentialDelay = BASE_BACKOFF_DELAY_MS * Math.pow(2, errors - 1);
const cappedDelay = Math.min(exponentialDelay, MAX_BACKOFF_DELAY_MS);
// Jitter should be ±10%
const minWithJitter = cappedDelay * 0.9;
const maxWithJitter = cappedDelay * 1.1;
expect(delay).toBeGreaterThanOrEqual(Math.floor(minWithJitter));
expect(delay).toBeLessThanOrEqual(Math.ceil(maxWithJitter));
}),
{ numRuns: 100 }
);
});
});
describe('Delay Value Ranges', () => {
it('delay is always a non-negative integer', () => {
fc.assert(
fc.property(consecutiveErrorsArb, (errors) => {
const delay = calculateBackoffDelay(errors);
expect(delay).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(delay)).toBe(true);
}),
{ numRuns: 100 }
);
});
it('delay for 1 error is approximately BASE_BACKOFF_DELAY_MS', () => {
fc.assert(
fc.property(fc.constant(1), () => {
const delay = calculateBackoffDelay(1);
// 1 error: base * 2^0 = base = 1000ms
expect(delay).toBeGreaterThanOrEqual(900); // 1000 - 10%
expect(delay).toBeLessThanOrEqual(1100); // 1000 + 10%
}),
{ numRuns: 100 }
);
});
it('delay for 5 errors is approximately 16x BASE_BACKOFF_DELAY_MS', () => {
fc.assert(
fc.property(fc.constant(5), () => {
const delay = calculateBackoffDelay(5);
// 5 errors: base * 2^4 = 1000 * 16 = 16000ms
const expected = 16000;
expect(delay).toBeGreaterThanOrEqual(expected * 0.9);
expect(delay).toBeLessThanOrEqual(expected * 1.1);
}),
{ numRuns: 100 }
);
});
it('delay for 10 errors is approximately 512x BASE_BACKOFF_DELAY_MS', () => {
fc.assert(
fc.property(fc.constant(10), () => {
const delay = calculateBackoffDelay(10);
// 10 errors: base * 2^9 = 1000 * 512 = 512000ms
const expected = 512000;
expect(delay).toBeGreaterThanOrEqual(expected * 0.9);
expect(delay).toBeLessThanOrEqual(expected * 1.1);
}),
{ numRuns: 100 }
);
});
});
describe('Edge Cases', () => {
it('handles boundary at cap transition correctly', () => {
// Find the error count where we transition to being capped
// MAX_BACKOFF_DELAY_MS = 3600000ms (1 hour)
// base * 2^(n-1) >= 3600000
// 1000 * 2^(n-1) >= 3600000
// 2^(n-1) >= 3600
// n-1 >= log2(3600) ≈ 11.8
// n >= 12.8, so n = 13 is first capped
fc.assert(
fc.property(fc.constant(12), () => {
const delay12 = calculateBackoffDelay(12);
const delay13 = calculateBackoffDelay(13);
const delay14 = calculateBackoffDelay(14);
// 12 errors: 1000 * 2^11 = 2048000ms (not capped)
// 13 errors: 1000 * 2^12 = 4096000ms (would be capped to 3600000)
// 14 errors: 1000 * 2^13 = 8192000ms (would be capped to 3600000)
// delay12 should be around 2048000
expect(delay12).toBeGreaterThanOrEqual(2048000 * 0.9);
expect(delay12).toBeLessThanOrEqual(2048000 * 1.1);
// delay13 and delay14 should both be capped at MAX_BACKOFF_DELAY_MS
expect(delay13).toBeGreaterThanOrEqual(MAX_BACKOFF_DELAY_MS * 0.9);
expect(delay13).toBeLessThanOrEqual(MAX_BACKOFF_DELAY_MS * 1.1);
expect(delay14).toBeGreaterThanOrEqual(MAX_BACKOFF_DELAY_MS * 0.9);
expect(delay14).toBeLessThanOrEqual(MAX_BACKOFF_DELAY_MS * 1.1);
}),
{ numRuns: 100 }
);
});
});
});
+276
View File
@@ -0,0 +1,276 @@
/**
* Content Fetcher Tests
*
* Tests for the content fetcher module including exponential backoff,
* error tracking, and content storage.
*
* Requirements: 4.5, 4.7
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
calculateBackoffDelay,
shouldRetrySource,
isSourceDueForFetch,
BASE_BACKOFF_DELAY_MS,
MAX_BACKOFF_DELAY_MS,
MAX_CONSECUTIVE_ERRORS,
} from './contentFetcher';
import type { ContentSource } from './contentSource';
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Create a mock content source for testing.
*/
function createMockSource(overrides: Partial<ContentSource> = {}): ContentSource {
return {
id: 'test-source-id',
botId: 'test-bot-id',
type: 'rss',
url: 'https://example.com/feed.xml',
subreddit: null,
sourceConfig: null,
keywords: null,
isActive: true,
lastFetchAt: null,
lastError: null,
consecutiveErrors: 0,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// ============================================
// EXPONENTIAL BACKOFF TESTS
// ============================================
describe('calculateBackoffDelay', () => {
it('should return 0 for 0 consecutive errors', () => {
const delay = calculateBackoffDelay(0);
expect(delay).toBe(0);
});
it('should return 0 for negative consecutive errors', () => {
const delay = calculateBackoffDelay(-1);
expect(delay).toBe(0);
});
it('should return base delay for 1 consecutive error', () => {
const delay = calculateBackoffDelay(1);
// With jitter, should be within ±10% of base delay
expect(delay).toBeGreaterThanOrEqual(BASE_BACKOFF_DELAY_MS * 0.9);
expect(delay).toBeLessThanOrEqual(BASE_BACKOFF_DELAY_MS * 1.1);
});
it('should double delay for each consecutive error', () => {
// Test exponential growth pattern
const delay1 = calculateBackoffDelay(1);
const delay2 = calculateBackoffDelay(2);
const delay3 = calculateBackoffDelay(3);
// delay2 should be approximately 2x delay1 (accounting for jitter)
expect(delay2).toBeGreaterThan(delay1);
expect(delay3).toBeGreaterThan(delay2);
// Check approximate doubling (within jitter range)
const expectedDelay2 = BASE_BACKOFF_DELAY_MS * 2;
const expectedDelay3 = BASE_BACKOFF_DELAY_MS * 4;
expect(delay2).toBeGreaterThanOrEqual(expectedDelay2 * 0.9);
expect(delay2).toBeLessThanOrEqual(expectedDelay2 * 1.1);
expect(delay3).toBeGreaterThanOrEqual(expectedDelay3 * 0.9);
expect(delay3).toBeLessThanOrEqual(expectedDelay3 * 1.1);
});
it('should cap delay at maximum', () => {
// With very high consecutive errors, should cap at max
const delay = calculateBackoffDelay(100);
expect(delay).toBeLessThanOrEqual(MAX_BACKOFF_DELAY_MS * 1.1);
});
it('should include jitter in delay', () => {
// Run multiple times and check for variation
const delays = Array.from({ length: 10 }, () => calculateBackoffDelay(5));
const uniqueDelays = new Set(delays);
// With jitter, we should get some variation
// (though there's a small chance all are the same)
expect(uniqueDelays.size).toBeGreaterThanOrEqual(1);
});
});
// ============================================
// RETRY LOGIC TESTS
// ============================================
describe('shouldRetrySource', () => {
it('should return true for active source with no errors', () => {
const source = createMockSource({
isActive: true,
consecutiveErrors: 0,
});
expect(shouldRetrySource(source)).toBe(true);
});
it('should return false for inactive source', () => {
const source = createMockSource({
isActive: false,
consecutiveErrors: 0,
});
expect(shouldRetrySource(source)).toBe(false);
});
it('should return false when max consecutive errors reached', () => {
const source = createMockSource({
isActive: true,
consecutiveErrors: MAX_CONSECUTIVE_ERRORS,
});
expect(shouldRetrySource(source)).toBe(false);
});
it('should return false when in backoff period', () => {
const source = createMockSource({
isActive: true,
consecutiveErrors: 3,
lastFetchAt: new Date(), // Just fetched
});
expect(shouldRetrySource(source)).toBe(false);
});
it('should return true when backoff period has passed', () => {
const backoffDelay = calculateBackoffDelay(1);
const source = createMockSource({
isActive: true,
consecutiveErrors: 1,
lastFetchAt: new Date(Date.now() - backoffDelay - 1000), // Past backoff
});
expect(shouldRetrySource(source)).toBe(true);
});
it('should return true for source with errors but never fetched', () => {
const source = createMockSource({
isActive: true,
consecutiveErrors: 3,
lastFetchAt: null,
});
expect(shouldRetrySource(source)).toBe(true);
});
});
// ============================================
// FETCH DUE TESTS
// ============================================
describe('isSourceDueForFetch', () => {
it('should return true for source never fetched', () => {
const source = createMockSource({
isActive: true,
lastFetchAt: null,
});
expect(isSourceDueForFetch(source)).toBe(true);
});
it('should return false for inactive source', () => {
const source = createMockSource({
isActive: false,
lastFetchAt: null,
});
expect(isSourceDueForFetch(source)).toBe(false);
});
});
// ============================================
// BACKOFF DELAY PROPERTY TESTS
// ============================================
describe('Exponential Backoff Properties', () => {
/**
* Property: Backoff delay should always be non-negative
* Validates: Requirements 4.7
*/
it('should always return non-negative delay', () => {
for (let errors = -10; errors <= 20; errors++) {
const delay = calculateBackoffDelay(errors);
expect(delay).toBeGreaterThanOrEqual(0);
}
});
/**
* Property: Backoff delay should never exceed maximum
* Validates: Requirements 4.7
*/
it('should never exceed maximum delay', () => {
for (let errors = 0; errors <= 100; errors++) {
const delay = calculateBackoffDelay(errors);
// Allow for jitter (10% above max)
expect(delay).toBeLessThanOrEqual(MAX_BACKOFF_DELAY_MS * 1.1);
}
});
/**
* Property: Backoff delay should increase with consecutive errors
* Validates: Requirements 4.7
*/
it('should generally increase with consecutive errors', () => {
// Test average over multiple runs to account for jitter
const getAverageDelay = (errors: number) => {
const samples = 10;
const total = Array.from({ length: samples }, () => calculateBackoffDelay(errors))
.reduce((a, b) => a + b, 0);
return total / samples;
};
const avg1 = getAverageDelay(1);
const avg3 = getAverageDelay(3);
const avg5 = getAverageDelay(5);
expect(avg3).toBeGreaterThan(avg1);
expect(avg5).toBeGreaterThan(avg3);
});
});
// ============================================
// ERROR TRACKING TESTS
// ============================================
describe('Error Tracking', () => {
it('should track consecutive errors correctly', () => {
// Source with increasing errors should eventually be disabled
let source = createMockSource({ consecutiveErrors: 0 });
for (let i = 0; i < MAX_CONSECUTIVE_ERRORS - 1; i++) {
source = { ...source, consecutiveErrors: i + 1 };
expect(shouldRetrySource(source)).toBe(true);
}
// At max errors, should not retry
source = { ...source, consecutiveErrors: MAX_CONSECUTIVE_ERRORS };
expect(shouldRetrySource(source)).toBe(false);
});
it('should reset error count on success', () => {
// After success, consecutive errors should be 0
const source = createMockSource({
consecutiveErrors: 0,
lastError: null,
});
expect(shouldRetrySource(source)).toBe(true);
});
});
+991
View File
@@ -0,0 +1,991 @@
/**
* Content Fetcher Module
*
* Fetches content from RSS, Reddit, and news API sources with exponential backoff
* retry logic on failures. Tracks consecutive errors per source and stores
* fetched content items in the database.
*
* Requirements: 4.5, 4.7
* Validates: Property 13 - Content Item Storage
* Validates: Property 15 - Fetch Error Retry with Backoff
*/
import { db, botContentSources, botContentItems } from '@/db';
import { eq, and } from 'drizzle-orm';
import { parseRSSFeed, FeedItem } from './rssParser';
import { ContentSource, ContentSourceType, getSourceById, BraveNewsConfig } from './contentSource';
import { decryptApiKey, deserializeEncryptedData } from './encryption';
// ============================================
// TYPES
// ============================================
/**
* Result of a content fetch operation.
*/
export interface FetchResult {
success: boolean;
sourceId: string;
itemsFetched: number;
itemsStored: number;
error?: string;
warnings: string[];
}
/**
* Content item to be stored in the database.
*/
export interface ContentItemInput {
sourceId: string;
externalId: string;
title: string;
content: string | null;
url: string;
publishedAt: Date;
}
/**
* Stored content item from the database.
*/
export interface StoredContentItem {
id: string;
sourceId: string;
externalId: string;
title: string;
content: string | null;
url: string;
publishedAt: Date;
fetchedAt: Date;
isProcessed: boolean;
processedAt: Date | null;
postId: string | null;
interestScore: number | null;
interestReason: string | null;
}
/**
* Options for fetch operations.
*/
export interface FetchOptions {
/** Maximum number of items to fetch (default: 50) */
maxItems?: number;
/** Timeout for HTTP requests in milliseconds (default: 30000) */
timeout?: number;
/** Whether to skip duplicate detection (default: false) */
skipDuplicateCheck?: boolean;
}
// ============================================
// CONSTANTS
// ============================================
/** Default maximum items to fetch per source */
export const DEFAULT_MAX_ITEMS = 50;
/** Default HTTP request timeout in milliseconds */
export const DEFAULT_TIMEOUT_MS = 30000;
/** Base delay for exponential backoff in milliseconds */
export const BASE_BACKOFF_DELAY_MS = 1000;
/** Maximum backoff delay in milliseconds (1 hour) */
export const MAX_BACKOFF_DELAY_MS = 3600000;
/** Maximum consecutive errors before disabling source */
export const MAX_CONSECUTIVE_ERRORS = 10;
/** Reddit API base URL */
const REDDIT_API_BASE = 'https://www.reddit.com';
/** User agent for HTTP requests */
const USER_AGENT = 'Synapsis Bot Content Fetcher/1.0';
// ============================================
// ERROR CLASSES
// ============================================
/**
* Base error class for content fetching operations.
*/
export class ContentFetchError extends Error {
constructor(
message: string,
public code: string,
public sourceId?: string,
public retryable: boolean = true
) {
super(message);
this.name = 'ContentFetchError';
}
}
/**
* Error thrown when a network request fails.
*/
export class NetworkError extends ContentFetchError {
constructor(message: string, sourceId?: string) {
super(message, 'NETWORK_ERROR', sourceId, true);
this.name = 'NetworkError';
}
}
/**
* Error thrown when content parsing fails.
*/
export class ParseError extends ContentFetchError {
constructor(message: string, sourceId?: string) {
super(message, 'PARSE_ERROR', sourceId, false);
this.name = 'ParseError';
}
}
/**
* Error thrown when source is not found.
*/
export class SourceNotFoundError extends ContentFetchError {
constructor(sourceId: string) {
super(`Content source not found: ${sourceId}`, 'SOURCE_NOT_FOUND', sourceId, false);
this.name = 'SourceNotFoundError';
}
}
// ============================================
// EXPONENTIAL BACKOFF
// ============================================
/**
* Calculate the backoff delay based on consecutive errors.
* Uses exponential backoff with jitter.
*
* @param consecutiveErrors - Number of consecutive errors
* @returns Delay in milliseconds
*
* Validates: Requirements 4.7
*/
export function calculateBackoffDelay(consecutiveErrors: number): number {
if (consecutiveErrors <= 0) {
return 0;
}
// Exponential backoff: base * 2^(errors - 1)
const exponentialDelay = BASE_BACKOFF_DELAY_MS * Math.pow(2, consecutiveErrors - 1);
// Cap at maximum delay
const cappedDelay = Math.min(exponentialDelay, MAX_BACKOFF_DELAY_MS);
// Add jitter (±10% of the delay)
const jitter = cappedDelay * 0.1 * (Math.random() * 2 - 1);
return Math.floor(cappedDelay + jitter);
}
/**
* Check if a source should be retried based on its error state.
*
* @param source - The content source
* @returns Whether the source should be retried
*/
export function shouldRetrySource(source: ContentSource): boolean {
// Don't retry if source is inactive
if (!source.isActive) {
return false;
}
// Don't retry if max consecutive errors reached
if (source.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
return false;
}
// Check if enough time has passed since last fetch
if (source.lastFetchAt && source.consecutiveErrors > 0) {
const backoffDelay = calculateBackoffDelay(source.consecutiveErrors);
const timeSinceLastFetch = Date.now() - source.lastFetchAt.getTime();
if (timeSinceLastFetch < backoffDelay) {
return false;
}
}
return true;
}
/**
* Check if a source is due for fetching based on its interval.
*
* @param source - The content source
* @returns Whether the source is due for fetching
*/
export function isSourceDueForFetch(source: ContentSource): boolean {
// Only check if source is active
// Content is now fetched on-demand when posting, not on a schedule
return source.isActive;
}
// ============================================
// HTTP FETCHING
// ============================================
/**
* Fetch content from a URL with timeout.
*
* @param url - The URL to fetch
* @param options - Fetch options
* @returns The response text
* @throws NetworkError on failure
*/
async function fetchUrl(
url: string,
options: { timeout?: number; headers?: Record<string, string> } = {}
): Promise<string> {
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/rss+xml, application/xml, application/atom+xml, text/xml, application/json, */*',
...options.headers,
},
});
if (!response.ok) {
throw new NetworkError(
`HTTP ${response.status}: ${response.statusText}`
);
}
return await response.text();
} catch (error) {
if (error instanceof NetworkError) {
throw error;
}
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new NetworkError(`Request timeout after ${timeout}ms`);
}
throw new NetworkError(error.message);
}
throw new NetworkError('Unknown network error');
} finally {
clearTimeout(timeoutId);
}
}
// ============================================
// RSS FETCHING
// ============================================
/**
* Fetch and parse an RSS feed.
*
* @param url - The RSS feed URL
* @param options - Fetch options
* @returns Array of feed items
* @throws NetworkError or ParseError on failure
*
* Validates: Requirements 4.2
*/
export async function fetchRSSFeed(
url: string,
options: FetchOptions = {}
): Promise<FeedItem[]> {
const xml = await fetchUrl(url, { timeout: options.timeout });
const result = parseRSSFeed(xml);
if (!result.success || !result.feed) {
throw new ParseError(result.error || 'Failed to parse RSS feed');
}
const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
return result.feed.items.slice(0, maxItems);
}
// ============================================
// REDDIT FETCHING
// ============================================
/**
* Fetch posts from a Reddit subreddit.
* Uses the RSS feed which is more reliable than the JSON API.
*
* @param subreddit - The subreddit name
* @param options - Fetch options
* @returns Array of feed items
* @throws NetworkError or ParseError on failure
*
* Validates: Requirements 4.3
*/
export async function fetchRedditPosts(
subreddit: string,
options: FetchOptions = {}
): Promise<FeedItem[]> {
// Use RSS feed instead of JSON API - more reliable and doesn't require auth
const rssUrl = `https://www.reddit.com/r/${subreddit}/hot.rss`;
try {
return await fetchRSSFeed(rssUrl, options);
} catch (error) {
// If RSS fails, try the old.reddit.com RSS which sometimes works better
const oldRedditUrl = `https://old.reddit.com/r/${subreddit}/hot.rss`;
return await fetchRSSFeed(oldRedditUrl, options);
}
}
// ============================================
// NEWS API FETCHING
// ============================================
/**
* News API article structure.
*/
interface NewsApiArticle {
source?: { id?: string; name?: string };
title: string;
description?: string;
url: string;
publishedAt: string;
content?: string;
}
/**
* News API response structure.
*/
interface NewsApiResponse {
status: string;
articles?: NewsApiArticle[];
error?: string;
}
/**
* Brave News API result structure.
*/
interface BraveNewsResult {
title: string;
url: string;
description?: string;
age?: string;
page_age?: string;
meta_url?: {
hostname?: string;
};
}
/**
* Brave News API response structure.
*/
interface BraveNewsResponse {
type: string;
results?: BraveNewsResult[];
}
/**
* Fetch articles from a news API.
*
* @param url - The news API URL
* @param apiKey - The API key for authentication
* @param options - Fetch options
* @returns Array of feed items
* @throws NetworkError or ParseError on failure
*
* Validates: Requirements 4.4
*/
export async function fetchNewsApi(
url: string,
apiKey: string,
options: FetchOptions = {}
): Promise<FeedItem[]> {
// Append API key to URL if not already present
const urlObj = new URL(url);
if (!urlObj.searchParams.has('apiKey') && !urlObj.searchParams.has('api_key')) {
urlObj.searchParams.set('apiKey', apiKey);
}
const responseText = await fetchUrl(urlObj.toString(), {
timeout: options.timeout,
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
});
let response: NewsApiResponse;
try {
response = JSON.parse(responseText);
} catch {
throw new ParseError('Failed to parse News API JSON response');
}
if (response.status !== 'ok' || !response.articles) {
throw new ParseError(response.error || 'Invalid News API response');
}
const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
return response.articles.slice(0, maxItems).map((article, index): FeedItem => ({
id: `${article.source?.id || 'news'}-${index}-${Date.now()}`,
title: article.title,
content: article.description || article.content || '',
url: article.url,
publishedAt: new Date(article.publishedAt),
}));
}
/**
* Fetch articles from Brave News Search API.
*
* @param config - The Brave News configuration
* @param apiKey - The API key for authentication
* @param options - Fetch options
* @returns Array of feed items
* @throws NetworkError or ParseError on failure
*/
export async function fetchBraveNews(
config: BraveNewsConfig,
apiKey: string,
options: FetchOptions = {}
): Promise<FeedItem[]> {
// Build the URL from config
const url = new URL('https://api.search.brave.com/res/v1/news/search');
url.searchParams.set('q', config.query);
if (config.freshness) {
url.searchParams.set('freshness', config.freshness);
}
if (config.country) {
url.searchParams.set('country', config.country);
}
if (config.searchLang) {
url.searchParams.set('search_lang', config.searchLang);
}
const count = Math.min(config.count || 20, 50);
url.searchParams.set('count', String(count));
const responseText = await fetchUrl(url.toString(), {
timeout: options.timeout,
headers: {
'Accept': 'application/json',
'X-Subscription-Token': apiKey,
},
});
let response: BraveNewsResponse;
try {
response = JSON.parse(responseText);
} catch {
throw new ParseError('Failed to parse Brave News API JSON response');
}
if (!response.results || response.results.length === 0) {
return [];
}
const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
return response.results.slice(0, maxItems).map((result, index): FeedItem => ({
id: `brave-${config.query.replace(/\s+/g, '-')}-${index}-${Date.now()}`,
title: result.title,
content: result.description || '',
url: result.url,
publishedAt: new Date(), // Brave doesn't always provide exact dates
}));
}
// ============================================
// CONTENT STORAGE
// ============================================
/**
* Check if a content item already exists in the database.
*
* @param sourceId - The source ID
* @param externalId - The external ID from the source
* @returns True if the item exists
*/
export async function contentItemExists(
sourceId: string,
externalId: string
): Promise<boolean> {
const existing = await db.query.botContentItems.findFirst({
where: and(
eq(botContentItems.sourceId, sourceId),
eq(botContentItems.externalId, externalId)
),
columns: { id: true },
});
return existing !== undefined;
}
/**
* Store a content item in the database.
*
* @param item - The content item to store
* @returns The stored content item
*
* Validates: Requirements 4.5
*/
export async function storeContentItem(
item: ContentItemInput
): Promise<StoredContentItem> {
const [stored] = await db
.insert(botContentItems)
.values({
sourceId: item.sourceId,
externalId: item.externalId,
title: item.title,
content: item.content,
url: item.url,
publishedAt: item.publishedAt,
isProcessed: false,
})
.returning();
return {
id: stored.id,
sourceId: stored.sourceId,
externalId: stored.externalId,
title: stored.title,
content: stored.content,
url: stored.url,
publishedAt: stored.publishedAt,
fetchedAt: stored.fetchedAt,
isProcessed: stored.isProcessed,
processedAt: stored.processedAt,
postId: stored.postId,
interestScore: stored.interestScore,
interestReason: stored.interestReason,
};
}
/**
* Store multiple content items, skipping duplicates.
*
* @param items - The content items to store
* @param skipDuplicateCheck - Whether to skip duplicate checking
* @returns Number of items stored
*
* Validates: Requirements 4.5
*/
export async function storeContentItems(
items: ContentItemInput[],
skipDuplicateCheck: boolean = false
): Promise<number> {
let storedCount = 0;
for (const item of items) {
// Check for duplicates unless skipped
if (!skipDuplicateCheck) {
const exists = await contentItemExists(item.sourceId, item.externalId);
if (exists) {
continue;
}
}
try {
await storeContentItem(item);
storedCount++;
} catch (error) {
// Skip items that fail to store (e.g., constraint violations)
console.error(`Failed to store content item: ${error}`);
}
}
return storedCount;
}
// ============================================
// SOURCE STATE MANAGEMENT
// ============================================
/**
* Update source state after a successful fetch.
*
* @param sourceId - The source ID
*/
export async function recordFetchSuccess(sourceId: string): Promise<void> {
await db
.update(botContentSources)
.set({
lastFetchAt: new Date(),
lastError: null,
consecutiveErrors: 0,
updatedAt: new Date(),
})
.where(eq(botContentSources.id, sourceId));
}
/**
* Update source state after a failed fetch.
*
* @param sourceId - The source ID
* @param error - The error message
*
* Validates: Requirements 4.7
*/
export async function recordFetchError(
sourceId: string,
error: string
): Promise<void> {
// Get current consecutive errors
const source = await db.query.botContentSources.findFirst({
where: eq(botContentSources.id, sourceId),
columns: { consecutiveErrors: true },
});
const currentErrors = source?.consecutiveErrors ?? 0;
const newErrors = currentErrors + 1;
// Disable source if max errors reached
const shouldDisable = newErrors >= MAX_CONSECUTIVE_ERRORS;
await db
.update(botContentSources)
.set({
lastFetchAt: new Date(),
lastError: error,
consecutiveErrors: newErrors,
isActive: shouldDisable ? false : undefined,
updatedAt: new Date(),
})
.where(eq(botContentSources.id, sourceId));
}
// ============================================
// MAIN FETCH FUNCTION
// ============================================
/**
* Fetch content from a source and store new items.
* Implements exponential backoff on failures.
*
* @param sourceId - The ID of the content source
* @param options - Fetch options
* @returns Fetch result with statistics
*
* Validates: Requirements 4.5, 4.7
*/
export async function fetchContent(
sourceId: string,
options: FetchOptions = {}
): Promise<FetchResult> {
const warnings: string[] = [];
// Get the source
const source = await getSourceById(sourceId);
if (!source) {
throw new SourceNotFoundError(sourceId);
}
// Check if source should be retried
if (!shouldRetrySource(source)) {
return {
success: false,
sourceId,
itemsFetched: 0,
itemsStored: 0,
error: source.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS
? 'Source disabled due to too many consecutive errors'
: 'Source is in backoff period',
warnings,
};
}
try {
let items: FeedItem[];
// Fetch based on source type
switch (source.type) {
case 'rss':
case 'youtube':
// YouTube channels/playlists use RSS feeds
items = await fetchRSSFeed(source.url, options);
break;
case 'reddit':
if (!source.subreddit) {
throw new ParseError('Reddit source missing subreddit');
}
items = await fetchRedditPosts(source.subreddit, options);
break;
case 'news_api':
// Decrypt API key
const dbSource = await db.query.botContentSources.findFirst({
where: eq(botContentSources.id, sourceId),
columns: { apiKeyEncrypted: true },
});
if (!dbSource?.apiKeyEncrypted) {
throw new ParseError('News API source missing API key');
}
const encryptedData = deserializeEncryptedData(dbSource.apiKeyEncrypted);
const apiKey = decryptApiKey(encryptedData);
items = await fetchNewsApi(source.url, apiKey, options);
break;
case 'brave_news':
// Decrypt API key
const braveDbSource = await db.query.botContentSources.findFirst({
where: eq(botContentSources.id, sourceId),
columns: { apiKeyEncrypted: true, sourceConfig: true },
});
if (!braveDbSource?.apiKeyEncrypted) {
throw new ParseError('Brave News source missing API key');
}
const braveEncryptedData = deserializeEncryptedData(braveDbSource.apiKeyEncrypted);
const braveApiKey = decryptApiKey(braveEncryptedData);
// Get config from sourceConfig or parse from URL
let braveConfig: BraveNewsConfig;
if (braveDbSource.sourceConfig) {
braveConfig = JSON.parse(braveDbSource.sourceConfig) as BraveNewsConfig;
} else {
// Fallback: extract query from URL
const urlObj = new URL(source.url);
braveConfig = {
query: urlObj.searchParams.get('q') || 'news',
freshness: (urlObj.searchParams.get('freshness') as BraveNewsConfig['freshness']) || undefined,
country: urlObj.searchParams.get('country') || undefined,
searchLang: urlObj.searchParams.get('search_lang') || undefined,
};
}
items = await fetchBraveNews(braveConfig, braveApiKey, options);
break;
default:
throw new ParseError(`Unsupported source type: ${source.type}`);
}
// Filter by keywords if configured
if (source.keywords && source.keywords.length > 0) {
const keywords = source.keywords.map(k => k.toLowerCase());
items = items.filter(item => {
const text = `${item.title} ${item.content}`.toLowerCase();
return keywords.some(keyword => text.includes(keyword));
});
}
// Convert to content items
const contentItems: ContentItemInput[] = items.map(item => ({
sourceId,
externalId: item.id,
title: item.title,
content: item.content,
url: item.url,
publishedAt: item.publishedAt,
}));
// Store items
const storedCount = await storeContentItems(
contentItems,
options.skipDuplicateCheck
);
// Record success
await recordFetchSuccess(sourceId);
return {
success: true,
sourceId,
itemsFetched: items.length,
itemsStored: storedCount,
warnings,
};
} catch (error) {
// Record error
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await recordFetchError(sourceId, errorMessage);
return {
success: false,
sourceId,
itemsFetched: 0,
itemsStored: 0,
error: errorMessage,
warnings,
};
}
}
/**
* Fetch content from a source with retry logic.
* Retries on retryable errors with exponential backoff.
*
* @param sourceId - The ID of the content source
* @param maxRetries - Maximum number of retries (default: 3)
* @param options - Fetch options
* @returns Fetch result
*
* Validates: Requirements 4.7
*/
export async function fetchContentWithRetry(
sourceId: string,
maxRetries: number = 3,
options: FetchOptions = {}
): Promise<FetchResult> {
let lastResult: FetchResult | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// Wait for backoff delay on retries
if (attempt > 0) {
const delay = calculateBackoffDelay(attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
try {
const result = await fetchContent(sourceId, options);
if (result.success) {
return result;
}
lastResult = result;
// Don't retry if error is not retryable
if (result.error?.includes('disabled') || result.error?.includes('backoff')) {
return result;
}
} catch (error) {
if (error instanceof ContentFetchError && !error.retryable) {
throw error;
}
lastResult = {
success: false,
sourceId,
itemsFetched: 0,
itemsStored: 0,
error: error instanceof Error ? error.message : 'Unknown error',
warnings: [],
};
}
}
return lastResult || {
success: false,
sourceId,
itemsFetched: 0,
itemsStored: 0,
error: 'Max retries exceeded',
warnings: [],
};
}
/**
* Fetch content from all active sources for a bot.
*
* @param botId - The bot ID
* @param options - Fetch options
* @returns Array of fetch results
*/
export async function fetchAllSourcesForBot(
botId: string,
options: FetchOptions = {}
): Promise<FetchResult[]> {
const sources = await db.query.botContentSources.findMany({
where: and(
eq(botContentSources.botId, botId),
eq(botContentSources.isActive, true)
),
});
const results: FetchResult[] = [];
for (const source of sources) {
// Check if source is due for fetching
const sourceObj: ContentSource = {
id: source.id,
botId: source.botId,
type: source.type as ContentSourceType,
url: source.url,
subreddit: source.subreddit,
keywords: source.keywords ? JSON.parse(source.keywords) : null,
sourceConfig: source.sourceConfig ? JSON.parse(source.sourceConfig) : null,
isActive: source.isActive,
lastFetchAt: source.lastFetchAt,
lastError: source.lastError,
consecutiveErrors: source.consecutiveErrors,
createdAt: source.createdAt,
updatedAt: source.updatedAt,
};
if (!isSourceDueForFetch(sourceObj)) {
continue;
}
const result = await fetchContentWithRetry(source.id, 3, options);
results.push(result);
}
return results;
}
/**
* Get unprocessed content items for a source.
*
* @param sourceId - The source ID
* @param limit - Maximum number of items to return
* @returns Array of unprocessed content items
*/
export async function getUnprocessedItems(
sourceId: string,
limit: number = 10
): Promise<StoredContentItem[]> {
const items = await db.query.botContentItems.findMany({
where: and(
eq(botContentItems.sourceId, sourceId),
eq(botContentItems.isProcessed, false)
),
orderBy: (items, { asc }) => [asc(items.publishedAt)],
limit,
});
return items.map(item => ({
id: item.id,
sourceId: item.sourceId,
externalId: item.externalId,
title: item.title,
content: item.content,
url: item.url,
publishedAt: item.publishedAt,
fetchedAt: item.fetchedAt,
isProcessed: item.isProcessed,
processedAt: item.processedAt,
postId: item.postId,
interestScore: item.interestScore,
interestReason: item.interestReason,
}));
}
/**
* Mark a content item as processed.
*
* @param itemId - The content item ID
* @param postId - Optional post ID if a post was created
*/
export async function markItemProcessed(
itemId: string,
postId?: string
): Promise<void> {
await db
.update(botContentItems)
.set({
isProcessed: true,
processedAt: new Date(),
postId: postId || null,
})
.where(eq(botContentItems.id, itemId));
}
File diff suppressed because it is too large Load Diff
+816
View File
@@ -0,0 +1,816 @@
/**
* Unit Tests for Content Generator Module
*
* Tests the content generator implementation for post generation,
* reply generation, content interest evaluation, and content truncation.
*
* Requirements: 3.2, 3.5, 6.2, 11.1, 11.2, 11.3
*/
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach, afterEach } from 'vitest';
import {
ContentGenerator,
ContentGeneratorError,
truncateContent,
isContentTruncated,
buildPostSystemPrompt,
buildReplySystemPrompt,
buildEvaluationSystemPrompt,
buildPostUserMessage,
buildReplyUserMessage,
buildEvaluationUserMessage,
parseInterestResponse,
createContentGenerator,
createContentGeneratorWithClient,
MAX_SOURCE_CONTENT_LENGTH,
TRUNCATION_SUFFIX,
Bot,
ContentItem,
Post,
} from './contentGenerator';
import { LLMClient } from './llmClient';
import { PersonalityConfig } from './personality';
// ============================================
// TEST SETUP
// ============================================
// Store original env value to restore after tests
const originalEncryptionKey = process.env.BOT_ENCRYPTION_KEY;
// Generate a valid 32-byte encryption key for testing (base64 encoded)
const TEST_ENCRYPTION_KEY = Buffer.from(
'test-encryption-key-32-bytes!!!!'.slice(0, 32)
).toString('base64');
beforeAll(() => {
// Set up test encryption key
process.env.BOT_ENCRYPTION_KEY = TEST_ENCRYPTION_KEY;
});
afterAll(() => {
// Restore original encryption key
if (originalEncryptionKey !== undefined) {
process.env.BOT_ENCRYPTION_KEY = originalEncryptionKey;
} else {
delete process.env.BOT_ENCRYPTION_KEY;
}
});
// ============================================
// TEST DATA
// ============================================
const createTestPersonality = (): PersonalityConfig => ({
systemPrompt: 'You are a helpful tech news bot that shares interesting technology updates.',
temperature: 0.7,
maxTokens: 500,
responseStyle: 'professional',
});
const createTestBot = (): Bot => ({
id: 'bot-123',
name: 'TechBot',
handle: 'techbot',
personalityConfig: createTestPersonality(),
llmProvider: 'openai',
llmModel: 'gpt-4',
llmApiKeyEncrypted: 'test-api-key-12345678901234567890',
});
const createTestContentItem = (): ContentItem => ({
id: 'content-123',
sourceId: 'source-456',
title: 'New AI Breakthrough in Natural Language Processing',
content: 'Researchers have developed a new AI model that significantly improves natural language understanding. The model uses a novel architecture that combines transformer networks with memory systems.',
url: 'https://example.com/ai-breakthrough',
publishedAt: new Date('2024-01-15T10:00:00Z'),
});
const createTestPost = (overrides?: Partial<Post>): Post => ({
id: 'post-123',
userId: 'user-456',
content: 'Hey @techbot, what do you think about the new AI developments?',
createdAt: new Date('2024-01-15T12:00:00Z'),
author: {
handle: 'curious_user',
displayName: 'Curious User',
},
...overrides,
});
// ============================================
// CONTENT TRUNCATION TESTS
// ============================================
describe('Content Truncation', () => {
describe('truncateContent', () => {
it('returns original content if under max length', () => {
const content = 'Short content that does not need truncation.';
const result = truncateContent(content);
expect(result).toBe(content);
expect(isContentTruncated(result)).toBe(false);
});
it('returns empty string for null/undefined content', () => {
expect(truncateContent('')).toBe('');
expect(truncateContent(null as unknown as string)).toBe('');
});
it('truncates content at sentence boundary when possible', () => {
const content = 'First sentence. Second sentence. Third sentence that is very long and would exceed the limit if we included it all.';
const result = truncateContent(content, 50);
expect(result).toContain('First sentence.');
expect(result).toContain(TRUNCATION_SUFFIX);
expect(result.length).toBeLessThanOrEqual(50);
});
it('truncates content at word boundary when no sentence boundary', () => {
const content = 'This is a long sentence without any periods that needs to be truncated at a word boundary';
const result = truncateContent(content, 50);
expect(result).toContain(TRUNCATION_SUFFIX);
expect(result.length).toBeLessThanOrEqual(50);
// Should not cut in the middle of a word (check that the character before the suffix is a space or punctuation)
const beforeSuffix = result.slice(0, result.indexOf(TRUNCATION_SUFFIX));
const lastChar = beforeSuffix.trim().slice(-1);
// Last character should be a letter or punctuation, not in the middle of a word
expect(beforeSuffix.endsWith(' ') || /[a-zA-Z]$/.test(beforeSuffix.trim())).toBe(true);
});
it('hard truncates when no good boundary found', () => {
const content = 'Verylongwordwithoutanyspacesorpunctuationthatneedstobetruncated';
const result = truncateContent(content, 30);
expect(result).toContain(TRUNCATION_SUFFIX);
expect(result.length).toBeLessThanOrEqual(30);
});
it('respects custom max length', () => {
const content = 'A'.repeat(1000);
const result = truncateContent(content, 100);
expect(result.length).toBeLessThanOrEqual(100);
expect(isContentTruncated(result)).toBe(true);
});
it('handles content exactly at max length', () => {
const content = 'A'.repeat(MAX_SOURCE_CONTENT_LENGTH);
const result = truncateContent(content);
expect(result).toBe(content);
expect(isContentTruncated(result)).toBe(false);
});
it('handles content just over max length', () => {
const content = 'A'.repeat(MAX_SOURCE_CONTENT_LENGTH + 1);
const result = truncateContent(content);
expect(result.length).toBeLessThanOrEqual(MAX_SOURCE_CONTENT_LENGTH);
expect(isContentTruncated(result)).toBe(true);
});
});
describe('isContentTruncated', () => {
it('returns true for truncated content', () => {
const truncated = 'Some content' + TRUNCATION_SUFFIX;
expect(isContentTruncated(truncated)).toBe(true);
});
it('returns false for non-truncated content', () => {
const content = 'Some content without truncation';
expect(isContentTruncated(content)).toBe(false);
});
});
});
// ============================================
// PROMPT BUILDING TESTS
// ============================================
describe('Prompt Building', () => {
const personality = createTestPersonality();
describe('buildPostSystemPrompt', () => {
it('includes personality system prompt', () => {
const prompt = buildPostSystemPrompt(personality);
expect(prompt).toContain(personality.systemPrompt);
});
it('includes response style when provided', () => {
const prompt = buildPostSystemPrompt(personality);
expect(prompt).toContain('Response Style: professional');
});
it('includes post creation instructions', () => {
const prompt = buildPostSystemPrompt(personality);
expect(prompt).toContain('Instructions for creating posts');
expect(prompt).toContain('engaging');
});
it('handles personality without response style', () => {
const personalityNoStyle: PersonalityConfig = {
systemPrompt: 'Test prompt',
temperature: 0.7,
maxTokens: 500,
};
const prompt = buildPostSystemPrompt(personalityNoStyle);
expect(prompt).toContain('Test prompt');
expect(prompt).not.toContain('Response Style:');
});
});
describe('buildReplySystemPrompt', () => {
it('includes personality system prompt', () => {
const prompt = buildReplySystemPrompt(personality);
expect(prompt).toContain(personality.systemPrompt);
});
it('includes reply instructions', () => {
const prompt = buildReplySystemPrompt(personality);
expect(prompt).toContain('Instructions for replying');
expect(prompt).toContain('conversational');
});
});
describe('buildEvaluationSystemPrompt', () => {
it('includes personality system prompt', () => {
const prompt = buildEvaluationSystemPrompt(personality);
expect(prompt).toContain(personality.systemPrompt);
});
it('includes evaluation instructions', () => {
const prompt = buildEvaluationSystemPrompt(personality);
expect(prompt).toContain('evaluating');
expect(prompt).toContain('interesting');
expect(prompt).toContain('JSON');
});
});
describe('buildPostUserMessage', () => {
it('builds message with source content', () => {
const content = createTestContentItem();
const message = buildPostUserMessage(content);
expect(message).toContain(content.title);
expect(message).toContain(content.url);
expect(message).toContain(content.content!);
});
it('builds message without source content', () => {
const message = buildPostUserMessage();
expect(message).toContain('Create an engaging post');
});
it('includes additional context when provided', () => {
const content = createTestContentItem();
const context = 'This is breaking news';
const message = buildPostUserMessage(content, context);
expect(message).toContain(context);
});
it('handles content with null content field', () => {
const content: ContentItem = {
...createTestContentItem(),
content: null,
};
const message = buildPostUserMessage(content);
expect(message).toContain(content.title);
expect(message).toContain(content.url);
});
it('truncates long source content', () => {
const longContent: ContentItem = {
...createTestContentItem(),
content: 'A'.repeat(MAX_SOURCE_CONTENT_LENGTH + 1000),
};
const message = buildPostUserMessage(longContent);
expect(message).toContain(TRUNCATION_SUFFIX);
});
});
describe('buildReplyUserMessage', () => {
it('builds message with mention post', () => {
const post = createTestPost();
const message = buildReplyUserMessage(post, []);
expect(message).toContain(post.content);
expect(message).toContain('@curious_user');
});
it('includes conversation context', () => {
const mentionPost = createTestPost();
const contextPosts: Post[] = [
createTestPost({
id: 'post-1',
content: 'First message in conversation',
author: { handle: 'user1' },
}),
createTestPost({
id: 'post-2',
content: 'Second message in conversation',
author: { handle: 'user2' },
}),
];
const message = buildReplyUserMessage(mentionPost, contextPosts);
expect(message).toContain('Conversation context');
expect(message).toContain('First message');
expect(message).toContain('Second message');
});
it('handles post without author info', () => {
const post: Post = {
id: 'post-123',
userId: 'user-456',
content: 'Test content',
createdAt: new Date(),
};
const message = buildReplyUserMessage(post, []);
expect(message).toContain('@unknown');
});
});
describe('buildEvaluationUserMessage', () => {
it('builds evaluation message with content', () => {
const content = createTestContentItem();
const message = buildEvaluationUserMessage(content);
expect(message).toContain(content.title);
expect(message).toContain(content.url);
expect(message).toContain('Evaluate');
expect(message).toContain('JSON');
});
});
});
// ============================================
// RESPONSE PARSING TESTS
// ============================================
describe('Response Parsing', () => {
describe('parseInterestResponse', () => {
it('parses valid JSON response with interesting=true', () => {
const response = '{"interesting": true, "reason": "This is relevant to tech"}';
const result = parseInterestResponse(response);
expect(result.interesting).toBe(true);
expect(result.reason).toBe('This is relevant to tech');
});
it('parses valid JSON response with interesting=false', () => {
const response = '{"interesting": false, "reason": "Not relevant to our audience"}';
const result = parseInterestResponse(response);
expect(result.interesting).toBe(false);
expect(result.reason).toBe('Not relevant to our audience');
});
it('parses JSON wrapped in markdown code blocks', () => {
const response = '```json\n{"interesting": true, "reason": "Great content"}\n```';
const result = parseInterestResponse(response);
expect(result.interesting).toBe(true);
expect(result.reason).toBe('Great content');
});
it('handles alternative property names', () => {
const response = '{"isInteresting": true, "explanation": "Good stuff"}';
const result = parseInterestResponse(response);
expect(result.interesting).toBe(true);
expect(result.reason).toBe('Good stuff');
});
it('falls back to text analysis for non-JSON response', () => {
const response = 'Yes, this content is interesting and relevant to share with followers.';
const result = parseInterestResponse(response);
expect(result.interesting).toBe(true);
});
it('detects negative response from text', () => {
const response = 'No, this content is not interesting and should be skipped.';
const result = parseInterestResponse(response);
expect(result.interesting).toBe(false);
});
it('handles malformed JSON gracefully', () => {
const response = '{interesting: true, reason: missing quotes}';
const result = parseInterestResponse(response);
// Should fall back to text analysis
expect(typeof result.interesting).toBe('boolean');
expect(typeof result.reason).toBe('string');
});
});
});
// ============================================
// CONTENT GENERATOR CLASS TESTS
// ============================================
describe('ContentGenerator', () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
global.fetch = originalFetch;
vi.useRealTimers();
});
describe('constructor', () => {
it('creates generator with bot configuration', () => {
const bot = createTestBot();
const generator = new ContentGenerator(bot);
expect(generator.getBot()).toBe(bot);
expect(generator.getLLMClient()).toBeInstanceOf(LLMClient);
});
it('accepts custom LLM client', () => {
const bot = createTestBot();
const customClient = new LLMClient({
provider: 'anthropic',
apiKey: 'custom-key',
model: 'claude-3',
});
const generator = new ContentGenerator(bot, customClient);
expect(generator.getLLMClient()).toBe(customClient);
});
});
describe('generatePost', () => {
it('generates post with source content', async () => {
const mockResponse = {
choices: [
{
message: {
content: 'Exciting news in AI! A new breakthrough in NLP is changing how we interact with machines. Check it out: https://example.com/ai-breakthrough',
},
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
},
model: 'gpt-4',
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const bot = createTestBot();
const generator = new ContentGenerator(bot);
const content = createTestContentItem();
const result = await generator.generatePost(content);
expect(result.text).toContain('AI');
expect(result.tokensUsed).toBe(150);
expect(result.model).toBe('gpt-4');
});
it('generates post without source content', async () => {
const mockResponse = {
choices: [
{
message: {
content: 'Hello followers! Here is an update from your friendly tech bot.',
},
},
],
usage: {
prompt_tokens: 50,
completion_tokens: 20,
total_tokens: 70,
},
model: 'gpt-4',
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const bot = createTestBot();
const generator = new ContentGenerator(bot);
const result = await generator.generatePost();
expect(result.text).toBeTruthy();
expect(result.tokensUsed).toBe(70);
});
it('includes personality in LLM request', async () => {
let capturedBody: string | undefined;
global.fetch = vi.fn().mockImplementation((_url, options) => {
capturedBody = options?.body as string;
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'Test response' } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}),
});
});
const bot = createTestBot();
const generator = new ContentGenerator(bot);
await generator.generatePost();
expect(capturedBody).toBeDefined();
const body = JSON.parse(capturedBody!);
// System message should contain personality
const systemMessage = body.messages.find((m: { role: string }) => m.role === 'system');
expect(systemMessage.content).toContain(bot.personalityConfig.systemPrompt);
// Temperature should match personality config
expect(body.temperature).toBe(bot.personalityConfig.temperature);
});
it('throws ContentGeneratorError on LLM failure', async () => {
// Don't use fake timers for this test as it interferes with retry logic
vi.useRealTimers();
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Internal server error' }),
});
const bot = createTestBot();
// Create LLM client with no retries to avoid timeout
const llmClient = new LLMClient(
{
provider: bot.llmProvider,
apiKey: bot.llmApiKeyEncrypted,
model: bot.llmModel,
},
{ maxRetries: 0, initialDelayMs: 0, maxDelayMs: 0, backoffMultiplier: 1 }
);
const generator = new ContentGenerator(bot, llmClient);
await expect(generator.generatePost()).rejects.toThrow(ContentGeneratorError);
// Restore fake timers for other tests
vi.useFakeTimers({ shouldAdvanceTime: true });
});
});
describe('generateReply', () => {
it('generates reply to mention', async () => {
const mockResponse = {
choices: [
{
message: {
content: 'Great question! The recent AI developments are fascinating. I think we are seeing a major shift in how NLP models work.',
},
},
],
usage: {
prompt_tokens: 80,
completion_tokens: 40,
total_tokens: 120,
},
model: 'gpt-4',
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const bot = createTestBot();
const generator = new ContentGenerator(bot);
const mentionPost = createTestPost();
const result = await generator.generateReply(mentionPost, []);
expect(result.text).toContain('AI');
expect(result.tokensUsed).toBe(120);
});
it('includes conversation context in reply', async () => {
let capturedBody: string | undefined;
global.fetch = vi.fn().mockImplementation((_url, options) => {
capturedBody = options?.body as string;
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'Reply with context' } }],
usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 },
}),
});
});
const bot = createTestBot();
const generator = new ContentGenerator(bot);
const mentionPost = createTestPost();
const contextPosts: Post[] = [
createTestPost({
id: 'context-1',
content: 'Previous message in thread',
author: { handle: 'other_user' },
}),
];
await generator.generateReply(mentionPost, contextPosts);
expect(capturedBody).toBeDefined();
const body = JSON.parse(capturedBody!);
// User message should contain conversation context
const userMessage = body.messages.find((m: { role: string }) => m.role === 'user');
expect(userMessage.content).toContain('Previous message');
});
});
describe('evaluateContentInterest', () => {
it('evaluates content as interesting', async () => {
const mockResponse = {
choices: [
{
message: {
content: '{"interesting": true, "reason": "This AI breakthrough is highly relevant to our tech-focused audience."}',
},
},
],
usage: {
prompt_tokens: 60,
completion_tokens: 30,
total_tokens: 90,
},
model: 'gpt-4',
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const bot = createTestBot();
const generator = new ContentGenerator(bot);
const content = createTestContentItem();
const result = await generator.evaluateContentInterest(content);
expect(result.interesting).toBe(true);
expect(result.reason).toContain('relevant');
});
it('evaluates content as not interesting', async () => {
const mockResponse = {
choices: [
{
message: {
content: '{"interesting": false, "reason": "This content is not relevant to technology."}',
},
},
],
usage: {
prompt_tokens: 60,
completion_tokens: 25,
total_tokens: 85,
},
model: 'gpt-4',
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const bot = createTestBot();
const generator = new ContentGenerator(bot);
const content: ContentItem = {
...createTestContentItem(),
title: 'Celebrity Gossip News',
content: 'Latest celebrity news and gossip...',
};
const result = await generator.evaluateContentInterest(content);
expect(result.interesting).toBe(false);
});
it('uses lower temperature for evaluation', async () => {
let capturedBody: string | undefined;
global.fetch = vi.fn().mockImplementation((_url, options) => {
capturedBody = options?.body as string;
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: '{"interesting": true, "reason": "test"}' } }],
usage: { prompt_tokens: 50, completion_tokens: 10, total_tokens: 60 },
}),
});
});
const bot = createTestBot();
const generator = new ContentGenerator(bot);
const content = createTestContentItem();
await generator.evaluateContentInterest(content);
expect(capturedBody).toBeDefined();
const body = JSON.parse(capturedBody!);
// Should use lower temperature for consistent evaluation
expect(body.temperature).toBe(0.3);
});
});
});
// ============================================
// FACTORY FUNCTION TESTS
// ============================================
describe('Factory Functions', () => {
describe('createContentGenerator', () => {
it('creates generator from bot config', () => {
const bot = createTestBot();
const generator = createContentGenerator(bot);
expect(generator).toBeInstanceOf(ContentGenerator);
expect(generator.getBot()).toBe(bot);
});
});
describe('createContentGeneratorWithClient', () => {
it('creates generator with custom client', () => {
const bot = createTestBot();
const client = new LLMClient({
provider: 'anthropic',
apiKey: 'test-key',
model: 'claude-3',
});
const generator = createContentGeneratorWithClient(bot, client);
expect(generator).toBeInstanceOf(ContentGenerator);
expect(generator.getLLMClient()).toBe(client);
});
});
});
// ============================================
// ERROR HANDLING TESTS
// ============================================
describe('Error Handling', () => {
describe('ContentGeneratorError', () => {
it('creates error with all properties', () => {
const cause = new Error('Original error');
const error = new ContentGeneratorError(
'Generation failed',
'LLM_ERROR',
cause
);
expect(error.message).toBe('Generation failed');
expect(error.code).toBe('LLM_ERROR');
expect(error.cause).toBe(cause);
expect(error.name).toBe('ContentGeneratorError');
});
it('is instanceof Error', () => {
const error = new ContentGeneratorError('Test', 'GENERATION_FAILED');
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(ContentGeneratorError);
});
});
});
+655
View File
@@ -0,0 +1,655 @@
/**
* Content Generator Module
*
* Generates posts and replies using LLM providers with personality context.
* Handles content truncation for long sources and evaluates content interest
* for autonomous posting decisions.
*
* Requirements: 3.2, 3.5, 6.2, 11.1, 11.2, 11.3
*/
import { LLMClient, LLMMessage, LLMCompletionRequest } from './llmClient';
import { PersonalityConfig, buildPromptWithPersonality } from './personality';
import type { LLMProvider } from './encryption';
// ============================================
// TYPES
// ============================================
/**
* Bot data required for content generation.
*/
export interface Bot {
id: string;
name: string;
handle: string;
personalityConfig: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
llmApiKeyEncrypted: string;
}
/**
* Content item from external sources.
*/
export interface ContentItem {
id: string;
sourceId: string;
title: string;
content: string | null;
url: string;
publishedAt: Date;
}
/**
* Post data for reply context.
*/
export interface Post {
id: string;
userId: string;
content: string;
createdAt: Date;
author?: {
handle: string;
displayName?: string | null;
};
}
/**
* Generated content result.
*/
export interface GeneratedContent {
text: string;
tokensUsed: number;
model: string;
}
/**
* Content interest evaluation result.
*/
export interface ContentInterestResult {
interesting: boolean;
reason: string;
}
/**
* Error thrown by content generator operations.
*/
export class ContentGeneratorError extends Error {
constructor(
message: string,
public code: ContentGeneratorErrorCode,
public cause?: Error
) {
super(message);
this.name = 'ContentGeneratorError';
}
}
export type ContentGeneratorErrorCode =
| 'LLM_ERROR'
| 'INVALID_BOT'
| 'INVALID_CONTENT'
| 'GENERATION_FAILED'
| 'EVALUATION_FAILED';
// ============================================
// CONSTANTS
// ============================================
/**
* Maximum character length for source content before truncation.
* Validates: Requirements 11.3
*/
export const MAX_SOURCE_CONTENT_LENGTH = 4000;
/**
* Maximum character length for conversation context.
*/
export const MAX_CONVERSATION_CONTEXT_LENGTH = 2000;
/**
* Truncation suffix added to truncated content.
*/
export const TRUNCATION_SUFFIX = '... [content truncated]';
/**
* Default max tokens for post generation.
*/
export const DEFAULT_POST_MAX_TOKENS = 500;
/**
* Default max tokens for reply generation.
*/
export const DEFAULT_REPLY_MAX_TOKENS = 300;
/**
* Default max tokens for interest evaluation.
*/
export const DEFAULT_EVALUATION_MAX_TOKENS = 150;
// ============================================
// CONTENT TRUNCATION
// ============================================
/**
* Truncate content to a maximum length.
* Attempts to truncate at sentence or word boundaries when possible.
*
* @param content - The content to truncate
* @param maxLength - Maximum length (default: MAX_SOURCE_CONTENT_LENGTH)
* @returns Truncated content with suffix if truncated
*
* Validates: Requirements 11.3
*/
export function truncateContent(
content: string,
maxLength: number = MAX_SOURCE_CONTENT_LENGTH
): string {
if (!content || content.length <= maxLength) {
return content || '';
}
// Account for truncation suffix length
const targetLength = maxLength - TRUNCATION_SUFFIX.length;
if (targetLength <= 0) {
return TRUNCATION_SUFFIX;
}
// Try to find a sentence boundary (., !, ?)
const sentenceEnd = findLastBoundary(content, targetLength, /[.!?]\s/g);
if (sentenceEnd > targetLength * 0.5) {
return content.slice(0, sentenceEnd + 1).trim() + TRUNCATION_SUFFIX;
}
// Try to find a word boundary
const wordEnd = findLastBoundary(content, targetLength, /\s/g);
if (wordEnd > targetLength * 0.5) {
return content.slice(0, wordEnd).trim() + TRUNCATION_SUFFIX;
}
// Hard truncate if no good boundary found
return content.slice(0, targetLength).trim() + TRUNCATION_SUFFIX;
}
/**
* Find the last occurrence of a pattern before a given position.
*
* @param text - Text to search
* @param maxPos - Maximum position to search up to
* @param pattern - Regex pattern to find
* @returns Position of last match, or -1 if not found
*/
function findLastBoundary(text: string, maxPos: number, pattern: RegExp): number {
let lastPos = -1;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index >= maxPos) break;
lastPos = match.index;
}
return lastPos;
}
/**
* Check if content was truncated.
*
* @param content - Content to check
* @returns True if content ends with truncation suffix
*/
export function isContentTruncated(content: string): boolean {
return content.endsWith(TRUNCATION_SUFFIX);
}
// ============================================
// PROMPT BUILDING
// ============================================
/**
* Build a system prompt for post generation.
* Includes personality context and instructions.
*
* @param personality - Bot's personality configuration
* @returns System prompt string
*
* Validates: Requirements 3.2, 11.1
*/
export function buildPostSystemPrompt(personality: PersonalityConfig): string {
let prompt = personality.systemPrompt;
if (personality.responseStyle) {
prompt += `\n\nResponse Style: ${personality.responseStyle}`;
}
prompt += `\n\nIMPORTANT: Your posts MUST be under 450 characters (not including the URL). This leaves room for the source link. This is a strict limit.
Instructions for creating posts:
- Create engaging, original content based on the source material
- Add your own perspective or commentary
- Keep the text concise - aim for 200-400 characters
- ALWAYS include the source URL at the end of your post
- Do not simply copy or summarize - add value with your unique voice
- Do NOT use hashtags
- Write like a human, not a marketing bot
- Format: [Your commentary] [URL]`;
return prompt;
}
/**
* Build a system prompt for reply generation.
* Includes personality context and reply-specific instructions.
*
* @param personality - Bot's personality configuration
* @returns System prompt string
*
* Validates: Requirements 3.5
*/
export function buildReplySystemPrompt(personality: PersonalityConfig): string {
let prompt = personality.systemPrompt;
if (personality.responseStyle) {
prompt += `\n\nResponse Style: ${personality.responseStyle}`;
}
prompt += `\n\nInstructions for replying:
- Respond directly to what the user said
- Be conversational and engaging
- Stay in character with your personality
- Keep replies concise and relevant
- Be respectful and constructive`;
return prompt;
}
/**
* Build a system prompt for content interest evaluation.
*
* @param personality - Bot's personality configuration
* @returns System prompt string
*
* Validates: Requirements 6.2
*/
export function buildEvaluationSystemPrompt(personality: PersonalityConfig): string {
let prompt = personality.systemPrompt;
prompt += `\n\nYou are evaluating whether content is interesting enough to share with your followers.
Consider:
- Is this content relevant to your interests and expertise?
- Would your followers find this valuable or engaging?
- Is this timely or newsworthy?
- Does this align with your personality and posting style?
Respond with a JSON object containing:
- "interesting": true or false
- "reason": a brief explanation of your decision`;
return prompt;
}
/**
* Build user message for post generation from source content.
*
* @param sourceContent - Source content to post about
* @param context - Optional additional context
* @returns User message string
*/
export function buildPostUserMessage(
sourceContent?: ContentItem,
context?: string
): string {
if (!sourceContent) {
if (context) {
return `Create a post about the following:\n\n${context}`;
}
return 'Create an engaging post for your followers.';
}
const truncatedContent = truncateContent(sourceContent.content || '');
let message = `Create a post about the following content:\n\n`;
message += `Title: ${sourceContent.title}\n`;
message += `URL: ${sourceContent.url}\n`;
if (truncatedContent) {
message += `\nContent:\n${truncatedContent}`;
}
if (context) {
message += `\n\nAdditional context: ${context}`;
}
return message;
}
/**
* Build user message for reply generation.
*
* @param mentionPost - The post that mentioned the bot
* @param conversationContext - Previous posts in the conversation
* @returns User message string
*/
export function buildReplyUserMessage(
mentionPost: Post,
conversationContext: Post[]
): string {
let message = '';
// Add conversation context if available
if (conversationContext.length > 0) {
message += 'Conversation context:\n';
// Truncate conversation context if too long
let contextLength = 0;
const relevantContext: string[] = [];
// Process in reverse to get most recent context first
for (let i = conversationContext.length - 1; i >= 0; i--) {
const post = conversationContext[i];
const authorHandle = post.author?.handle || 'unknown';
const postText = `@${authorHandle}: ${post.content}`;
if (contextLength + postText.length > MAX_CONVERSATION_CONTEXT_LENGTH) {
break;
}
relevantContext.unshift(postText);
contextLength += postText.length;
}
message += relevantContext.join('\n');
message += '\n\n';
}
// Add the mention post
const authorHandle = mentionPost.author?.handle || 'unknown';
message += `Reply to this post from @${authorHandle}:\n`;
message += mentionPost.content;
return message;
}
/**
* Build user message for content interest evaluation.
*
* @param content - Content to evaluate
* @returns User message string
*/
export function buildEvaluationUserMessage(content: ContentItem): string {
const truncatedContent = truncateContent(content.content || '', 2000);
let message = `Evaluate whether you should share this content:\n\n`;
message += `Title: ${content.title}\n`;
message += `URL: ${content.url}\n`;
if (truncatedContent) {
message += `\nContent:\n${truncatedContent}`;
}
message += `\n\nRespond with JSON: {"interesting": true/false, "reason": "your explanation"}`;
return message;
}
// ============================================
// CONTENT GENERATOR CLASS
// ============================================
/**
* Content Generator for creating posts and replies using LLM.
*
* Validates: Requirements 3.2, 3.5, 6.2, 11.1, 11.2, 11.3
*/
export class ContentGenerator {
private llmClient: LLMClient;
private bot: Bot;
/**
* Create a new content generator for a bot.
*
* @param bot - Bot configuration
* @param llmClient - Optional LLM client (created from bot config if not provided)
*/
constructor(bot: Bot, llmClient?: LLMClient) {
this.bot = bot;
this.llmClient = llmClient || new LLMClient({
provider: bot.llmProvider,
apiKey: bot.llmApiKeyEncrypted,
model: bot.llmModel,
});
}
/**
* Generate a post with personality context.
*
* @param sourceContent - Optional source content to post about
* @param context - Optional additional context
* @returns Generated content with token usage
*
* Validates: Requirements 3.2, 11.1, 11.2, 11.3
*/
async generatePost(
sourceContent?: ContentItem,
context?: string
): Promise<GeneratedContent> {
const systemPrompt = buildPostSystemPrompt(this.bot.personalityConfig);
const userMessage = buildPostUserMessage(sourceContent, context);
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
];
const request: LLMCompletionRequest = {
messages,
temperature: this.bot.personalityConfig.temperature,
maxTokens: this.bot.personalityConfig.maxTokens || DEFAULT_POST_MAX_TOKENS,
};
try {
const response = await this.llmClient.generateCompletion(request);
return {
text: response.content.trim(),
tokensUsed: response.tokensUsed.total,
model: response.model,
};
} catch (error) {
throw new ContentGeneratorError(
`Failed to generate post: ${error instanceof Error ? error.message : String(error)}`,
'LLM_ERROR',
error instanceof Error ? error : undefined
);
}
}
/**
* Generate a reply with conversation context.
*
* @param mentionPost - The post that mentioned the bot
* @param conversationContext - Previous posts in the conversation
* @returns Generated content with token usage
*
* Validates: Requirements 3.5
*/
async generateReply(
mentionPost: Post,
conversationContext: Post[] = []
): Promise<GeneratedContent> {
const systemPrompt = buildReplySystemPrompt(this.bot.personalityConfig);
const userMessage = buildReplyUserMessage(mentionPost, conversationContext);
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
];
const request: LLMCompletionRequest = {
messages,
temperature: this.bot.personalityConfig.temperature,
maxTokens: Math.min(
this.bot.personalityConfig.maxTokens || DEFAULT_REPLY_MAX_TOKENS,
DEFAULT_REPLY_MAX_TOKENS
),
};
try {
const response = await this.llmClient.generateCompletion(request);
return {
text: response.content.trim(),
tokensUsed: response.tokensUsed.total,
model: response.model,
};
} catch (error) {
throw new ContentGeneratorError(
`Failed to generate reply: ${error instanceof Error ? error.message : String(error)}`,
'LLM_ERROR',
error instanceof Error ? error : undefined
);
}
}
/**
* Evaluate whether content is interesting enough to post about.
* Used for autonomous posting decisions.
*
* @param content - Content to evaluate
* @returns Interest evaluation result
*
* Validates: Requirements 6.2
*/
async evaluateContentInterest(
content: ContentItem
): Promise<ContentInterestResult> {
const systemPrompt = buildEvaluationSystemPrompt(this.bot.personalityConfig);
const userMessage = buildEvaluationUserMessage(content);
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
];
const request: LLMCompletionRequest = {
messages,
temperature: 0.3, // Lower temperature for more consistent evaluation
maxTokens: DEFAULT_EVALUATION_MAX_TOKENS,
};
try {
const response = await this.llmClient.generateCompletion(request);
// Parse the JSON response
const result = parseInterestResponse(response.content);
return result;
} catch (error) {
throw new ContentGeneratorError(
`Failed to evaluate content interest: ${error instanceof Error ? error.message : String(error)}`,
'EVALUATION_FAILED',
error instanceof Error ? error : undefined
);
}
}
/**
* Get the bot associated with this generator.
*/
getBot(): Bot {
return this.bot;
}
/**
* Get the LLM client used by this generator.
*/
getLLMClient(): LLMClient {
return this.llmClient;
}
}
// ============================================
// RESPONSE PARSING
// ============================================
/**
* Parse the interest evaluation response from LLM.
* Handles various response formats and extracts the boolean result.
*
* @param response - Raw LLM response
* @returns Parsed interest result
*/
export function parseInterestResponse(response: string): ContentInterestResult {
// Try to parse as JSON first
try {
// Extract JSON from response (may be wrapped in markdown code blocks)
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
// Handle various property names
const interesting = parsed.interesting ?? parsed.isInteresting ?? parsed.interest ?? false;
const reason = parsed.reason ?? parsed.explanation ?? parsed.rationale ?? 'No reason provided';
return {
interesting: Boolean(interesting),
reason: String(reason),
};
}
} catch {
// JSON parsing failed, try text analysis
}
// Fallback: analyze text response
const lowerResponse = response.toLowerCase();
// Look for clear indicators
const positiveIndicators = ['yes', 'true', 'interesting', 'share', 'post', 'relevant'];
const negativeIndicators = ['no', 'false', 'not interesting', 'skip', 'irrelevant'];
let positiveScore = 0;
let negativeScore = 0;
for (const indicator of positiveIndicators) {
if (lowerResponse.includes(indicator)) positiveScore++;
}
for (const indicator of negativeIndicators) {
if (lowerResponse.includes(indicator)) negativeScore++;
}
return {
interesting: positiveScore > negativeScore,
reason: response.slice(0, 200), // Use first 200 chars as reason
};
}
// ============================================
// FACTORY FUNCTIONS
// ============================================
/**
* Create a content generator for a bot.
*
* @param bot - Bot configuration
* @returns Content generator instance
*/
export function createContentGenerator(bot: Bot): ContentGenerator {
return new ContentGenerator(bot);
}
/**
* Create a content generator with a custom LLM client.
* Useful for testing or custom configurations.
*
* @param bot - Bot configuration
* @param llmClient - Custom LLM client
* @returns Content generator instance
*/
export function createContentGeneratorWithClient(
bot: Bot,
llmClient: LLMClient
): ContentGenerator {
return new ContentGenerator(bot, llmClient);
}
+924
View File
@@ -0,0 +1,924 @@
/**
* Property-Based Tests for Content Source URL Validation
*
* Feature: bot-system, Property 11: Content Source URL Validation
*
* Tests the content source URL and type validation using fast-check
* for property-based testing.
*
* **Validates: Requirements 4.1**
*/
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import {
validateSourceUrl,
validateSourceType,
validateContentSourceConfig,
isSupportedSourceType,
isValidUrl,
ContentSourceType,
SUPPORTED_SOURCE_TYPES,
ContentSourceValidationResult,
} from './contentSource';
// ============================================
// GENERATORS
// ============================================
/**
* Generator for invalid URLs - strings that should NOT pass URL validation.
*
* Invalid URLs include:
* - Empty strings
* - Strings without protocol
* - Strings with invalid protocols (ftp, file, etc.)
* - Malformed URLs
* - Strings with spaces
* - Random garbage strings
*/
// Generator for empty or whitespace-only strings
const emptyOrWhitespaceArb = fc.oneof(
fc.constant(''),
fc.constant(' '),
fc.constant('\t\n'),
fc.constant(' \t '),
fc.constant('\n\r\t'),
fc.constant(' ')
);
// Generator for URLs without protocol
const noProtocolUrlArb = fc.oneof(
fc.constant('example.com'),
fc.constant('www.example.com/path'),
fc.constant('reddit.com/r/test'),
fc.stringMatching(/^[a-z0-9]+\.[a-z]{2,4}(\/[a-z0-9]+)?$/)
);
// Generator for URLs with invalid protocols
const invalidProtocolUrlArb = fc.oneof(
fc.constant('ftp://example.com'),
fc.constant('file:///path/to/file'),
fc.constant('mailto:test@example.com'),
fc.constant('javascript:alert(1)'),
fc.constant('data:text/html,<h1>test</h1>'),
fc.stringMatching(/^[a-z]{2,10}:\/\/[a-z0-9]+\.[a-z]{2,4}$/)
.filter(s => !s.startsWith('http://') && !s.startsWith('https://'))
);
// Generator for malformed URLs (invalid characters, structure)
// Note: Only include URLs that will actually fail URL parsing
const malformedUrlArb = fc.oneof(
fc.constant('http://'),
fc.constant('https://'),
fc.constant('http://example .com'), // Space in hostname
fc.constant('http://exam ple.com/path'), // Space in hostname
fc.constant('http://[invalid'), // Invalid IPv6
fc.constant('http://example.com:abc'), // Invalid port
fc.constant('http://user:pass@'), // Missing host after auth
fc.constant('://example.com'), // Missing protocol
);
// Generator for URLs that are too long (> 2048 characters)
const tooLongUrlArb = fc.stringMatching(/^[a-z0-9]{2100,2500}$/)
.map(s => `https://example.com/${s}`);
// Generator for random garbage strings (most will be invalid URLs)
const randomGarbageArb = fc.string({ minLength: 1, maxLength: 100 })
.filter(s => {
try {
new URL(s);
return false; // Filter out accidentally valid URLs
} catch {
return true;
}
});
// Combined generator for all invalid URLs
const invalidUrlArb = fc.oneof(
emptyOrWhitespaceArb,
noProtocolUrlArb,
invalidProtocolUrlArb,
malformedUrlArb,
randomGarbageArb
);
/**
* Generator for unsupported source types.
*
* Unsupported types include:
* - Empty strings
* - Random strings that aren't 'rss', 'reddit', or 'news_api'
* - Typos of valid types
* - Non-string values
*/
// Generator for unsupported type strings
const unsupportedTypeStringArb = fc.string({ minLength: 1, maxLength: 50 })
.filter(s => !SUPPORTED_SOURCE_TYPES.includes(s as ContentSourceType));
// Generator for typos of valid types
const typoTypeArb = fc.oneof(
fc.constant('RSS'),
fc.constant('Rss'),
fc.constant('rss '),
fc.constant(' rss'),
fc.constant('reddit '),
fc.constant('Reddit'),
fc.constant('REDDIT'),
fc.constant('news_API'),
fc.constant('newsapi'),
fc.constant('news-api'),
fc.constant('atom'),
fc.constant('twitter'),
fc.constant('facebook'),
fc.constant('api'),
fc.constant('feed'),
);
// Combined generator for unsupported types
const unsupportedTypeArb = fc.oneof(
unsupportedTypeStringArb,
typoTypeArb,
fc.constant(''),
);
/**
* Generator for valid URLs (for testing type-specific validation).
*/
const validHttpUrlArb = fc.oneof(
fc.constant('https://example.com'),
fc.constant('https://example.com/feed.xml'),
fc.constant('http://test.org/rss'),
fc.stringMatching(/^[a-z0-9]{3,20}$/).map(s => `https://${s}.com/feed`)
);
/**
* Generator for invalid Reddit URLs (valid HTTP URLs but not Reddit format).
*/
const invalidRedditUrlArb = fc.oneof(
fc.constant('https://example.com'),
fc.constant('https://twitter.com/user'),
fc.constant('https://reddit.com'), // Missing /r/subreddit
fc.constant('https://reddit.com/user/test'),
fc.constant('https://reddit.com/r/'), // Missing subreddit name
fc.constant('https://notreddit.com/r/test'),
validHttpUrlArb.filter(url => !url.includes('reddit.com/r/'))
);
/**
* Generator for invalid News API URLs (valid HTTP URLs but not API format).
*/
const invalidNewsApiUrlArb = fc.oneof(
fc.constant('https://example.com'),
fc.constant('https://news.google.com'),
fc.constant('https://cnn.com/news'),
fc.constant('http://bbc.com/feed'),
validHttpUrlArb.filter(url =>
!url.includes('newsapi.org') &&
!url.includes('gnews.io') &&
!url.includes('newsdata.io') &&
!url.includes('api.')
)
);
/**
* Generator for valid RSS URLs.
*/
const validRssUrlArb = fc.oneof(
fc.constant('https://example.com/feed.xml'),
fc.constant('https://blog.example.org/rss'),
fc.constant('http://news.site.com/feed'),
fc.stringMatching(/^[a-z0-9]{3,15}$/).map(s => `https://${s}.com/feed.xml`)
);
/**
* Generator for valid Reddit URLs.
*/
const validRedditUrlArb = fc.oneof(
fc.constant('https://reddit.com/r/programming'),
fc.constant('https://www.reddit.com/r/technology'),
fc.constant('https://old.reddit.com/r/news'),
fc.stringMatching(/^[a-zA-Z0-9_]{3,21}$/).map(s => `https://reddit.com/r/${s}`)
);
/**
* Generator for valid News API URLs.
*/
const validNewsApiUrlArb = fc.oneof(
fc.constant('https://newsapi.org/v2/everything'),
fc.constant('https://gnews.io/api/v4/search'),
fc.constant('https://api.newsdata.io/v1/news'),
fc.constant('https://api.example.com/news'),
);
/**
* Generator for supported source types.
*/
const supportedTypeArb = fc.constantFrom<ContentSourceType>('rss', 'reddit', 'news_api');
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 11: Content Source URL Validation', () => {
/**
* Property 11: Content Source URL Validation
*
* *For any* invalid URL or unsupported source type, adding a content source
* SHALL fail with a validation error.
*
* **Validates: Requirements 4.1**
*/
describe('Invalid URLs are rejected', () => {
it('rejects empty or whitespace-only URLs for all source types', () => {
fc.assert(
fc.property(emptyOrWhitespaceArb, supportedTypeArb, (url, type) => {
const errors = validateSourceUrl(url, type);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e =>
e.includes('required') ||
e.includes('empty') ||
e.includes('valid')
)).toBe(true);
}),
{ numRuns: 100 }
);
});
it('rejects URLs without HTTP/HTTPS protocol for all source types', () => {
fc.assert(
fc.property(noProtocolUrlArb, supportedTypeArb, (url, type) => {
const errors = validateSourceUrl(url, type);
expect(errors.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('rejects URLs with invalid protocols for all source types', () => {
fc.assert(
fc.property(invalidProtocolUrlArb, supportedTypeArb, (url, type) => {
const errors = validateSourceUrl(url, type);
expect(errors.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('rejects malformed URLs for all source types', () => {
fc.assert(
fc.property(malformedUrlArb, supportedTypeArb, (url, type) => {
const errors = validateSourceUrl(url, type);
expect(errors.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('rejects URLs that are too long (> 2048 characters)', () => {
fc.assert(
fc.property(tooLongUrlArb, supportedTypeArb, (url, type) => {
const errors = validateSourceUrl(url, type);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('too long'))).toBe(true);
}),
{ numRuns: 100 }
);
});
it('rejects random garbage strings as URLs', () => {
fc.assert(
fc.property(randomGarbageArb, supportedTypeArb, (url, type) => {
const errors = validateSourceUrl(url, type);
expect(errors.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
});
describe('Unsupported source types are rejected', () => {
it('rejects unsupported source type strings', () => {
fc.assert(
fc.property(unsupportedTypeStringArb, (type) => {
const errors = validateSourceType(type);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('Unsupported source type'))).toBe(true);
}),
{ numRuns: 100 }
);
});
it('rejects typos of valid source types', () => {
fc.assert(
fc.property(typoTypeArb, (type) => {
const errors = validateSourceType(type);
expect(errors.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('rejects empty string as source type', () => {
const errors = validateSourceType('');
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('required'))).toBe(true);
});
it('rejects null and undefined as source type', () => {
const nullErrors = validateSourceType(null);
const undefinedErrors = validateSourceType(undefined);
expect(nullErrors.length).toBeGreaterThan(0);
expect(undefinedErrors.length).toBeGreaterThan(0);
});
it('rejects non-string values as source type', () => {
const invalidTypes = [123, {}, [], true, false];
for (const invalidType of invalidTypes) {
const errors = validateSourceType(invalidType);
expect(errors.length).toBeGreaterThan(0);
}
});
});
describe('Type-specific URL validation', () => {
it('rejects non-Reddit URLs for Reddit source type', () => {
fc.assert(
fc.property(invalidRedditUrlArb, (url) => {
const errors = validateSourceUrl(url, 'reddit');
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('Reddit') || e.includes('subreddit'))).toBe(true);
}),
{ numRuns: 100 }
);
});
it('rejects non-API URLs for news_api source type', () => {
fc.assert(
fc.property(invalidNewsApiUrlArb, (url) => {
const errors = validateSourceUrl(url, 'news_api');
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('API') || e.includes('endpoint'))).toBe(true);
}),
{ numRuns: 100 }
);
});
});
describe('Complete configuration validation', () => {
it('rejects configurations with invalid URL and valid type', () => {
fc.assert(
fc.property(invalidUrlArb, supportedTypeArb, (url, type) => {
const config = { url, type };
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('rejects configurations with valid URL and invalid type', () => {
fc.assert(
fc.property(validHttpUrlArb, unsupportedTypeArb, (url, type) => {
const config = { url, type };
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('rejects configurations with both invalid URL and invalid type', () => {
fc.assert(
fc.property(invalidUrlArb, unsupportedTypeArb, (url, type) => {
const config = { url, type };
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('rejects non-object configurations', () => {
const invalidConfigs = [null, undefined, 'string', 123, [], true];
for (const config of invalidConfigs) {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
}
});
it('rejects configurations missing required fields', () => {
const incompleteConfigs = [
{},
{ url: 'https://example.com' }, // Missing type
{ type: 'rss' }, // Missing url
];
for (const config of incompleteConfigs) {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
}
});
});
describe('Valid configurations are accepted', () => {
it('accepts valid RSS configurations', () => {
fc.assert(
fc.property(validRssUrlArb, (url) => {
const config = { url, type: 'rss' as const };
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
}),
{ numRuns: 100 }
);
});
it('accepts valid Reddit configurations with subreddit', () => {
fc.assert(
fc.property(
validRedditUrlArb,
fc.stringMatching(/^[a-zA-Z0-9_]{3,21}$/),
(url, subreddit) => {
const config = { url, type: 'reddit' as const, subreddit };
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
}
),
{ numRuns: 100 }
);
});
it('accepts valid News API configurations with API key', () => {
fc.assert(
fc.property(
validNewsApiUrlArb,
fc.stringMatching(/^[a-zA-Z0-9]{10,100}$/),
(url, apiKey) => {
const config = { url, type: 'news_api' as const, apiKey };
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
}
),
{ numRuns: 100 }
);
});
});
describe('Helper function validation', () => {
it('isSupportedSourceType returns false for unsupported types', () => {
fc.assert(
fc.property(unsupportedTypeStringArb, (type) => {
expect(isSupportedSourceType(type)).toBe(false);
}),
{ numRuns: 100 }
);
});
it('isSupportedSourceType returns true for supported types', () => {
for (const type of SUPPORTED_SOURCE_TYPES) {
expect(isSupportedSourceType(type)).toBe(true);
}
});
it('isValidUrl returns false for invalid URLs', () => {
fc.assert(
fc.property(invalidUrlArb, (url) => {
expect(isValidUrl(url)).toBe(false);
}),
{ numRuns: 100 }
);
});
it('isValidUrl returns true for valid HTTP/HTTPS URLs', () => {
fc.assert(
fc.property(validHttpUrlArb, (url) => {
expect(isValidUrl(url)).toBe(true);
}),
{ numRuns: 100 }
);
});
it('isValidUrl returns false for non-string inputs', () => {
const invalidInputs = [null, undefined, 123, {}, [], true];
for (const input of invalidInputs) {
expect(isValidUrl(input as unknown as string)).toBe(false);
}
});
});
});
// ============================================
// PROPERTY 14: MULTIPLE SOURCE TYPES PER BOT
// ============================================
/**
* Property-Based Tests for Multiple Source Types Per Bot
*
* Feature: bot-system, Property 14: Multiple Source Types Per Bot
*
* Tests that bots can have content sources of different types (RSS, Reddit, news API)
* and all succeed and are retrievable.
*
* **Validates: Requirements 4.6**
*/
describe('Feature: bot-system, Property 14: Multiple Source Types Per Bot', () => {
/**
* Property 14: Multiple Source Types Per Bot
*
* *For any* bot, adding content sources of different types (RSS, Reddit, news API)
* SHALL all succeed and be retrievable.
*
* **Validates: Requirements 4.6**
*/
// ============================================
// GENERATORS FOR PROPERTY 14
// ============================================
/**
* Generator for valid RSS source configurations.
*/
const validRssConfigArb = fc.record({
type: fc.constant('rss' as const),
url: fc.oneof(
fc.constant('https://example.com/feed.xml'),
fc.constant('https://blog.example.org/rss'),
fc.constant('http://news.site.com/feed'),
fc.stringMatching(/^[a-z0-9]{3,15}$/).map(s => `https://${s}.com/feed.xml`)
),
fetchInterval: fc.option(fc.integer({ min: 5, max: 1440 }), { nil: undefined }),
keywords: fc.option(
fc.array(fc.stringMatching(/^[a-zA-Z0-9]{1,50}$/), { minLength: 0, maxLength: 5 }),
{ nil: undefined }
),
});
/**
* Generator for valid Reddit source configurations.
*/
const validRedditConfigArb = fc.record({
type: fc.constant('reddit' as const),
url: fc.oneof(
fc.constant('https://reddit.com/r/programming'),
fc.constant('https://www.reddit.com/r/technology'),
fc.constant('https://old.reddit.com/r/news'),
fc.stringMatching(/^[a-zA-Z0-9_]{3,21}$/).map(s => `https://reddit.com/r/${s}`)
),
subreddit: fc.stringMatching(/^[a-zA-Z0-9_]{3,21}$/),
fetchInterval: fc.option(fc.integer({ min: 5, max: 1440 }), { nil: undefined }),
keywords: fc.option(
fc.array(fc.stringMatching(/^[a-zA-Z0-9]{1,50}$/), { minLength: 0, maxLength: 5 }),
{ nil: undefined }
),
});
/**
* Generator for valid News API source configurations.
*/
const validNewsApiConfigArb = fc.record({
type: fc.constant('news_api' as const),
url: fc.oneof(
fc.constant('https://newsapi.org/v2/everything'),
fc.constant('https://gnews.io/api/v4/search'),
fc.constant('https://api.newsdata.io/v1/news'),
fc.constant('https://api.example.com/news')
),
apiKey: fc.stringMatching(/^[a-zA-Z0-9]{10,100}$/),
fetchInterval: fc.option(fc.integer({ min: 5, max: 1440 }), { nil: undefined }),
keywords: fc.option(
fc.array(fc.stringMatching(/^[a-zA-Z0-9]{1,50}$/), { minLength: 0, maxLength: 5 }),
{ nil: undefined }
),
});
/**
* Generator for a combination of all three source types.
*/
const allSourceTypesConfigArb = fc.tuple(
validRssConfigArb,
validRedditConfigArb,
validNewsApiConfigArb
);
/**
* Generator for a non-empty subset of source types.
*/
const sourceTypeSubsetArb = fc.oneof(
// Single types
fc.tuple(validRssConfigArb).map(configs => configs),
fc.tuple(validRedditConfigArb).map(configs => configs),
fc.tuple(validNewsApiConfigArb).map(configs => configs),
// Pairs
fc.tuple(validRssConfigArb, validRedditConfigArb).map(configs => configs),
fc.tuple(validRssConfigArb, validNewsApiConfigArb).map(configs => configs),
fc.tuple(validRedditConfigArb, validNewsApiConfigArb).map(configs => configs),
// All three
allSourceTypesConfigArb.map(configs => configs)
);
// ============================================
// PROPERTY TESTS
// ============================================
describe('All source types validate successfully', () => {
it('RSS configurations with valid URLs pass validation', () => {
fc.assert(
fc.property(validRssConfigArb, (config) => {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
}),
{ numRuns: 100 }
);
});
it('Reddit configurations with valid URLs and subreddits pass validation', () => {
fc.assert(
fc.property(validRedditConfigArb, (config) => {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
}),
{ numRuns: 100 }
);
});
it('News API configurations with valid URLs and API keys pass validation', () => {
fc.assert(
fc.property(validNewsApiConfigArb, (config) => {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
}),
{ numRuns: 100 }
);
});
});
describe('Multiple source types can coexist', () => {
it('all three source types (RSS, Reddit, news_api) can be validated independently', () => {
fc.assert(
fc.property(allSourceTypesConfigArb, ([rssConfig, redditConfig, newsApiConfig]) => {
// Validate each source type independently
const rssResult: ContentSourceValidationResult = validateContentSourceConfig(rssConfig);
const redditResult: ContentSourceValidationResult = validateContentSourceConfig(redditConfig);
const newsApiResult: ContentSourceValidationResult = validateContentSourceConfig(newsApiConfig);
// All should pass validation
expect(rssResult.valid).toBe(true);
expect(redditResult.valid).toBe(true);
expect(newsApiResult.valid).toBe(true);
// All should have no errors
expect(rssResult.errors.length).toBe(0);
expect(redditResult.errors.length).toBe(0);
expect(newsApiResult.errors.length).toBe(0);
// Each should have the correct type
expect(rssConfig.type).toBe('rss');
expect(redditConfig.type).toBe('reddit');
expect(newsApiConfig.type).toBe('news_api');
}),
{ numRuns: 100 }
);
});
it('any subset of source types can be validated together', () => {
fc.assert(
fc.property(sourceTypeSubsetArb, (configs) => {
// Validate all configs in the subset
const results = configs.map(config => ({
config,
result: validateContentSourceConfig(config) as ContentSourceValidationResult,
}));
// All should pass validation
for (const { config, result } of results) {
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
expect(SUPPORTED_SOURCE_TYPES).toContain(config.type);
}
// Verify we have at least one config
expect(configs.length).toBeGreaterThan(0);
expect(configs.length).toBeLessThanOrEqual(3);
}),
{ numRuns: 100 }
);
});
});
describe('Source type identification', () => {
it('each source type is correctly identified as supported', () => {
fc.assert(
fc.property(allSourceTypesConfigArb, ([rssConfig, redditConfig, newsApiConfig]) => {
// All types should be recognized as supported
expect(isSupportedSourceType(rssConfig.type)).toBe(true);
expect(isSupportedSourceType(redditConfig.type)).toBe(true);
expect(isSupportedSourceType(newsApiConfig.type)).toBe(true);
// Types should be distinct
const types = new Set([rssConfig.type, redditConfig.type, newsApiConfig.type]);
expect(types.size).toBe(3);
}),
{ numRuns: 100 }
);
});
it('SUPPORTED_SOURCE_TYPES contains all three types', () => {
expect(SUPPORTED_SOURCE_TYPES).toContain('rss');
expect(SUPPORTED_SOURCE_TYPES).toContain('reddit');
expect(SUPPORTED_SOURCE_TYPES).toContain('news_api');
expect(SUPPORTED_SOURCE_TYPES.length).toBe(3);
});
});
describe('Source type-specific validation', () => {
it('RSS sources do not require subreddit or apiKey', () => {
fc.assert(
fc.property(validRssConfigArb, (config) => {
// RSS config should not have subreddit or apiKey
const configWithoutOptionals = {
type: config.type,
url: config.url,
};
const result: ContentSourceValidationResult = validateContentSourceConfig(configWithoutOptionals);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
}),
{ numRuns: 100 }
);
});
it('Reddit sources require subreddit', () => {
fc.assert(
fc.property(validRedditConfigArb, (config) => {
// Reddit config with subreddit should pass
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
// Reddit config without subreddit should fail
const configWithoutSubreddit = {
type: config.type,
url: config.url,
};
const resultWithoutSubreddit: ContentSourceValidationResult = validateContentSourceConfig(configWithoutSubreddit);
expect(resultWithoutSubreddit.valid).toBe(false);
expect(resultWithoutSubreddit.errors.some(e => e.includes('Subreddit') || e.includes('subreddit'))).toBe(true);
}),
{ numRuns: 100 }
);
});
it('News API sources require apiKey', () => {
fc.assert(
fc.property(validNewsApiConfigArb, (config) => {
// News API config with apiKey should pass
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
// News API config without apiKey should fail
const configWithoutApiKey = {
type: config.type,
url: config.url,
};
const resultWithoutApiKey: ContentSourceValidationResult = validateContentSourceConfig(configWithoutApiKey);
expect(resultWithoutApiKey.valid).toBe(false);
expect(resultWithoutApiKey.errors.some(e => e.includes('API key') || e.includes('apiKey'))).toBe(true);
}),
{ numRuns: 100 }
);
});
});
describe('Multiple sources with same type', () => {
it('multiple RSS sources can all be valid', () => {
fc.assert(
fc.property(
fc.array(validRssConfigArb, { minLength: 2, maxLength: 5 }),
(configs) => {
// All RSS configs should be valid
for (const config of configs) {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(config.type).toBe('rss');
}
}
),
{ numRuns: 100 }
);
});
it('multiple Reddit sources can all be valid', () => {
fc.assert(
fc.property(
fc.array(validRedditConfigArb, { minLength: 2, maxLength: 5 }),
(configs) => {
// All Reddit configs should be valid
for (const config of configs) {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(config.type).toBe('reddit');
}
}
),
{ numRuns: 100 }
);
});
it('multiple News API sources can all be valid', () => {
fc.assert(
fc.property(
fc.array(validNewsApiConfigArb, { minLength: 2, maxLength: 5 }),
(configs) => {
// All News API configs should be valid
for (const config of configs) {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(config.type).toBe('news_api');
}
}
),
{ numRuns: 100 }
);
});
});
describe('Mixed source types collection', () => {
it('a collection of mixed source types all validate correctly', () => {
// Generator for a mixed collection of 1-10 sources of various types
const mixedSourcesArb = fc.array(
fc.oneof(validRssConfigArb, validRedditConfigArb, validNewsApiConfigArb),
{ minLength: 1, maxLength: 10 }
);
fc.assert(
fc.property(mixedSourcesArb, (configs) => {
// Track which types we've seen
const seenTypes = new Set<string>();
// All configs should be valid
for (const config of configs) {
const result: ContentSourceValidationResult = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
// Track the type
seenTypes.add(config.type);
// Type should be one of the supported types
expect(SUPPORTED_SOURCE_TYPES).toContain(config.type);
}
// We should have at least one source
expect(configs.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
it('source configurations preserve their type after validation', () => {
fc.assert(
fc.property(allSourceTypesConfigArb, ([rssConfig, redditConfig, newsApiConfig]) => {
// Validate each config
validateContentSourceConfig(rssConfig);
validateContentSourceConfig(redditConfig);
validateContentSourceConfig(newsApiConfig);
// Types should still be correct after validation
expect(rssConfig.type).toBe('rss');
expect(redditConfig.type).toBe('reddit');
expect(newsApiConfig.type).toBe('news_api');
}),
{ numRuns: 100 }
);
});
});
});
+445
View File
@@ -0,0 +1,445 @@
/**
* Unit Tests for Content Source Service
*
* Tests URL validation, source type validation, and content source management.
*
* Requirements: 4.1, 4.6
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
// Types
ContentSourceType,
ContentSourceConfig,
SUPPORTED_SOURCE_TYPES,
MAX_KEYWORDS,
MAX_KEYWORD_LENGTH,
// Validation functions
isSupportedSourceType,
isValidUrl,
validateSourceUrl,
validateSourceType,
validateSubreddit,
validateNewsApiKey,
validateKeywords,
validateContentSourceConfig,
extractSubredditFromUrl,
// Error classes
ContentSourceError,
ContentSourceNotFoundError,
BotNotFoundError,
ContentSourceValidationError,
} from './contentSource';
// ============================================
// SOURCE TYPE VALIDATION TESTS
// ============================================
describe('isSupportedSourceType', () => {
it('should return true for supported source types', () => {
expect(isSupportedSourceType('rss')).toBe(true);
expect(isSupportedSourceType('reddit')).toBe(true);
expect(isSupportedSourceType('news_api')).toBe(true);
});
it('should return false for unsupported source types', () => {
expect(isSupportedSourceType('twitter')).toBe(false);
expect(isSupportedSourceType('facebook')).toBe(false);
expect(isSupportedSourceType('')).toBe(false);
expect(isSupportedSourceType('RSS')).toBe(false); // Case sensitive
});
});
describe('validateSourceType', () => {
it('should return no errors for valid source types', () => {
expect(validateSourceType('rss')).toEqual([]);
expect(validateSourceType('reddit')).toEqual([]);
expect(validateSourceType('news_api')).toEqual([]);
});
it('should return error for missing source type', () => {
expect(validateSourceType(undefined)).toContain('Source type is required');
expect(validateSourceType(null)).toContain('Source type is required');
expect(validateSourceType('')).toContain('Source type is required');
});
it('should return error for unsupported source type', () => {
const errors = validateSourceType('twitter');
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Unsupported source type');
expect(errors[0]).toContain('twitter');
});
it('should return error for non-string source type', () => {
expect(validateSourceType(123)).toContain('Source type is required');
expect(validateSourceType({})).toContain('Source type is required');
});
});
// ============================================
// URL VALIDATION TESTS
// ============================================
describe('isValidUrl', () => {
it('should return true for valid HTTP URLs', () => {
expect(isValidUrl('http://example.com')).toBe(true);
expect(isValidUrl('http://example.com/path')).toBe(true);
expect(isValidUrl('http://example.com/path?query=1')).toBe(true);
});
it('should return true for valid HTTPS URLs', () => {
expect(isValidUrl('https://example.com')).toBe(true);
expect(isValidUrl('https://example.com/path')).toBe(true);
expect(isValidUrl('https://subdomain.example.com')).toBe(true);
});
it('should return false for invalid URLs', () => {
expect(isValidUrl('')).toBe(false);
expect(isValidUrl('not-a-url')).toBe(false);
expect(isValidUrl('ftp://example.com')).toBe(false);
expect(isValidUrl('file:///path')).toBe(false);
});
it('should return false for non-string inputs', () => {
expect(isValidUrl(null as unknown as string)).toBe(false);
expect(isValidUrl(undefined as unknown as string)).toBe(false);
expect(isValidUrl(123 as unknown as string)).toBe(false);
});
});
describe('validateSourceUrl', () => {
describe('RSS URLs', () => {
it('should accept valid RSS feed URLs', () => {
expect(validateSourceUrl('https://example.com/feed.xml', 'rss')).toEqual([]);
expect(validateSourceUrl('https://blog.example.com/rss', 'rss')).toEqual([]);
expect(validateSourceUrl('http://example.com/feed', 'rss')).toEqual([]);
});
it('should reject invalid RSS URLs', () => {
const errors = validateSourceUrl('not-a-url', 'rss');
expect(errors.length).toBeGreaterThan(0);
});
});
describe('Reddit URLs', () => {
it('should accept valid Reddit subreddit URLs', () => {
expect(validateSourceUrl('https://reddit.com/r/programming', 'reddit')).toEqual([]);
expect(validateSourceUrl('https://www.reddit.com/r/javascript', 'reddit')).toEqual([]);
expect(validateSourceUrl('https://old.reddit.com/r/typescript', 'reddit')).toEqual([]);
});
it('should reject invalid Reddit URLs', () => {
const errors1 = validateSourceUrl('https://reddit.com', 'reddit');
expect(errors1.length).toBeGreaterThan(0);
expect(errors1[0]).toContain('subreddit URL');
const errors2 = validateSourceUrl('https://example.com/r/test', 'reddit');
expect(errors2.length).toBeGreaterThan(0);
});
});
describe('News API URLs', () => {
it('should accept valid news API URLs', () => {
expect(validateSourceUrl('https://newsapi.org/v2/everything', 'news_api')).toEqual([]);
expect(validateSourceUrl('https://api.example.com/news', 'news_api')).toEqual([]);
expect(validateSourceUrl('https://gnews.io/api/v4/search', 'news_api')).toEqual([]);
});
it('should reject invalid news API URLs', () => {
const errors = validateSourceUrl('https://example.com/page', 'news_api');
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('API endpoint');
});
});
describe('Common URL validation', () => {
it('should reject empty URLs', () => {
expect(validateSourceUrl('', 'rss')).toContain('URL is required');
expect(validateSourceUrl(' ', 'rss')).toContain('URL cannot be empty');
});
it('should reject URLs that are too long', () => {
const longUrl = 'https://example.com/' + 'a'.repeat(2050);
const errors = validateSourceUrl(longUrl, 'rss');
expect(errors.some(e => e.includes('too long'))).toBe(true);
});
it('should reject non-HTTP/HTTPS URLs', () => {
const errors = validateSourceUrl('ftp://example.com/feed', 'rss');
expect(errors.some(e => e.includes('HTTP or HTTPS'))).toBe(true);
});
});
});
// ============================================
// SUBREDDIT VALIDATION TESTS
// ============================================
describe('validateSubreddit', () => {
it('should return no errors for valid subreddit names', () => {
expect(validateSubreddit('programming', 'reddit')).toEqual([]);
expect(validateSubreddit('javascript', 'reddit')).toEqual([]);
expect(validateSubreddit('test_subreddit', 'reddit')).toEqual([]);
expect(validateSubreddit('abc', 'reddit')).toEqual([]); // Minimum 3 chars
});
it('should skip validation for non-Reddit sources', () => {
expect(validateSubreddit(undefined, 'rss')).toEqual([]);
expect(validateSubreddit(undefined, 'news_api')).toEqual([]);
});
it('should require subreddit for Reddit sources', () => {
expect(validateSubreddit(undefined, 'reddit')).toContain('Subreddit name is required for Reddit sources');
expect(validateSubreddit(null, 'reddit')).toContain('Subreddit name is required for Reddit sources');
expect(validateSubreddit('', 'reddit')).toContain('Subreddit name is required for Reddit sources');
});
it('should reject invalid subreddit names', () => {
// Too short
expect(validateSubreddit('ab', 'reddit').length).toBeGreaterThan(0);
// Too long (max 21 chars)
expect(validateSubreddit('a'.repeat(22), 'reddit').length).toBeGreaterThan(0);
// Invalid characters
expect(validateSubreddit('test-subreddit', 'reddit').length).toBeGreaterThan(0);
expect(validateSubreddit('test subreddit', 'reddit').length).toBeGreaterThan(0);
});
});
// ============================================
// NEWS API KEY VALIDATION TESTS
// ============================================
describe('validateNewsApiKey', () => {
it('should return no errors for valid API keys', () => {
expect(validateNewsApiKey('abcdefghij1234567890', 'news_api')).toEqual([]);
expect(validateNewsApiKey('a'.repeat(50), 'news_api')).toEqual([]);
});
it('should skip validation for non-news_api sources', () => {
expect(validateNewsApiKey(undefined, 'rss')).toEqual([]);
expect(validateNewsApiKey(undefined, 'reddit')).toEqual([]);
});
it('should require API key for news_api sources', () => {
expect(validateNewsApiKey(undefined, 'news_api')).toContain('API key is required for news API sources');
expect(validateNewsApiKey(null, 'news_api')).toContain('API key is required for news API sources');
expect(validateNewsApiKey('', 'news_api')).toContain('API key is required for news API sources');
});
it('should reject API keys that are too short', () => {
expect(validateNewsApiKey('short', 'news_api').some(e => e.includes('too short'))).toBe(true);
});
it('should reject API keys that are too long', () => {
expect(validateNewsApiKey('a'.repeat(300), 'news_api').some(e => e.includes('too long'))).toBe(true);
});
});
// ============================================
// KEYWORDS VALIDATION TESTS
// ============================================
describe('validateKeywords', () => {
it('should return no errors for valid keywords', () => {
expect(validateKeywords(['javascript', 'typescript'])).toEqual([]);
expect(validateKeywords(['single'])).toEqual([]);
expect(validateKeywords([])).toEqual([]);
});
it('should allow undefined/null (optional field)', () => {
expect(validateKeywords(undefined)).toEqual([]);
expect(validateKeywords(null)).toEqual([]);
});
it('should reject non-array values', () => {
expect(validateKeywords('keyword')).toContain('Keywords must be an array');
expect(validateKeywords({})).toContain('Keywords must be an array');
});
it('should reject too many keywords', () => {
const tooManyKeywords = Array(MAX_KEYWORDS + 1).fill('keyword');
expect(validateKeywords(tooManyKeywords).some(e => e.includes('Maximum'))).toBe(true);
});
it('should reject non-string keywords', () => {
expect(validateKeywords([123]).some(e => e.includes('must be a string'))).toBe(true);
expect(validateKeywords([null]).some(e => e.includes('must be a string'))).toBe(true);
});
it('should reject empty keywords', () => {
expect(validateKeywords(['']).some(e => e.includes('cannot be empty'))).toBe(true);
expect(validateKeywords([' ']).some(e => e.includes('cannot be empty'))).toBe(true);
});
it('should reject keywords that are too long', () => {
const longKeyword = 'a'.repeat(MAX_KEYWORD_LENGTH + 1);
expect(validateKeywords([longKeyword]).some(e => e.includes('too long'))).toBe(true);
});
});
// ============================================
// COMPLETE CONFIG VALIDATION TESTS
// ============================================
describe('validateContentSourceConfig', () => {
it('should validate a complete RSS config', () => {
const config: ContentSourceConfig = {
type: 'rss',
url: 'https://example.com/feed.xml',
keywords: ['tech', 'news'],
};
const result = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should validate a complete Reddit config', () => {
const config: ContentSourceConfig = {
type: 'reddit',
url: 'https://reddit.com/r/programming',
subreddit: 'programming',
};
const result = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should validate a complete news_api config', () => {
const config: ContentSourceConfig = {
type: 'news_api',
url: 'https://newsapi.org/v2/everything',
apiKey: 'abcdefghij1234567890',
keywords: ['technology'],
};
const result = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should reject non-object configs', () => {
expect(validateContentSourceConfig(null).valid).toBe(false);
expect(validateContentSourceConfig(undefined).valid).toBe(false);
expect(validateContentSourceConfig('string').valid).toBe(false);
});
it('should collect all validation errors', () => {
const config = {
type: 'reddit',
url: 'not-a-url',
// Missing subreddit
};
const result = validateContentSourceConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(1);
});
it('should use default fetch interval when not provided', () => {
const config: ContentSourceConfig = {
type: 'rss',
url: 'https://example.com/feed.xml',
};
const result = validateContentSourceConfig(config);
expect(result.valid).toBe(true);
});
});
// ============================================
// HELPER FUNCTION TESTS
// ============================================
describe('extractSubredditFromUrl', () => {
it('should extract subreddit from standard Reddit URLs', () => {
expect(extractSubredditFromUrl('https://reddit.com/r/programming')).toBe('programming');
expect(extractSubredditFromUrl('https://www.reddit.com/r/javascript')).toBe('javascript');
expect(extractSubredditFromUrl('https://old.reddit.com/r/typescript')).toBe('typescript');
});
it('should extract subreddit from URLs with paths', () => {
expect(extractSubredditFromUrl('https://reddit.com/r/programming/hot')).toBe('programming');
expect(extractSubredditFromUrl('https://reddit.com/r/test/comments/123')).toBe('test');
});
it('should return null for non-Reddit URLs', () => {
expect(extractSubredditFromUrl('https://example.com')).toBe(null);
expect(extractSubredditFromUrl('https://example.com/r/test')).toBe(null);
});
it('should return null for Reddit URLs without subreddit', () => {
expect(extractSubredditFromUrl('https://reddit.com')).toBe(null);
expect(extractSubredditFromUrl('https://reddit.com/user/test')).toBe(null);
});
});
// ============================================
// ERROR CLASS TESTS
// ============================================
describe('Error Classes', () => {
describe('ContentSourceError', () => {
it('should create error with message and code', () => {
const error = new ContentSourceError('Test error', 'TEST_CODE');
expect(error.message).toBe('Test error');
expect(error.code).toBe('TEST_CODE');
expect(error.name).toBe('ContentSourceError');
});
});
describe('ContentSourceNotFoundError', () => {
it('should create error with source ID', () => {
const error = new ContentSourceNotFoundError('source-123');
expect(error.message).toContain('source-123');
expect(error.code).toBe('SOURCE_NOT_FOUND');
});
});
describe('BotNotFoundError', () => {
it('should create error with bot ID', () => {
const error = new BotNotFoundError('bot-123');
expect(error.message).toContain('bot-123');
expect(error.code).toBe('BOT_NOT_FOUND');
});
});
describe('ContentSourceValidationError', () => {
it('should create error with message and errors array', () => {
const error = new ContentSourceValidationError('Validation failed', ['Error 1', 'Error 2']);
expect(error.message).toBe('Validation failed');
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.errors).toEqual(['Error 1', 'Error 2']);
});
});
});
// ============================================
// CONSTANTS TESTS
// ============================================
describe('Constants', () => {
it('should have correct supported source types', () => {
expect(SUPPORTED_SOURCE_TYPES).toContain('rss');
expect(SUPPORTED_SOURCE_TYPES).toContain('reddit');
expect(SUPPORTED_SOURCE_TYPES).toContain('news_api');
expect(SUPPORTED_SOURCE_TYPES.length).toBe(4);
});
it('should have reasonable keyword limits', () => {
expect(MAX_KEYWORDS).toBeGreaterThan(0);
expect(MAX_KEYWORD_LENGTH).toBeGreaterThan(0);
});
});
+861
View File
@@ -0,0 +1,861 @@
/**
* Content Source Service
*
* Handles content source management for bots including adding, removing,
* and validating content sources (RSS, Reddit, news APIs).
*
* Requirements: 4.1, 4.6
*/
import { db, botContentSources, bots } from '@/db';
import { eq, and } from 'drizzle-orm';
import { encryptApiKey, serializeEncryptedData } from './encryption';
// ============================================
// TYPES
// ============================================
/**
* Supported content source types.
*
* Validates: Requirements 4.6
*/
export type ContentSourceType = 'rss' | 'reddit' | 'news_api' | 'brave_news' | 'youtube';
/**
* Brave News configuration options.
*/
export interface BraveNewsConfig {
/** Search query */
query: string;
/** Freshness filter: pd (24h), pw (7d), pm (31d), py (year) */
freshness?: 'pd' | 'pw' | 'pm' | 'py';
/** Country code (2-letter ISO) */
country?: string;
/** Search language */
searchLang?: string;
/** Number of results (max 50) */
count?: number;
}
/**
* News API configuration options for query builder.
*/
export interface NewsApiConfig {
/** News API provider */
provider: 'newsapi' | 'gnews' | 'newsdata';
/** Search query/keywords */
query: string;
/** Category filter */
category?: string;
/** Country code */
country?: string;
/** Language */
language?: string;
}
/**
* Configuration for adding a content source.
*/
export interface ContentSourceConfig {
/** Type of content source */
type: ContentSourceType;
/** URL for the content source */
url: string;
/** Subreddit name (required for Reddit sources) */
subreddit?: string;
/** API key for news APIs (required for news_api and brave_news sources) */
apiKey?: string;
/** Keywords for filtering content */
keywords?: string[];
/** Brave News specific configuration */
braveNewsConfig?: BraveNewsConfig;
/** News API query builder configuration */
newsApiConfig?: NewsApiConfig;
}
/**
* Content source entity returned from database.
*/
export interface ContentSource {
id: string;
botId: string;
type: ContentSourceType;
url: string;
subreddit: string | null;
keywords: string[] | null;
sourceConfig: BraveNewsConfig | NewsApiConfig | null;
isActive: boolean;
lastFetchAt: Date | null;
lastError: string | null;
consecutiveErrors: number;
createdAt: Date;
updatedAt: Date;
}
/**
* Validation result for content source configuration.
*/
export interface ContentSourceValidationResult {
valid: boolean;
errors: string[];
}
// ============================================
// CONSTANTS
// ============================================
/** Supported content source types */
export const SUPPORTED_SOURCE_TYPES: ContentSourceType[] = ['rss', 'reddit', 'news_api', 'brave_news', 'youtube'];
/** Maximum keywords per source */
export const MAX_KEYWORDS = 20;
/** Maximum keyword length */
export const MAX_KEYWORD_LENGTH = 100;
/** Reddit URL patterns */
const REDDIT_URL_PATTERNS = [
/^https?:\/\/(www\.)?reddit\.com\/r\/[\w-]+/i,
/^https?:\/\/(www\.)?old\.reddit\.com\/r\/[\w-]+/i,
];
/** RSS URL pattern (basic HTTP/HTTPS URL) */
const RSS_URL_PATTERN = /^https?:\/\/[^\s/$.?#].[^\s]*$/i;
/** News API URL patterns for common providers */
const NEWS_API_URL_PATTERNS = [
/^https?:\/\/(www\.)?newsapi\.org/i,
/^https?:\/\/(www\.)?gnews\.io/i,
/^https?:\/\/(www\.)?newsdata\.io/i,
/^https?:\/\/api\./i, // Generic API endpoint
];
/** Brave News API URL pattern */
const BRAVE_NEWS_URL_PATTERN = /^https?:\/\/api\.search\.brave\.com/i;
// ============================================
// ERROR CLASSES
// ============================================
/**
* Base error class for content source operations.
*/
export class ContentSourceError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'ContentSourceError';
}
}
/**
* Error thrown when content source is not found.
*/
export class ContentSourceNotFoundError extends ContentSourceError {
constructor(sourceId: string) {
super(`Content source not found: ${sourceId}`, 'SOURCE_NOT_FOUND');
}
}
/**
* Error thrown when bot is not found.
*/
export class BotNotFoundError extends ContentSourceError {
constructor(botId: string) {
super(`Bot not found: ${botId}`, 'BOT_NOT_FOUND');
}
}
/**
* Error thrown when content source validation fails.
*/
export class ContentSourceValidationError extends ContentSourceError {
constructor(message: string, public errors: string[] = []) {
super(message, 'VALIDATION_ERROR');
}
}
// ============================================
// VALIDATION FUNCTIONS
// ============================================
/**
* Check if a source type is supported.
*
* @param type - The source type to check
* @returns True if the type is supported
*
* Validates: Requirements 4.6
*/
export function isSupportedSourceType(type: string): type is ContentSourceType {
return SUPPORTED_SOURCE_TYPES.includes(type as ContentSourceType);
}
/**
* Validate a URL format.
*
* @param url - The URL to validate
* @returns True if the URL is valid
*/
export function isValidUrl(url: string): boolean {
if (!url || typeof url !== 'string') {
return false;
}
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
/**
* Validate a URL for a specific source type.
*
* @param url - The URL to validate
* @param type - The source type
* @returns Validation errors (empty if valid)
*
* Validates: Requirements 4.1
*/
export function validateSourceUrl(url: string, type: ContentSourceType): string[] {
const errors: string[] = [];
if (!url || typeof url !== 'string') {
errors.push('URL is required');
return errors;
}
const trimmedUrl = url.trim();
if (trimmedUrl.length === 0) {
errors.push('URL cannot be empty');
return errors;
}
if (trimmedUrl.length > 2048) {
errors.push('URL is too long (maximum 2048 characters)');
return errors;
}
if (!isValidUrl(trimmedUrl)) {
errors.push('URL must be a valid HTTP or HTTPS URL');
return errors;
}
// Type-specific URL validation
switch (type) {
case 'rss':
// RSS feeds can be any valid URL
if (!RSS_URL_PATTERN.test(trimmedUrl)) {
errors.push('Invalid RSS feed URL format');
}
break;
case 'reddit':
// Reddit URLs must point to a subreddit
const isValidRedditUrl = REDDIT_URL_PATTERNS.some(pattern => pattern.test(trimmedUrl));
if (!isValidRedditUrl) {
errors.push('Reddit URL must be a valid subreddit URL (e.g., https://reddit.com/r/subreddit)');
}
break;
case 'news_api':
// News API URLs should be API endpoints
const isValidNewsApiUrl = NEWS_API_URL_PATTERNS.some(pattern => pattern.test(trimmedUrl));
if (!isValidNewsApiUrl) {
errors.push('News API URL must be a valid API endpoint');
}
break;
case 'brave_news':
// Brave News URLs must point to the Brave Search API
if (!BRAVE_NEWS_URL_PATTERN.test(trimmedUrl)) {
errors.push('Brave News URL must be a valid Brave Search API endpoint');
}
break;
}
return errors;
}
/**
* Validate source type.
*
* @param type - The source type to validate
* @returns Validation errors (empty if valid)
*
* Validates: Requirements 4.1, 4.6
*/
export function validateSourceType(type: unknown): string[] {
const errors: string[] = [];
if (!type || typeof type !== 'string') {
errors.push('Source type is required');
return errors;
}
if (!isSupportedSourceType(type)) {
errors.push(`Unsupported source type: ${type}. Supported types: ${SUPPORTED_SOURCE_TYPES.join(', ')}`);
}
return errors;
}
/**
* Validate subreddit name for Reddit sources.
*
* @param subreddit - The subreddit name
* @param type - The source type
* @returns Validation errors (empty if valid)
*/
export function validateSubreddit(subreddit: unknown, type: ContentSourceType): string[] {
const errors: string[] = [];
if (type !== 'reddit') {
return errors;
}
if (!subreddit || typeof subreddit !== 'string') {
errors.push('Subreddit name is required for Reddit sources');
return errors;
}
const trimmed = subreddit.trim();
// Subreddit names: 3-21 characters, alphanumeric and underscores
if (!/^[a-zA-Z0-9_]{3,21}$/.test(trimmed)) {
errors.push('Subreddit name must be 3-21 characters, alphanumeric and underscores only');
}
return errors;
}
/**
* Validate API key for news API sources.
*
* @param apiKey - The API key
* @param type - The source type
* @returns Validation errors (empty if valid)
*/
export function validateNewsApiKey(apiKey: unknown, type: ContentSourceType): string[] {
const errors: string[] = [];
if (type !== 'news_api' && type !== 'brave_news') {
return errors;
}
if (!apiKey || typeof apiKey !== 'string') {
errors.push(`API key is required for ${type === 'brave_news' ? 'Brave News' : 'news API'} sources`);
return errors;
}
const trimmed = apiKey.trim();
if (trimmed.length < 10) {
errors.push('API key is too short');
}
if (trimmed.length > 256) {
errors.push('API key is too long');
}
return errors;
}
/**
* Validate keywords array.
*
* @param keywords - The keywords array
* @returns Validation errors (empty if valid)
*/
export function validateKeywords(keywords: unknown): string[] {
const errors: string[] = [];
// Keywords are optional
if (keywords === undefined || keywords === null) {
return errors;
}
if (!Array.isArray(keywords)) {
errors.push('Keywords must be an array');
return errors;
}
if (keywords.length > MAX_KEYWORDS) {
errors.push(`Maximum ${MAX_KEYWORDS} keywords allowed`);
}
for (let i = 0; i < keywords.length; i++) {
const keyword = keywords[i];
if (typeof keyword !== 'string') {
errors.push(`Keyword at index ${i} must be a string`);
continue;
}
const trimmed = keyword.trim();
if (trimmed.length === 0) {
errors.push(`Keyword at index ${i} cannot be empty`);
}
if (trimmed.length > MAX_KEYWORD_LENGTH) {
errors.push(`Keyword at index ${i} is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`);
}
}
return errors;
}
/**
* Validate a complete content source configuration.
*
* @param config - The configuration to validate
* @returns Validation result with errors
*
* Validates: Requirements 4.1, 4.6
*/
export function validateContentSourceConfig(config: unknown): ContentSourceValidationResult {
const errors: string[] = [];
if (!config || typeof config !== 'object') {
return {
valid: false,
errors: ['Content source configuration must be an object'],
};
}
const configObj = config as Record<string, unknown>;
// Validate type first (needed for other validations)
const typeErrors = validateSourceType(configObj.type);
errors.push(...typeErrors);
// If type is invalid, we can't do type-specific validation
if (typeErrors.length > 0) {
return { valid: false, errors };
}
const type = configObj.type as ContentSourceType;
// Validate URL
errors.push(...validateSourceUrl(configObj.url as string, type));
// Validate type-specific fields
errors.push(...validateSubreddit(configObj.subreddit, type));
errors.push(...validateNewsApiKey(configObj.apiKey, type));
// Validate optional fields
errors.push(...validateKeywords(configObj.keywords));
return {
valid: errors.length === 0,
errors,
};
}
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Extract subreddit name from a Reddit URL.
*
* @param url - The Reddit URL
* @returns The subreddit name or null
*/
export function extractSubredditFromUrl(url: string): string | null {
const match = url.match(/reddit\.com\/r\/([a-zA-Z0-9_]+)/i);
return match ? match[1] : null;
}
/**
* Build a Brave News API URL from configuration.
*
* @param config - The Brave News configuration
* @returns The constructed API URL
*/
export function buildBraveNewsUrl(config: BraveNewsConfig): string {
const url = new URL('https://api.search.brave.com/res/v1/news/search');
url.searchParams.set('q', config.query);
if (config.freshness) {
url.searchParams.set('freshness', config.freshness);
}
if (config.country) {
url.searchParams.set('country', config.country);
}
if (config.searchLang) {
url.searchParams.set('search_lang', config.searchLang);
}
if (config.count) {
url.searchParams.set('count', String(Math.min(config.count, 50)));
}
return url.toString();
}
/**
* Build a News API URL from configuration.
*
* @param config - The News API configuration
* @returns The constructed API URL
*/
export function buildNewsApiUrl(config: NewsApiConfig): string {
let baseUrl: string;
const params = new URLSearchParams();
switch (config.provider) {
case 'newsapi':
baseUrl = 'https://newsapi.org/v2/everything';
params.set('q', config.query);
if (config.language) params.set('language', config.language);
break;
case 'gnews':
baseUrl = 'https://gnews.io/api/v4/search';
params.set('q', config.query);
if (config.country) params.set('country', config.country);
if (config.language) params.set('lang', config.language);
if (config.category) params.set('topic', config.category);
break;
case 'newsdata':
baseUrl = 'https://newsdata.io/api/1/news';
params.set('q', config.query);
if (config.country) params.set('country', config.country);
if (config.language) params.set('language', config.language);
if (config.category) params.set('category', config.category);
break;
default:
throw new Error(`Unknown news API provider: ${config.provider}`);
}
return `${baseUrl}?${params.toString()}`;
}
/**
* Convert database row to ContentSource interface.
*/
function dbSourceToContentSource(dbSource: typeof botContentSources.$inferSelect): ContentSource {
return {
id: dbSource.id,
botId: dbSource.botId,
type: dbSource.type as ContentSourceType,
url: dbSource.url,
subreddit: dbSource.subreddit,
keywords: dbSource.keywords ? JSON.parse(dbSource.keywords) : null,
sourceConfig: dbSource.sourceConfig ? JSON.parse(dbSource.sourceConfig) : null,
isActive: dbSource.isActive,
lastFetchAt: dbSource.lastFetchAt,
lastError: dbSource.lastError,
consecutiveErrors: dbSource.consecutiveErrors,
createdAt: dbSource.createdAt,
updatedAt: dbSource.updatedAt,
};
}
// ============================================
// CONTENT SOURCE MANAGEMENT FUNCTIONS
// ============================================
/**
* Add a content source to a bot.
*
* @param botId - The ID of the bot
* @param config - The content source configuration
* @returns The created content source
* @throws BotNotFoundError if bot doesn't exist
* @throws ContentSourceValidationError if configuration is invalid
*
* Validates: Requirements 4.1, 4.6
*/
export async function addSource(
botId: string,
config: ContentSourceConfig
): Promise<ContentSource> {
// Validate configuration
const validation = validateContentSourceConfig(config);
if (!validation.valid) {
throw new ContentSourceValidationError(
`Invalid content source configuration: ${validation.errors.join(', ')}`,
validation.errors
);
}
// Check if bot exists
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { id: true },
});
if (!bot) {
throw new BotNotFoundError(botId);
}
// Prepare data for insertion
const insertData: typeof botContentSources.$inferInsert = {
botId,
type: config.type,
url: config.url.trim(),
subreddit: config.type === 'reddit'
? (config.subreddit?.trim() || extractSubredditFromUrl(config.url))
: null,
keywords: config.keywords && config.keywords.length > 0
? JSON.stringify(config.keywords.map(k => k.trim()))
: null,
sourceConfig: config.braveNewsConfig
? JSON.stringify(config.braveNewsConfig)
: config.newsApiConfig
? JSON.stringify(config.newsApiConfig)
: null,
isActive: true,
consecutiveErrors: 0,
};
// Encrypt API key if provided (for news_api and brave_news sources)
if ((config.type === 'news_api' || config.type === 'brave_news') && config.apiKey) {
const encryptedApiKey = encryptApiKey(config.apiKey);
insertData.apiKeyEncrypted = serializeEncryptedData(encryptedApiKey);
}
// Insert the content source
const [createdSource] = await db
.insert(botContentSources)
.values(insertData)
.returning();
return dbSourceToContentSource(createdSource);
}
/**
* Remove a content source.
*
* @param sourceId - The ID of the content source to remove
* @throws ContentSourceNotFoundError if source doesn't exist
*
* Validates: Requirements 4.1
*/
export async function removeSource(sourceId: string): Promise<void> {
// Check if source exists
const existingSource = await db.query.botContentSources.findFirst({
where: eq(botContentSources.id, sourceId),
columns: { id: true },
});
if (!existingSource) {
throw new ContentSourceNotFoundError(sourceId);
}
// Delete the source (cascade will handle content items)
await db.delete(botContentSources).where(eq(botContentSources.id, sourceId));
}
/**
* Get a content source by ID.
*
* @param sourceId - The ID of the content source
* @returns The content source or null if not found
*/
export async function getSourceById(sourceId: string): Promise<ContentSource | null> {
const source = await db.query.botContentSources.findFirst({
where: eq(botContentSources.id, sourceId),
});
if (!source) {
return null;
}
return dbSourceToContentSource(source);
}
/**
* Get all content sources for a bot.
*
* @param botId - The ID of the bot
* @returns Array of content sources
*
* Validates: Requirements 4.6
*/
export async function getSourcesByBot(botId: string): Promise<ContentSource[]> {
const sources = await db.query.botContentSources.findMany({
where: eq(botContentSources.botId, botId),
orderBy: (sources, { desc }) => [desc(sources.createdAt)],
});
return sources.map(dbSourceToContentSource);
}
/**
* Get all active content sources for a bot.
*
* @param botId - The ID of the bot
* @returns Array of active content sources
*/
export async function getActiveSourcesByBot(botId: string): Promise<ContentSource[]> {
const sources = await db.query.botContentSources.findMany({
where: and(
eq(botContentSources.botId, botId),
eq(botContentSources.isActive, true)
),
orderBy: (sources, { desc }) => [desc(sources.createdAt)],
});
return sources.map(dbSourceToContentSource);
}
/**
* Update a content source.
*
* @param sourceId - The ID of the content source
* @param updates - The fields to update
* @returns The updated content source
* @throws ContentSourceNotFoundError if source doesn't exist
* @throws ContentSourceValidationError if updates are invalid
*/
export async function updateSource(
sourceId: string,
updates: Partial<Pick<ContentSourceConfig, 'url' | 'keywords'>> & {
isActive?: boolean;
}
): Promise<ContentSource> {
// Check if source exists
const existingSource = await db.query.botContentSources.findFirst({
where: eq(botContentSources.id, sourceId),
});
if (!existingSource) {
throw new ContentSourceNotFoundError(sourceId);
}
const errors: string[] = [];
// Validate updates
if (updates.url !== undefined) {
errors.push(...validateSourceUrl(updates.url, existingSource.type as ContentSourceType));
}
if (updates.keywords !== undefined) {
errors.push(...validateKeywords(updates.keywords));
}
if (errors.length > 0) {
throw new ContentSourceValidationError(
`Invalid update: ${errors.join(', ')}`,
errors
);
}
// Build update data
const updateData: Partial<typeof botContentSources.$inferInsert> = {
updatedAt: new Date(),
};
if (updates.url !== undefined) {
updateData.url = updates.url.trim();
}
if (updates.keywords !== undefined) {
updateData.keywords = updates.keywords && updates.keywords.length > 0
? JSON.stringify(updates.keywords.map(k => k.trim()))
: null;
}
if (updates.isActive !== undefined) {
updateData.isActive = updates.isActive;
}
// Update the source
const [updatedSource] = await db
.update(botContentSources)
.set(updateData)
.where(eq(botContentSources.id, sourceId))
.returning();
return dbSourceToContentSource(updatedSource);
}
/**
* Activate a content source.
*
* @param sourceId - The ID of the content source
* @throws ContentSourceNotFoundError if source doesn't exist
*/
export async function activateSource(sourceId: string): Promise<void> {
await updateSource(sourceId, { isActive: true });
}
/**
* Deactivate a content source.
*
* @param sourceId - The ID of the content source
* @throws ContentSourceNotFoundError if source doesn't exist
*/
export async function deactivateSource(sourceId: string): Promise<void> {
await updateSource(sourceId, { isActive: false });
}
/**
* Check if a bot owns a specific content source.
*
* @param botId - The ID of the bot
* @param sourceId - The ID of the content source
* @returns True if the bot owns the source
*/
export async function botOwnsSource(botId: string, sourceId: string): Promise<boolean> {
const source = await db.query.botContentSources.findFirst({
where: and(
eq(botContentSources.id, sourceId),
eq(botContentSources.botId, botId)
),
columns: { id: true },
});
return source !== undefined;
}
/**
* Get the count of content sources for a bot.
*
* @param botId - The ID of the bot
* @returns The number of content sources
*/
export async function getSourceCountForBot(botId: string): Promise<number> {
const sources = await db.query.botContentSources.findMany({
where: eq(botContentSources.botId, botId),
columns: { id: true },
});
return sources.length;
}
/**
* Get content sources by type for a bot.
*
* @param botId - The ID of the bot
* @param type - The source type to filter by
* @returns Array of content sources of the specified type
*
* Validates: Requirements 4.6
*/
export async function getSourcesByType(
botId: string,
type: ContentSourceType
): Promise<ContentSource[]> {
const sources = await db.query.botContentSources.findMany({
where: and(
eq(botContentSources.botId, botId),
eq(botContentSources.type, type)
),
orderBy: (sources, { desc }) => [desc(sources.createdAt)],
});
return sources.map(dbSourceToContentSource);
}
+485
View File
@@ -0,0 +1,485 @@
/**
* Property-Based Tests for Bot API Key Encryption
*
* Feature: bot-system, Property 6: API Key Encryption Round-Trip
*
* Tests the encryption module using fast-check for property-based testing.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fc from 'fast-check';
import {
encryptApiKey,
decryptApiKey,
EncryptedData,
} from './encryption';
// ============================================
// TEST SETUP
// ============================================
// Store original env value to restore after tests
const originalEncryptionKey = process.env.BOT_ENCRYPTION_KEY;
// Generate a valid 32-byte encryption key for testing (base64 encoded)
const TEST_ENCRYPTION_KEY = Buffer.from(
'test-encryption-key-32-bytes!!!!'.slice(0, 32)
).toString('base64');
beforeAll(() => {
// Set up test encryption key
process.env.BOT_ENCRYPTION_KEY = TEST_ENCRYPTION_KEY;
});
afterAll(() => {
// Restore original encryption key
if (originalEncryptionKey !== undefined) {
process.env.BOT_ENCRYPTION_KEY = originalEncryptionKey;
} else {
delete process.env.BOT_ENCRYPTION_KEY;
}
});
// ============================================
// GENERATORS
// ============================================
/**
* Generator for valid API keys.
*
* Valid API keys:
* - OpenRouter: starts with "sk-or-" followed by alphanumeric chars
* - OpenAI: starts with "sk-" (but not "sk-or-" or "sk-ant-") followed by alphanumeric chars
* - Anthropic: starts with "sk-ant-" followed by alphanumeric chars
*
* All keys must be at least 20 characters and at most 256 characters.
*/
const validApiKeyArb = fc.oneof(
// OpenRouter keys: sk-or-{alphanumeric, min 14 chars to reach 20 total}
fc.stringMatching(/^[a-zA-Z0-9_-]{14,200}$/).map(suffix => `sk-or-${suffix}`),
// Anthropic keys: sk-ant-{alphanumeric, min 12 chars to reach 20 total}
fc.stringMatching(/^[a-zA-Z0-9_-]{12,200}$/).map(suffix => `sk-ant-${suffix}`),
// OpenAI keys: sk-{alphanumeric, min 17 chars to reach 20 total, but not starting with or- or ant-}
fc.stringMatching(/^[a-zA-Z0-9_-]{17,200}$/)
.filter(suffix => !suffix.startsWith('or-') && !suffix.startsWith('ant-'))
.map(suffix => `sk-${suffix}`)
);
/**
* Generator for arbitrary non-empty strings (for testing encryption of any string).
* This tests the encryption mechanism itself, not API key validation.
*/
const arbitraryStringArb = fc.string({ minLength: 1, maxLength: 500 });
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 6: API Key Encryption Round-Trip', () => {
/**
* Property 6: API Key Encryption Round-Trip
*
* *For any* valid API key, encrypting then decrypting the key SHALL produce
* the original key value, and the encrypted value SHALL differ from the original.
*
* **Validates: Requirements 2.2, 2.3**
*/
it('encrypting then decrypting any valid API key produces the original value', () => {
fc.assert(
fc.property(validApiKeyArb, (apiKey) => {
// Encrypt the API key
const encrypted: EncryptedData = encryptApiKey(apiKey);
// Decrypt the encrypted data
const decrypted: string = decryptApiKey(encrypted);
// The decrypted value must equal the original
expect(decrypted).toBe(apiKey);
}),
{ numRuns: 100 } // Minimum 100 iterations as per design doc
);
});
it('encrypted value differs from the original API key', () => {
fc.assert(
fc.property(validApiKeyArb, (apiKey) => {
// Encrypt the API key
const encrypted: EncryptedData = encryptApiKey(apiKey);
// The encrypted value (base64 encoded ciphertext) must differ from original
expect(encrypted.encrypted).not.toBe(apiKey);
// Additionally, the encrypted data should not contain the original key
// (this is a stronger check for security)
expect(encrypted.encrypted).not.toContain(apiKey);
}),
{ numRuns: 100 }
);
});
it('encryption produces different ciphertext for the same key (due to random IV)', () => {
fc.assert(
fc.property(validApiKeyArb, (apiKey) => {
// Encrypt the same key twice
const encrypted1: EncryptedData = encryptApiKey(apiKey);
const encrypted2: EncryptedData = encryptApiKey(apiKey);
// The encrypted values should differ due to random IV
expect(encrypted1.encrypted).not.toBe(encrypted2.encrypted);
expect(encrypted1.iv).not.toBe(encrypted2.iv);
// But both should decrypt to the same original value
expect(decryptApiKey(encrypted1)).toBe(apiKey);
expect(decryptApiKey(encrypted2)).toBe(apiKey);
}),
{ numRuns: 100 }
);
});
it('round-trip works for arbitrary non-empty strings', () => {
fc.assert(
fc.property(arbitraryStringArb, (plaintext) => {
// Encrypt the string
const encrypted: EncryptedData = encryptApiKey(plaintext);
// Decrypt the encrypted data
const decrypted: string = decryptApiKey(encrypted);
// The decrypted value must equal the original
expect(decrypted).toBe(plaintext);
// The encrypted value must differ from original (unless empty after encoding)
if (plaintext.length > 0) {
expect(encrypted.encrypted).not.toBe(plaintext);
}
}),
{ numRuns: 100 }
);
});
it('encrypted data has valid structure', () => {
fc.assert(
fc.property(validApiKeyArb, (apiKey) => {
const encrypted: EncryptedData = encryptApiKey(apiKey);
// Encrypted data should have both required fields
expect(encrypted).toHaveProperty('encrypted');
expect(encrypted).toHaveProperty('iv');
// Both fields should be non-empty strings
expect(typeof encrypted.encrypted).toBe('string');
expect(typeof encrypted.iv).toBe('string');
expect(encrypted.encrypted.length).toBeGreaterThan(0);
expect(encrypted.iv.length).toBeGreaterThan(0);
// IV should be base64 encoded 16 bytes (approximately 24 chars with padding)
const ivBuffer = Buffer.from(encrypted.iv, 'base64');
expect(ivBuffer.length).toBe(16);
}),
{ numRuns: 100 }
);
});
});
// ============================================
// IMPORTS FOR PROPERTY 7
// ============================================
import {
validateApiKeyFormat,
LLMProvider,
} from './encryption';
// ============================================
// GENERATORS FOR PROPERTY 7
// ============================================
/**
* Generator for invalid API keys - strings that should NOT pass validation.
*
* Invalid keys include:
* - Empty strings
* - Strings too short (< 20 chars)
* - Strings too long (> 256 chars)
* - Strings with wrong prefixes
* - Strings with invalid characters
* - Strings that don't match any provider pattern
*/
// Generator for strings that are too short (< 20 chars)
const tooShortKeyArb = fc.string({ minLength: 1, maxLength: 19 });
// Generator for strings that are too long (> 256 chars)
const tooLongKeyArb = fc.string({ minLength: 257, maxLength: 500 });
// Generator for strings with wrong/no prefix (doesn't start with sk-)
const wrongPrefixKeyArb = fc.stringMatching(/^[a-zA-Z0-9_-]{20,100}$/)
.filter(s => !s.startsWith('sk-'));
// Generator for strings with invalid characters after valid prefix
const invalidCharsAfterPrefixArb = fc.oneof(
// OpenRouter prefix with invalid chars (spaces, special chars)
fc.stringMatching(/^[a-zA-Z0-9 !@#$%^&*()]{14,50}$/)
.filter(s => /[^a-zA-Z0-9_-]/.test(s))
.map(suffix => `sk-or-${suffix}`),
// Anthropic prefix with invalid chars
fc.stringMatching(/^[a-zA-Z0-9 !@#$%^&*()]{12,50}$/)
.filter(s => /[^a-zA-Z0-9_-]/.test(s))
.map(suffix => `sk-ant-${suffix}`),
// OpenAI prefix with invalid chars
fc.stringMatching(/^[a-zA-Z0-9 !@#$%^&*()]{17,50}$/)
.filter(s => /[^a-zA-Z0-9_-]/.test(s) && !s.startsWith('or-') && !s.startsWith('ant-'))
.map(suffix => `sk-${suffix}`)
);
// Generator for OpenRouter keys that are mismatched with OpenAI provider
const openRouterKeyForOpenAIArb = fc.stringMatching(/^[a-zA-Z0-9_-]{14,100}$/)
.map(suffix => `sk-or-${suffix}`);
// Generator for Anthropic keys that are mismatched with OpenAI provider
// sk-ant- is 7 chars, so suffix needs at least 13 chars to reach 20 total
const anthropicKeyForOpenAIArb = fc.stringMatching(/^[a-zA-Z0-9_-]{13,100}$/)
.map(suffix => `sk-ant-${suffix}`);
// Generator for valid OpenAI keys (for testing provider mismatch)
const validOpenAIKeyArb = fc.stringMatching(/^[a-zA-Z0-9_-]{17,100}$/)
.filter(suffix => !suffix.startsWith('or-') && !suffix.startsWith('ant-'))
.map(suffix => `sk-${suffix}`);
// Generator for valid OpenRouter keys
const validOpenRouterKeyArb = fc.stringMatching(/^[a-zA-Z0-9_-]{14,100}$/)
.map(suffix => `sk-or-${suffix}`);
// Generator for valid Anthropic keys
// sk-ant- is 7 chars, so suffix needs at least 13 chars to reach 20 total
const validAnthropicKeyArb = fc.stringMatching(/^[a-zA-Z0-9_-]{13,100}$/)
.map(suffix => `sk-ant-${suffix}`);
// Generator for completely random strings (most will be invalid)
const randomStringArb = fc.string({ minLength: 1, maxLength: 300 });
// ============================================
// PROPERTY 7 TESTS
// ============================================
describe('Feature: bot-system, Property 7: API Key Format Validation', () => {
/**
* Property 7: API Key Format Validation
*
* *For any* string that does not match valid API key formats for supported providers,
* the validation SHALL reject the key.
*
* **Validates: Requirements 2.1**
*/
describe('Invalid keys are rejected', () => {
it('rejects keys that are too short (< 20 characters)', () => {
fc.assert(
fc.property(
tooShortKeyArb,
fc.constantFrom<LLMProvider>('openrouter', 'openai', 'anthropic'),
(key, provider) => {
const result = validateApiKeyFormat(key, provider);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
}
),
{ numRuns: 100 }
);
});
it('rejects keys that are too long (> 256 characters)', () => {
fc.assert(
fc.property(
tooLongKeyArb,
fc.constantFrom<LLMProvider>('openrouter', 'openai', 'anthropic'),
(key, provider) => {
const result = validateApiKeyFormat(key, provider);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
}
),
{ numRuns: 100 }
);
});
it('rejects keys with wrong prefix for the provider', () => {
fc.assert(
fc.property(wrongPrefixKeyArb, (key) => {
// Test against all providers - keys without sk- prefix should fail all
const openrouterResult = validateApiKeyFormat(key, 'openrouter');
const openaiResult = validateApiKeyFormat(key, 'openai');
const anthropicResult = validateApiKeyFormat(key, 'anthropic');
expect(openrouterResult.valid).toBe(false);
expect(openaiResult.valid).toBe(false);
expect(anthropicResult.valid).toBe(false);
}),
{ numRuns: 100 }
);
});
it('rejects keys with invalid characters after valid prefix', () => {
fc.assert(
fc.property(invalidCharsAfterPrefixArb, (key) => {
// Determine which provider this key appears to be for
let provider: LLMProvider;
if (key.startsWith('sk-or-')) {
provider = 'openrouter';
} else if (key.startsWith('sk-ant-')) {
provider = 'anthropic';
} else {
provider = 'openai';
}
const result = validateApiKeyFormat(key, provider);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
}),
{ numRuns: 100 }
);
});
it('rejects empty strings', () => {
const providers: LLMProvider[] = ['openrouter', 'openai', 'anthropic'];
for (const provider of providers) {
const result = validateApiKeyFormat('', provider);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
}
});
it('rejects non-string inputs', () => {
const providers: LLMProvider[] = ['openrouter', 'openai', 'anthropic'];
const invalidInputs = [null, undefined, 123, {}, []];
for (const provider of providers) {
for (const input of invalidInputs) {
const result = validateApiKeyFormat(input as unknown as string, provider);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
}
}
});
});
describe('Provider mismatch detection', () => {
it('rejects OpenRouter keys when validating for OpenAI provider', () => {
fc.assert(
fc.property(openRouterKeyForOpenAIArb, (key) => {
const result = validateApiKeyFormat(key, 'openai');
expect(result.valid).toBe(false);
expect(result.error).toContain('OpenRouter');
}),
{ numRuns: 100 }
);
});
it('rejects Anthropic keys when validating for OpenAI provider', () => {
fc.assert(
fc.property(anthropicKeyForOpenAIArb, (key) => {
const result = validateApiKeyFormat(key, 'openai');
expect(result.valid).toBe(false);
expect(result.error).toContain('Anthropic');
}),
{ numRuns: 100 }
);
});
it('rejects OpenAI keys when validating for OpenRouter provider', () => {
fc.assert(
fc.property(validOpenAIKeyArb, (key) => {
const result = validateApiKeyFormat(key, 'openrouter');
expect(result.valid).toBe(false);
}),
{ numRuns: 100 }
);
});
it('rejects OpenAI keys when validating for Anthropic provider', () => {
fc.assert(
fc.property(validOpenAIKeyArb, (key) => {
const result = validateApiKeyFormat(key, 'anthropic');
expect(result.valid).toBe(false);
}),
{ numRuns: 100 }
);
});
});
describe('Valid keys are accepted', () => {
it('accepts valid OpenRouter keys', () => {
fc.assert(
fc.property(validOpenRouterKeyArb, (key) => {
const result = validateApiKeyFormat(key, 'openrouter');
expect(result.valid).toBe(true);
expect(result.provider).toBe('openrouter');
expect(result.error).toBeUndefined();
}),
{ numRuns: 100 }
);
});
it('accepts valid OpenAI keys', () => {
fc.assert(
fc.property(validOpenAIKeyArb, (key) => {
const result = validateApiKeyFormat(key, 'openai');
expect(result.valid).toBe(true);
expect(result.provider).toBe('openai');
expect(result.error).toBeUndefined();
}),
{ numRuns: 100 }
);
});
it('accepts valid Anthropic keys', () => {
fc.assert(
fc.property(validAnthropicKeyArb, (key) => {
const result = validateApiKeyFormat(key, 'anthropic');
expect(result.valid).toBe(true);
expect(result.provider).toBe('anthropic');
expect(result.error).toBeUndefined();
}),
{ numRuns: 100 }
);
});
});
describe('Random string rejection', () => {
it('most random strings are rejected by all providers', () => {
fc.assert(
fc.property(randomStringArb, (key) => {
// Count how many providers accept this key
const results = [
validateApiKeyFormat(key, 'openrouter'),
validateApiKeyFormat(key, 'openai'),
validateApiKeyFormat(key, 'anthropic'),
];
const validCount = results.filter(r => r.valid).length;
// A random string should be accepted by at most one provider
// (if it happens to match a valid format)
expect(validCount).toBeLessThanOrEqual(1);
// If it's valid for one provider, verify it matches the expected format
if (validCount === 1) {
const validResult = results.find(r => r.valid)!;
if (validResult.provider === 'openrouter') {
expect(key.startsWith('sk-or-')).toBe(true);
} else if (validResult.provider === 'anthropic') {
expect(key.startsWith('sk-ant-')).toBe(true);
} else if (validResult.provider === 'openai') {
expect(key.startsWith('sk-')).toBe(true);
expect(key.startsWith('sk-or-')).toBe(false);
expect(key.startsWith('sk-ant-')).toBe(false);
}
}
}),
{ numRuns: 100 }
);
});
});
});
+343
View File
@@ -0,0 +1,343 @@
/**
* Bot API Key Encryption Module
*
* Provides AES-256-GCM encryption for API keys and format validation
* for supported LLM providers (OpenRouter, OpenAI, Anthropic).
*
* Requirements: 2.1, 2.2, 2.3, 10.3
*/
import * as crypto from 'crypto';
// ============================================
// TYPES
// ============================================
export type LLMProvider = 'openrouter' | 'openai' | 'anthropic';
export interface EncryptedData {
encrypted: string; // Base64 encoded ciphertext + auth tag
iv: string; // Base64 encoded initialization vector
}
export interface ApiKeyValidationResult {
valid: boolean;
provider?: LLMProvider;
error?: string;
}
// ============================================
// CONSTANTS
// ============================================
/**
* API key format patterns for supported providers
* - OpenRouter: typically starts with "sk-or-"
* - OpenAI: typically starts with "sk-" (but not "sk-or-" or "sk-ant-")
* - Anthropic: typically starts with "sk-ant-"
*/
const API_KEY_PATTERNS: Record<LLMProvider, RegExp> = {
openrouter: /^sk-or-[a-zA-Z0-9_-]+$/,
anthropic: /^sk-ant-[a-zA-Z0-9_-]+$/,
openai: /^sk-[a-zA-Z0-9_-]+$/,
};
/**
* Minimum length for API keys (security requirement)
*/
const MIN_API_KEY_LENGTH = 20;
/**
* Maximum length for API keys (sanity check)
*/
const MAX_API_KEY_LENGTH = 256;
// ============================================
// ENCRYPTION KEY MANAGEMENT
// ============================================
/**
* Get the encryption key from environment variables.
* Uses AUTH_SECRET as the encryption key.
*
* @throws Error if AUTH_SECRET is not set
*/
function getEncryptionKey(): Buffer {
const keyEnv = process.env.AUTH_SECRET;
if (!keyEnv) {
throw new Error('AUTH_SECRET environment variable is not set');
}
// Create a 32-byte key from AUTH_SECRET using SHA-256
return crypto.createHash('sha256').update(keyEnv).digest();
}
// ============================================
// ENCRYPTION FUNCTIONS
// ============================================
/**
* Encrypt an API key using AES-256-GCM.
*
* @param apiKey - The plaintext API key to encrypt
* @returns EncryptedData containing the encrypted key and IV
* @throws Error if encryption fails or encryption key is not configured
*
* Validates: Requirements 2.2, 10.3
*/
export function encryptApiKey(apiKey: string): EncryptedData {
if (!apiKey || typeof apiKey !== 'string') {
throw new Error('API key must be a non-empty string');
}
const key = getEncryptionKey();
// Generate a random 16-byte IV for each encryption
const iv = crypto.randomBytes(16);
// Create cipher with AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
// Encrypt the API key
let encrypted = cipher.update(apiKey, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
// Get the authentication tag (16 bytes for GCM)
const authTag = cipher.getAuthTag();
// Combine encrypted data with auth tag
const combined = Buffer.concat([encrypted, authTag]);
return {
encrypted: combined.toString('base64'),
iv: iv.toString('base64'),
};
}
/**
* Decrypt an encrypted API key using AES-256-GCM.
*
* @param encryptedData - The encrypted data containing ciphertext and IV
* @returns The decrypted plaintext API key
* @throws Error if decryption fails or data is tampered
*
* Validates: Requirements 2.3
*/
export function decryptApiKey(encryptedData: EncryptedData): string {
if (!encryptedData || !encryptedData.encrypted || !encryptedData.iv) {
throw new Error('Invalid encrypted data: missing required fields');
}
const key = getEncryptionKey();
// Decode the IV
const iv = Buffer.from(encryptedData.iv, 'base64');
if (iv.length !== 16) {
throw new Error('Invalid IV length');
}
// Decode the combined encrypted data + auth tag
const combined = Buffer.from(encryptedData.encrypted, 'base64');
// Separate the auth tag (last 16 bytes) from the encrypted data
if (combined.length < 17) {
throw new Error('Invalid encrypted data: too short');
}
const authTag = combined.subarray(combined.length - 16);
const encryptedBuffer = combined.subarray(0, combined.length - 16);
// Create decipher
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
// Decrypt
let decrypted = decipher.update(encryptedBuffer);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Serialize encrypted data to a JSON string for database storage.
*
* @param encryptedData - The encrypted data to serialize
* @returns JSON string representation
*/
export function serializeEncryptedData(encryptedData: EncryptedData): string {
return JSON.stringify(encryptedData);
}
/**
* Deserialize encrypted data from a JSON string (from database).
*
* @param serialized - The JSON string to deserialize
* @returns EncryptedData object
* @throws Error if the string is not valid JSON or missing required fields
*/
export function deserializeEncryptedData(serialized: string): EncryptedData {
if (!serialized || typeof serialized !== 'string') {
throw new Error('Invalid serialized data: must be a non-empty string');
}
try {
const parsed = JSON.parse(serialized);
if (!parsed.encrypted || !parsed.iv) {
throw new Error('Invalid encrypted data format: missing required fields');
}
return {
encrypted: parsed.encrypted,
iv: parsed.iv,
};
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error('Invalid serialized data: not valid JSON');
}
throw error;
}
}
// ============================================
// API KEY VALIDATION
// ============================================
/**
* Validate an API key format for a specific provider.
*
* @param apiKey - The API key to validate
* @param provider - The LLM provider type
* @returns Validation result with validity status and any error message
*
* Validates: Requirements 2.1
*/
export function validateApiKeyFormat(apiKey: string, provider: LLMProvider): ApiKeyValidationResult {
// Basic validation
if (!apiKey || typeof apiKey !== 'string') {
return {
valid: false,
error: 'API key must be a non-empty string',
};
}
// Length validation
if (apiKey.length < MIN_API_KEY_LENGTH) {
return {
valid: false,
error: `API key is too short (minimum ${MIN_API_KEY_LENGTH} characters)`,
};
}
if (apiKey.length > MAX_API_KEY_LENGTH) {
return {
valid: false,
error: `API key is too long (maximum ${MAX_API_KEY_LENGTH} characters)`,
};
}
// Provider validation
if (!API_KEY_PATTERNS[provider]) {
return {
valid: false,
error: `Unsupported provider: ${provider}`,
};
}
// For OpenAI, we need to ensure it doesn't match OpenRouter or Anthropic patterns
if (provider === 'openai') {
// OpenAI keys start with "sk-" but NOT "sk-or-" or "sk-ant-"
if (apiKey.startsWith('sk-or-')) {
return {
valid: false,
error: 'This appears to be an OpenRouter key, not an OpenAI key',
};
}
if (apiKey.startsWith('sk-ant-')) {
return {
valid: false,
error: 'This appears to be an Anthropic key, not an OpenAI key',
};
}
}
// Check against provider pattern
const pattern = API_KEY_PATTERNS[provider];
if (!pattern.test(apiKey)) {
return {
valid: false,
error: `Invalid API key format for ${provider}`,
};
}
return {
valid: true,
provider,
};
}
/**
* Detect the provider type from an API key based on its format.
*
* @param apiKey - The API key to analyze
* @returns The detected provider or null if unknown
*/
export function detectProviderFromApiKey(apiKey: string): LLMProvider | null {
if (!apiKey || typeof apiKey !== 'string') {
return null;
}
// Check in order of specificity (most specific prefixes first)
if (apiKey.startsWith('sk-or-')) {
return 'openrouter';
}
if (apiKey.startsWith('sk-ant-')) {
return 'anthropic';
}
if (apiKey.startsWith('sk-')) {
return 'openai';
}
return null;
}
/**
* Validate an API key and detect its provider automatically.
*
* @param apiKey - The API key to validate
* @returns Validation result with detected provider
*/
export function validateAndDetectApiKey(apiKey: string): ApiKeyValidationResult {
const provider = detectProviderFromApiKey(apiKey);
if (!provider) {
return {
valid: false,
error: 'Unable to detect API key provider. Key must start with sk-or- (OpenRouter), sk-ant- (Anthropic), or sk- (OpenAI)',
};
}
return validateApiKeyFormat(apiKey, provider);
}
/**
* Check if a provider is supported.
*
* @param provider - The provider string to check
* @returns True if the provider is supported
*/
export function isSupportedProvider(provider: string): provider is LLMProvider {
return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic';
}
/**
* Get the list of supported providers.
*
* @returns Array of supported provider names
*/
export function getSupportedProviders(): LLMProvider[] {
return ['openrouter', 'openai', 'anthropic'];
}
File diff suppressed because it is too large Load Diff
+662
View File
@@ -0,0 +1,662 @@
/**
* LLM Client Module
*
* Provides a unified interface for generating completions from multiple LLM providers:
* - OpenRouter
* - OpenAI
* - Anthropic
*
* Includes retry logic with exponential backoff (3 retries).
*
* Requirements: 2.6, 11.4
*/
import { decryptApiKey, deserializeEncryptedData, LLMProvider } from './encryption';
// ============================================
// TYPES
// ============================================
/**
* LLM configuration for a bot.
*/
export interface LLMConfig {
provider: LLMProvider;
apiKey: string; // Encrypted before storage
model: string;
}
/**
* Message format for LLM requests.
*/
export interface LLMMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
/**
* Request options for LLM completion.
*/
export interface LLMCompletionRequest {
messages: LLMMessage[];
temperature?: number;
maxTokens?: number;
}
/**
* Response from LLM completion.
*/
export interface LLMCompletionResponse {
content: string;
tokensUsed: {
prompt: number;
completion: number;
total: number;
};
model: string;
provider: LLMProvider;
}
/**
* Error thrown by LLM client operations.
*/
export class LLMClientError extends Error {
constructor(
message: string,
public code: LLMErrorCode,
public provider: LLMProvider,
public statusCode?: number,
public retryable: boolean = false
) {
super(message);
this.name = 'LLMClientError';
}
}
/**
* Error codes for LLM client errors.
*/
export type LLMErrorCode =
| 'AUTHENTICATION_ERROR'
| 'RATE_LIMIT_ERROR'
| 'INVALID_REQUEST'
| 'CONTENT_POLICY_VIOLATION'
| 'SERVER_ERROR'
| 'NETWORK_ERROR'
| 'TIMEOUT_ERROR'
| 'UNKNOWN_ERROR';
/**
* Retry configuration.
*/
export interface RetryConfig {
maxRetries: number;
initialDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
}
// ============================================
// CONSTANTS
// ============================================
/**
* Default retry configuration.
* 3 retries with exponential backoff.
*/
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
backoffMultiplier: 2,
};
/**
* Default timeout for API requests (30 seconds).
*/
export const DEFAULT_TIMEOUT_MS = 30000;
/**
* API endpoints for each provider.
*/
export const PROVIDER_ENDPOINTS: Record<LLMProvider, string> = {
openrouter: 'https://openrouter.ai/api/v1/chat/completions',
openai: 'https://api.openai.com/v1/chat/completions',
anthropic: 'https://api.anthropic.com/v1/messages',
};
/**
* Default models for each provider.
*/
export const DEFAULT_MODELS: Record<LLMProvider, string> = {
openrouter: 'openai/gpt-3.5-turbo',
openai: 'gpt-3.5-turbo',
anthropic: 'claude-3-haiku-20240307',
};
// ============================================
// UTILITY FUNCTIONS
// ============================================
/**
* Sleep for a specified duration.
*
* @param ms - Duration in milliseconds
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Calculate delay for retry attempt with exponential backoff.
*
* @param attempt - Current attempt number (0-indexed)
* @param config - Retry configuration
* @returns Delay in milliseconds
*/
export function calculateRetryDelay(attempt: number, config: RetryConfig): number {
const delay = config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt);
return Math.min(delay, config.maxDelayMs);
}
/**
* Determine if an error is retryable.
*
* @param error - The error to check
* @returns True if the error is retryable
*/
export function isRetryableError(error: unknown): boolean {
if (error instanceof LLMClientError) {
return error.retryable;
}
// Network errors are generally retryable
if (error instanceof TypeError && error.message.includes('fetch')) {
return true;
}
return false;
}
/**
* Map HTTP status code to error code.
*
* @param statusCode - HTTP status code
* @returns LLM error code
*/
export function mapStatusToErrorCode(statusCode: number): { code: LLMErrorCode; retryable: boolean } {
switch (statusCode) {
case 401:
case 403:
return { code: 'AUTHENTICATION_ERROR', retryable: false };
case 429:
return { code: 'RATE_LIMIT_ERROR', retryable: true };
case 400:
return { code: 'INVALID_REQUEST', retryable: false };
case 500:
case 502:
case 503:
case 504:
return { code: 'SERVER_ERROR', retryable: true };
default:
return { code: 'UNKNOWN_ERROR', retryable: false };
}
}
// ============================================
// PROVIDER-SPECIFIC IMPLEMENTATIONS
// ============================================
/**
* Build request body for OpenRouter API.
*/
export function buildOpenRouterRequest(
request: LLMCompletionRequest,
model: string
): Record<string, unknown> {
return {
model,
messages: request.messages.map(msg => ({
role: msg.role,
content: msg.content,
})),
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens ?? 500,
};
}
/**
* Build request body for OpenAI API.
*/
export function buildOpenAIRequest(
request: LLMCompletionRequest,
model: string
): Record<string, unknown> {
return {
model,
messages: request.messages.map(msg => ({
role: msg.role,
content: msg.content,
})),
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens ?? 500,
};
}
/**
* Build request body for Anthropic API.
* Anthropic has a different message format - system message is separate.
*/
export function buildAnthropicRequest(
request: LLMCompletionRequest,
model: string
): Record<string, unknown> {
// Extract system message if present
const systemMessage = request.messages.find(msg => msg.role === 'system');
const otherMessages = request.messages.filter(msg => msg.role !== 'system');
const body: Record<string, unknown> = {
model,
messages: otherMessages.map(msg => ({
role: msg.role,
content: msg.content,
})),
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens ?? 500,
};
if (systemMessage) {
body.system = systemMessage.content;
}
return body;
}
/**
* Parse response from OpenRouter API.
*/
export function parseOpenRouterResponse(
data: Record<string, unknown>,
model: string
): LLMCompletionResponse {
const choices = data.choices as Array<{ message: { content: string } }>;
const usage = data.usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number };
return {
content: choices[0]?.message?.content ?? '',
tokensUsed: {
prompt: usage?.prompt_tokens ?? 0,
completion: usage?.completion_tokens ?? 0,
total: usage?.total_tokens ?? 0,
},
model: (data.model as string) ?? model,
provider: 'openrouter',
};
}
/**
* Parse response from OpenAI API.
*/
export function parseOpenAIResponse(
data: Record<string, unknown>,
model: string
): LLMCompletionResponse {
const choices = data.choices as Array<{ message: { content: string } }>;
const usage = data.usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number };
return {
content: choices[0]?.message?.content ?? '',
tokensUsed: {
prompt: usage?.prompt_tokens ?? 0,
completion: usage?.completion_tokens ?? 0,
total: usage?.total_tokens ?? 0,
},
model: (data.model as string) ?? model,
provider: 'openai',
};
}
/**
* Parse response from Anthropic API.
*/
export function parseAnthropicResponse(
data: Record<string, unknown>,
model: string
): LLMCompletionResponse {
const content = data.content as Array<{ type: string; text: string }>;
const usage = data.usage as { input_tokens: number; output_tokens: number };
// Anthropic returns content as an array of content blocks
const textContent = content
?.filter(block => block.type === 'text')
.map(block => block.text)
.join('') ?? '';
return {
content: textContent,
tokensUsed: {
prompt: usage?.input_tokens ?? 0,
completion: usage?.output_tokens ?? 0,
total: (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0),
},
model: (data.model as string) ?? model,
provider: 'anthropic',
};
}
/**
* Build headers for API request.
*/
export function buildHeaders(provider: LLMProvider, apiKey: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
switch (provider) {
case 'openrouter':
headers['Authorization'] = `Bearer ${apiKey}`;
headers['HTTP-Referer'] = 'https://synapsis.social'; // Required by OpenRouter
headers['X-Title'] = 'Synapsis Bot';
break;
case 'openai':
headers['Authorization'] = `Bearer ${apiKey}`;
break;
case 'anthropic':
headers['x-api-key'] = apiKey;
headers['anthropic-version'] = '2023-06-01';
break;
}
return headers;
}
// ============================================
// LLM CLIENT CLASS
// ============================================
/**
* LLM Client for generating completions from multiple providers.
*
* Supports OpenRouter, OpenAI, and Anthropic with unified interface.
* Includes retry logic with exponential backoff.
*
* Validates: Requirements 2.6, 11.4
*/
export class LLMClient {
private provider: LLMProvider;
private apiKey: string;
private model: string;
private retryConfig: RetryConfig;
private timeoutMs: number;
/**
* Create a new LLM client.
*
* @param config - LLM configuration
* @param retryConfig - Optional retry configuration
* @param timeoutMs - Optional timeout in milliseconds
*/
constructor(
config: LLMConfig,
retryConfig: RetryConfig = DEFAULT_RETRY_CONFIG,
timeoutMs: number = DEFAULT_TIMEOUT_MS
) {
this.provider = config.provider;
this.model = config.model || DEFAULT_MODELS[config.provider];
this.retryConfig = retryConfig;
this.timeoutMs = timeoutMs;
// Decrypt API key if it's encrypted (JSON format)
try {
const encryptedData = deserializeEncryptedData(config.apiKey);
this.apiKey = decryptApiKey(encryptedData);
} catch {
// If deserialization fails, assume it's a plain API key (for testing)
this.apiKey = config.apiKey;
}
}
/**
* Get the provider for this client.
*/
getProvider(): LLMProvider {
return this.provider;
}
/**
* Get the model for this client.
*/
getModel(): string {
return this.model;
}
/**
* Generate a completion from the LLM.
*
* @param request - Completion request
* @returns Completion response
* @throws LLMClientError if the request fails after all retries
*
* Validates: Requirements 2.6, 11.4
*/
async generateCompletion(request: LLMCompletionRequest): Promise<LLMCompletionResponse> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
try {
return await this.makeRequest(request);
} catch (error) {
lastError = error as Error;
// Check if we should retry
if (attempt < this.retryConfig.maxRetries && isRetryableError(error)) {
const delay = calculateRetryDelay(attempt, this.retryConfig);
await sleep(delay);
continue;
}
// Don't retry non-retryable errors
if (!isRetryableError(error)) {
throw error;
}
}
}
// All retries exhausted
throw lastError ?? new LLMClientError(
'All retries exhausted',
'UNKNOWN_ERROR',
this.provider,
undefined,
false
);
}
/**
* Make a single API request without retry logic.
*
* @param request - Completion request
* @returns Completion response
* @throws LLMClientError if the request fails
*/
private async makeRequest(request: LLMCompletionRequest): Promise<LLMCompletionResponse> {
const endpoint = PROVIDER_ENDPOINTS[this.provider];
const headers = buildHeaders(this.provider, this.apiKey);
const body = this.buildRequestBody(request);
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const { code, retryable } = mapStatusToErrorCode(response.status);
// Check for content policy violation
const errorMessage = (errorData as Record<string, unknown>).error?.toString() ?? '';
const isContentPolicy = errorMessage.toLowerCase().includes('content policy') ||
errorMessage.toLowerCase().includes('safety') ||
response.status === 400 && errorMessage.toLowerCase().includes('flagged');
throw new LLMClientError(
`${this.provider} API error: ${response.status} - ${JSON.stringify(errorData)}`,
isContentPolicy ? 'CONTENT_POLICY_VIOLATION' : code,
this.provider,
response.status,
retryable
);
}
const data = await response.json() as Record<string, unknown>;
return this.parseResponse(data);
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof LLMClientError) {
throw error;
}
// Handle abort (timeout)
if (error instanceof Error && error.name === 'AbortError') {
throw new LLMClientError(
`Request timed out after ${this.timeoutMs}ms`,
'TIMEOUT_ERROR',
this.provider,
undefined,
true
);
}
// Handle network errors
if (error instanceof TypeError) {
throw new LLMClientError(
`Network error: ${error.message}`,
'NETWORK_ERROR',
this.provider,
undefined,
true
);
}
throw new LLMClientError(
`Unknown error: ${error instanceof Error ? error.message : String(error)}`,
'UNKNOWN_ERROR',
this.provider,
undefined,
false
);
}
}
/**
* Build request body based on provider.
*/
private buildRequestBody(request: LLMCompletionRequest): Record<string, unknown> {
switch (this.provider) {
case 'openrouter':
return buildOpenRouterRequest(request, this.model);
case 'openai':
return buildOpenAIRequest(request, this.model);
case 'anthropic':
return buildAnthropicRequest(request, this.model);
}
}
/**
* Parse response based on provider.
*/
private parseResponse(data: Record<string, unknown>): LLMCompletionResponse {
switch (this.provider) {
case 'openrouter':
return parseOpenRouterResponse(data, this.model);
case 'openai':
return parseOpenAIResponse(data, this.model);
case 'anthropic':
return parseAnthropicResponse(data, this.model);
}
}
}
// ============================================
// FACTORY FUNCTIONS
// ============================================
/**
* Create an LLM client from configuration.
*
* @param config - LLM configuration
* @param retryConfig - Optional retry configuration
* @returns LLM client instance
*
* Validates: Requirements 2.6
*/
export function createLLMClient(
config: LLMConfig,
retryConfig?: RetryConfig
): LLMClient {
return new LLMClient(config, retryConfig);
}
/**
* Create an LLM client from bot data.
*
* @param provider - LLM provider
* @param encryptedApiKey - Encrypted API key (JSON string)
* @param model - Model name
* @returns LLM client instance
*/
export function createLLMClientFromBot(
provider: LLMProvider,
encryptedApiKey: string,
model: string
): LLMClient {
return new LLMClient({
provider,
apiKey: encryptedApiKey,
model,
});
}
/**
* Validate LLM configuration.
*
* @param config - Configuration to validate
* @returns Validation result
*/
export function validateLLMConfig(config: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!config || typeof config !== 'object') {
return { valid: false, errors: ['Configuration must be an object'] };
}
const configObj = config as Record<string, unknown>;
// Validate provider
const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic'];
if (!configObj.provider || !validProviders.includes(configObj.provider as LLMProvider)) {
errors.push(`Provider must be one of: ${validProviders.join(', ')}`);
}
// Validate API key
if (!configObj.apiKey || typeof configObj.apiKey !== 'string') {
errors.push('API key is required and must be a string');
}
// Validate model (optional but must be string if provided)
if (configObj.model !== undefined && typeof configObj.model !== 'string') {
errors.push('Model must be a string');
}
return { valid: errors.length === 0, errors };
}
@@ -0,0 +1,821 @@
/**
* Property-Based Tests for Mention Handler
*
* Feature: bot-system
* - Property 22: Mention Detection
* - Property 23: Mention Response Context
* - Property 24: Mention Chronological Processing
*
* Tests the Mention Handler service using fast-check for property-based testing.
*
* **Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5**
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fc from 'fast-check';
// ============================================
// MOCK SETUP
// ============================================
// In-memory storage for testing
let botsStore = new Map<string, any>();
let postsStore = new Map<string, any>();
let usersStore = new Map<string, any>();
let mentionsStore = new Map<string, any>();
let botIdCounter = 0;
let postIdCounter = 0;
let userIdCounter = 0;
let mentionIdCounter = 0;
// Helper functions for test access
export const __resetStore = () => {
botsStore.clear();
postsStore.clear();
usersStore.clear();
mentionsStore.clear();
botIdCounter = 0;
postIdCounter = 0;
userIdCounter = 0;
mentionIdCounter = 0;
};
export const __getBotsStore = () => botsStore;
export const __getPostsStore = () => postsStore;
export const __getUsersStore = () => usersStore;
export const __getMentionsStore = () => mentionsStore;
// Helper to add test data
export const __addBot = (handle: string, userId: string) => {
const id = `bot-${++botIdCounter}`;
const bot = {
id,
userId,
handle: handle.toLowerCase(),
name: `Bot ${handle}`,
personalityConfig: JSON.stringify({
systemPrompt: 'Test bot',
temperature: 0.7,
maxTokens: 1000,
}),
llmProvider: 'openai',
llmModel: 'gpt-4',
llmApiKeyEncrypted: 'encrypted-key',
createdAt: new Date(),
};
botsStore.set(id, bot);
return bot;
};
export const __addUser = (handle: string) => {
const id = `user-${++userIdCounter}`;
const user = {
id,
handle,
displayName: `User ${handle}`,
createdAt: new Date(),
};
usersStore.set(id, user);
return user;
};
export const __addPost = (userId: string, content: string, replyToId: string | null = null) => {
const id = `post-${++postIdCounter}`;
const post = {
id,
userId,
content,
replyToId,
isRemoved: false,
createdAt: new Date(Date.now() - postIdCounter * 1000), // Older posts have earlier timestamps
};
postsStore.set(id, post);
return post;
};
// Helper to set query context
export const __setCurrentBotId = (botId: string) => {
currentBotId = botId;
};
export const __setCurrentPostId = (postId: string) => {
currentPostId = postId;
};
export const __setFilterUnprocessedOnly = (value: boolean) => {
filterUnprocessedOnly = value;
};
// Track query context for filtering
let currentBotId: string | null = null;
let currentPostId: string | null = null;
let filterUnprocessedOnly = false;
// Mock the database module
vi.mock('@/db', () => {
return {
db: {
query: {
bots: {
findFirst: vi.fn().mockImplementation(({ where }: any) => {
// Find the first bot (usually the one we just created)
const bot = Array.from(botsStore.values())[0];
if (bot) {
currentBotId = bot.id;
}
return Promise.resolve(bot);
}),
},
posts: {
findFirst: vi.fn().mockImplementation(({ where, with: withClause }: any) => {
// Find post by ID if currentPostId is set
if (currentPostId) {
const post = postsStore.get(currentPostId);
if (post) {
const user = usersStore.get(post.userId);
return Promise.resolve({
...post,
author: user ? {
handle: user.handle,
displayName: user.displayName,
} : undefined,
});
}
}
// Otherwise return first post
for (const post of postsStore.values()) {
const user = usersStore.get(post.userId);
return Promise.resolve({
...post,
author: user ? {
handle: user.handle,
displayName: user.displayName,
} : undefined,
});
}
return Promise.resolve(undefined);
}),
findMany: vi.fn().mockImplementation(({ where, with: withClause, orderBy, limit }: any) => {
const posts = Array.from(postsStore.values())
.filter(p => !p.isRemoved)
.map(post => {
const user = usersStore.get(post.userId);
return {
...post,
author: user ? {
id: user.id,
handle: user.handle,
displayName: user.displayName,
} : undefined,
};
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return Promise.resolve(limit ? posts.slice(0, limit) : posts);
}),
},
botMentions: {
findFirst: vi.fn().mockImplementation(({ where }: any) => {
// Return first mention
for (const mention of mentionsStore.values()) {
return Promise.resolve(mention);
}
return Promise.resolve(undefined);
}),
findMany: vi.fn().mockImplementation(({ where, orderBy }: any) => {
let mentions = Array.from(mentionsStore.values());
// Filter by botId if set
if (currentBotId) {
mentions = mentions.filter(m => m.botId === currentBotId);
}
// Filter by isProcessed if needed
if (filterUnprocessedOnly) {
mentions = mentions.filter(m => !m.isProcessed);
filterUnprocessedOnly = false; // Reset flag
}
// Sort by createdAt
// Check if we want ascending (chronological) or descending order
const wantsAscending = mentions.length > 0 && mentions.some(m => !m.isProcessed);
mentions.sort((a, b) => {
return wantsAscending
? a.createdAt.getTime() - b.createdAt.getTime() // Ascending
: b.createdAt.getTime() - a.createdAt.getTime(); // Descending
});
return Promise.resolve(mentions);
}),
},
},
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values: any) => {
if (values.botId !== undefined) {
// This is a mention insert
const id = `mention-${++mentionIdCounter}`;
const mention = {
id,
...values,
createdAt: new Date(),
processedAt: null,
responsePostId: null,
};
mentionsStore.set(id, mention);
return {
returning: vi.fn().mockResolvedValue([mention]),
};
} else {
// This is a post insert
const id = `post-${++postIdCounter}`;
const post = {
id,
...values,
createdAt: new Date(),
};
postsStore.set(id, post);
return {
returning: vi.fn().mockResolvedValue([post]),
};
}
}),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => {
// Update mention as processed
for (const mention of mentionsStore.values()) {
if (!mention.isProcessed) {
mention.isProcessed = true;
mention.processedAt = new Date();
break;
}
}
return Promise.resolve(undefined);
}),
}),
}),
},
bots: {},
botMentions: {},
posts: {},
users: {},
};
});
// Mock drizzle-orm
vi.mock('drizzle-orm', () => ({
eq: vi.fn().mockImplementation((column: any, value: any) => ({ column, value, type: 'eq' })),
and: vi.fn().mockImplementation((...conditions: any[]) => ({ conditions, type: 'and' })),
desc: vi.fn().mockImplementation((column: any) => ({ column, direction: 'desc' })),
asc: vi.fn().mockImplementation((column: any) => ({ column, direction: 'asc' })),
isNull: vi.fn().mockImplementation((column: any) => ({ column, type: 'isNull' })),
}));
// Mock content generator
vi.mock('./contentGenerator', () => ({
ContentGenerator: vi.fn().mockImplementation(() => ({
generateReply: vi.fn().mockResolvedValue({
text: 'Generated reply text',
tokensUsed: 50,
model: 'gpt-4',
}),
})),
}));
// Mock rate limiter
vi.mock('./rateLimiter', () => ({
canReply: vi.fn().mockResolvedValue({ allowed: true }),
recordReply: vi.fn().mockResolvedValue(undefined),
}));
// Import after mocks are set up
import {
detectMentions,
getUnprocessedMentions,
getAllMentions,
getConversationContext,
processMention,
storeMention,
} from './mentionHandler';
// ============================================
// TEST SETUP
// ============================================
beforeEach(() => {
vi.clearAllMocks();
__resetStore();
});
// ============================================
// GENERATORS
// ============================================
/**
* Generator for valid bot handles.
*/
const botHandleArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_]{2,29}$/);
/**
* Generator for valid user handles.
*/
const userHandleArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_]{2,29}$/);
/**
* Generator for post content that may or may not contain mentions.
*/
const postContentArb = fc.string({ minLength: 1, maxLength: 500 });
/**
* Generator for post content with a specific mention.
*/
const postContentWithMentionArb = (handle: string) =>
fc.tuple(
fc.string({ maxLength: 200 }),
fc.string({ maxLength: 200 })
).map(([before, after]) => `${before} @${handle} ${after}`.trim());
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 22: Mention Detection', () => {
/**
* Property 22: Mention Detection
*
* *For any* post that mentions a bot's handle, the mention SHALL be detected
* and stored for processing.
*
* **Validates: Requirements 7.1, 7.2**
*/
it('detects mentions when post contains bot handle', async () => {
await fc.assert(
fc.asyncProperty(
botHandleArb,
userHandleArb,
async (botHandle, userHandle) => {
// Reset store
__resetStore();
// Create bot and user
const user = __addUser(userHandle);
const bot = __addBot(botHandle, user.id);
// Set current bot ID for queries
__setCurrentBotId(bot.id);
// Create post mentioning the bot
const postContent = `Hey @${botHandle.toLowerCase()}, how are you?`;
const post = __addPost(user.id, postContent);
// Detect mentions
const result = await detectMentions(bot.id);
// The function scans posts and creates mentions for those containing the handle
// Since our mock returns all posts, the function will check each post's content
// and create mentions for those that match
// Check if a mention was created in the store
const mentionsInStore = Array.from(__getMentionsStore().values());
const botMentions = mentionsInStore.filter(m => m.botId === bot.id);
// Should have created at least one mention
expect(botMentions.length).toBeGreaterThan(0);
// The result should reflect what was detected
expect(result.detected).toBe(botMentions.length > 0);
expect(result.mentions.length).toBe(botMentions.length);
}
),
{ numRuns: 100 }
);
});
it('does not detect mentions when post does not contain bot handle', async () => {
await fc.assert(
fc.asyncProperty(
botHandleArb,
userHandleArb,
postContentArb.filter(content => !content.includes('@')),
async (botHandle, userHandle, postContent) => {
// Reset store
__resetStore();
// Create bot and user
const user = __addUser(userHandle);
const bot = __addBot(botHandle, user.id);
// Create post without mentioning the bot
__addPost(user.id, postContent);
// Detect mentions
const result = await detectMentions(bot.id);
// Should not detect any mentions
expect(result.detected).toBe(false);
expect(result.mentions.length).toBe(0);
}
),
{ numRuns: 100 }
);
});
it('stores detected mentions with correct metadata', async () => {
await fc.assert(
fc.asyncProperty(
botHandleArb,
userHandleArb,
async (botHandle, userHandle) => {
// Reset store
__resetStore();
// Create bot and user
const user = __addUser(userHandle);
const bot = __addBot(botHandle, user.id);
// Set current bot ID for queries
__setCurrentBotId(bot.id);
// Create post mentioning the bot
const postContent = `@${botHandle} test mention`;
const post = __addPost(user.id, postContent);
// Detect mentions
const result = await detectMentions(bot.id);
// Should have detected at least one mention
if (result.mentions.length === 0) {
// Skip this test case if no mentions detected
return;
}
// Verify mention metadata
const mention = result.mentions[0];
expect(mention.botId).toBe(bot.id);
expect(mention.postId).toBe(post.id);
expect(mention.authorId).toBe(user.id);
expect(mention.content).toBe(postContent);
expect(mention.isProcessed).toBe(false);
expect(mention.isRemote).toBe(false);
expect(mention.createdAt).toBeInstanceOf(Date);
}
),
{ numRuns: 100 }
);
});
it('does not create duplicate mentions for the same post', async () => {
await fc.assert(
fc.asyncProperty(
botHandleArb,
userHandleArb,
async (botHandle, userHandle) => {
// Reset store
__resetStore();
// Create bot and user
const user = __addUser(userHandle);
const bot = __addBot(botHandle, user.id);
// Create post mentioning the bot
const postContent = `@${botHandle} test`;
__addPost(user.id, postContent);
// Detect mentions first time
const result1 = await detectMentions(bot.id);
const firstMentionCount = result1.mentions.length;
// Detect mentions second time
const result2 = await detectMentions(bot.id);
// Should not create duplicate mentions
expect(result2.mentions.length).toBe(0);
expect(__getMentionsStore().size).toBe(firstMentionCount);
}
),
{ numRuns: 100 }
);
});
it('detects multiple mentions in different posts', async () => {
await fc.assert(
fc.asyncProperty(
botHandleArb,
userHandleArb,
fc.integer({ min: 2, max: 5 }),
async (botHandle, userHandle, numPosts) => {
// Reset store
__resetStore();
// Create bot and user
const user = __addUser(userHandle);
const bot = __addBot(botHandle, user.id);
// Set current bot ID for queries
__setCurrentBotId(bot.id);
// Create multiple posts mentioning the bot (use lowercase to match bot handle)
for (let i = 0; i < numPosts; i++) {
__addPost(user.id, `Post ${i} @${botHandle.toLowerCase()}`);
}
// Detect mentions
const result = await detectMentions(bot.id);
// Check mentions in store
const mentionsInStore = Array.from(__getMentionsStore().values());
const botMentions = mentionsInStore.filter(m => m.botId === bot.id);
// Should have created mentions
expect(botMentions.length).toBeGreaterThan(0);
expect(result.detected).toBe(true);
expect(result.mentions.length).toBe(botMentions.length);
}
),
{ numRuns: 100 }
);
});
});
describe('Feature: bot-system, Property 23: Mention Response Context', () => {
/**
* Property 23: Mention Response Context
*
* *For any* mention being processed, the LLM prompt SHALL include the original
* post content and conversation context.
*
* **Validates: Requirements 7.3, 7.4**
*/
it('getConversationContext retrieves parent posts in thread', async () => {
await fc.assert(
fc.asyncProperty(
userHandleArb,
fc.integer({ min: 1, max: 5 }),
async (userHandle, threadDepth) => {
// Reset store
__resetStore();
// Create user
const user = __addUser(userHandle);
// Create a thread of posts (each replying to the previous)
let previousPostId: string | null = null;
const posts = [];
for (let i = 0; i < threadDepth; i++) {
const post = __addPost(user.id, `Post ${i}`, previousPostId);
posts.push(post);
previousPostId = post.id;
}
// Get conversation context for the last post
const lastPost = posts[posts.length - 1];
const context = await getConversationContext(lastPost.id);
// Context should include all posts in the thread
expect(context.length).toBeGreaterThan(0);
expect(context.length).toBeLessThanOrEqual(threadDepth);
// Context should be in chronological order (oldest first)
for (let i = 1; i < context.length; i++) {
expect(context[i].createdAt.getTime()).toBeGreaterThanOrEqual(
context[i - 1].createdAt.getTime()
);
}
}
),
{ numRuns: 100 }
);
});
it('conversation context is limited by maxDepth parameter', async () => {
await fc.assert(
fc.asyncProperty(
userHandleArb,
fc.integer({ min: 3, max: 10 }),
fc.integer({ min: 1, max: 5 }),
async (userHandle, threadDepth, maxDepth) => {
// Reset store
__resetStore();
// Create user
const user = __addUser(userHandle);
// Create a deep thread
let previousPostId: string | null = null;
const posts = [];
for (let i = 0; i < threadDepth; i++) {
const post = __addPost(user.id, `Post ${i}`, previousPostId);
posts.push(post);
previousPostId = post.id;
}
// Get conversation context with depth limit
const lastPost = posts[posts.length - 1];
const context = await getConversationContext(lastPost.id, maxDepth);
// Context should not exceed maxDepth
expect(context.length).toBeLessThanOrEqual(maxDepth);
}
),
{ numRuns: 100 }
);
});
it('conversation context includes post content and author info', async () => {
await fc.assert(
fc.asyncProperty(
userHandleArb,
postContentArb,
async (userHandle, postContent) => {
// Reset store
__resetStore();
// Create user and post
const user = __addUser(userHandle);
const post = __addPost(user.id, postContent);
// Get conversation context
const context = await getConversationContext(post.id);
// Context should include the post with content and author
expect(context.length).toBeGreaterThan(0);
const contextPost = context[0];
expect(contextPost.content).toBe(postContent);
expect(contextPost.author).toBeDefined();
expect(contextPost.author?.handle).toBe(userHandle);
}
),
{ numRuns: 100 }
);
});
});
describe('Feature: bot-system, Property 24: Mention Chronological Processing', () => {
/**
* Property 24: Mention Chronological Processing
*
* *For any* bot with multiple unprocessed mentions, processing SHALL occur
* in chronological order (oldest first).
*
* **Validates: Requirements 7.5**
*/
it('getUnprocessedMentions returns mentions in chronological order', async () => {
await fc.assert(
fc.asyncProperty(
botHandleArb,
userHandleArb,
fc.integer({ min: 2, max: 10 }),
async (botHandle, userHandle, numMentions) => {
// Reset store
__resetStore();
// Create bot and user
const user = __addUser(userHandle);
const bot = __addBot(botHandle, user.id);
// Create mentions with different timestamps
const createdMentions = [];
for (let i = 0; i < numMentions; i++) {
const mention = await storeMention({
botId: bot.id,
postId: `post-${i}`,
authorId: user.id,
content: `Mention ${i}`,
});
createdMentions.push(mention);
// Small delay to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 10));
}
// Get unprocessed mentions
const mentions = await getUnprocessedMentions(bot.id);
// Should be in chronological order (oldest first)
for (let i = 1; i < mentions.length; i++) {
expect(mentions[i].createdAt.getTime()).toBeGreaterThanOrEqual(
mentions[i - 1].createdAt.getTime()
);
}
}
),
{ numRuns: 50 } // Reduced runs due to setTimeout
);
});
it('only unprocessed mentions are returned by getUnprocessedMentions', async () => {
await fc.assert(
fc.asyncProperty(
botHandleArb,
userHandleArb,
fc.integer({ min: 2, max: 5 }),
async (botHandle, userHandle, numMentions) => {
// Reset store
__resetStore();
// Create bot and user
const user = __addUser(userHandle);
const bot = __addBot(botHandle, user.id);
// Set current bot ID and filter flag
__setCurrentBotId(bot.id);
__setFilterUnprocessedOnly(true);
// Create some processed and some unprocessed mentions
let unprocessedCount = 0;
for (let i = 0; i < numMentions; i++) {
const mention = await storeMention({
botId: bot.id,
postId: `post-${i}`,
authorId: user.id,
content: `Mention ${i}`,
});
// Mark some as processed
if (i % 2 === 0) {
const mentionInStore = __getMentionsStore().get(mention.id);
if (mentionInStore) {
mentionInStore.isProcessed = true;
mentionInStore.processedAt = new Date();
}
} else {
unprocessedCount++;
}
}
// Get unprocessed mentions
const mentions = await getUnprocessedMentions(bot.id);
// Should only return unprocessed mentions
for (const mention of mentions) {
expect(mention.isProcessed).toBe(false);
}
// Count should match unprocessed count
expect(mentions.length).toBe(unprocessedCount);
}
),
{ numRuns: 100 }
);
});
it('getAllMentions returns both processed and unprocessed mentions', async () => {
await fc.assert(
fc.asyncProperty(
botHandleArb,
userHandleArb,
fc.integer({ min: 2, max: 5 }),
async (botHandle, userHandle, numMentions) => {
// Reset store
__resetStore();
// Create bot and user
const user = __addUser(userHandle);
const bot = __addBot(botHandle, user.id);
// Create mentions with mixed processed status
for (let i = 0; i < numMentions; i++) {
const mention = await storeMention({
botId: bot.id,
postId: `post-${i}`,
authorId: user.id,
content: `Mention ${i}`,
});
// Mark some as processed
if (i % 2 === 0) {
const mentionInStore = __getMentionsStore().get(mention.id);
if (mentionInStore) {
mentionInStore.isProcessed = true;
mentionInStore.processedAt = new Date();
}
}
}
// Get all mentions
const allMentions = await getAllMentions(bot.id);
// Should return all mentions
expect(allMentions.length).toBe(numMentions);
// Should include both processed and unprocessed
const hasProcessed = allMentions.some(m => m.isProcessed);
const hasUnprocessed = allMentions.some(m => !m.isProcessed);
if (numMentions >= 2) {
expect(hasProcessed).toBe(true);
expect(hasUnprocessed).toBe(true);
}
}
),
{ numRuns: 100 }
);
});
});
+636
View File
@@ -0,0 +1,636 @@
/**
* Mention Handler Module
*
* Detects mentions of bots in posts and manages mention responses.
* Processes mentions in chronological order and respects reply rate limits.
*
* Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
*/
import { db, bots, botMentions, posts, users } from '@/db';
import { eq, and, desc, asc, isNull } from 'drizzle-orm';
import { ContentGenerator, type Bot as GeneratorBot, type Post as GeneratorPost } from './contentGenerator';
import { canReply, recordReply } from './rateLimiter';
import { decryptApiKey } from './encryption';
// ============================================
// TYPES
// ============================================
/**
* Mention data structure.
*/
export interface Mention {
id: string;
botId: string;
postId: string;
authorId: string;
content: string;
isProcessed: boolean;
processedAt: Date | null;
responsePostId: string | null;
isRemote: boolean;
remoteActorUrl: string | null;
createdAt: Date;
}
/**
* Post data with author information.
*/
export interface PostWithAuthor {
id: string;
userId: string;
content: string;
replyToId: string | null;
createdAt: Date;
author: {
id: string;
handle: string;
displayName: string | null;
};
}
/**
* Mention detection result.
*/
export interface MentionDetectionResult {
detected: boolean;
mentions: Mention[];
}
/**
* Mention response result.
*/
export interface MentionResponseResult {
success: boolean;
responsePostId?: string;
error?: string;
}
/**
* Error thrown by mention handler operations.
*/
export class MentionHandlerError extends Error {
constructor(
message: string,
public code: MentionHandlerErrorCode,
public cause?: Error
) {
super(message);
this.name = 'MentionHandlerError';
}
}
export type MentionHandlerErrorCode =
| 'BOT_NOT_FOUND'
| 'MENTION_NOT_FOUND'
| 'POST_NOT_FOUND'
| 'RATE_LIMITED'
| 'GENERATION_FAILED'
| 'DATABASE_ERROR';
// ============================================
// MENTION DETECTION
// ============================================
/**
* Detect mentions of a bot in posts.
* Scans posts for the bot's handle and creates mention records.
*
* @param botId - The ID of the bot to check for mentions
* @returns Detection result with found mentions
*
* Validates: Requirements 7.1, 7.2
*/
export async function detectMentions(botId: string): Promise<MentionDetectionResult> {
try {
// Get the bot's handle
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
with: {
user: {
columns: { handle: true },
},
},
columns: { id: true },
});
if (!bot) {
throw new MentionHandlerError(
`Bot not found: ${botId}`,
'BOT_NOT_FOUND'
);
}
// Get existing mention post IDs to avoid duplicates
const existingMentions = await db.query.botMentions.findMany({
where: eq(botMentions.botId, botId),
columns: { postId: true },
});
const existingPostIds = new Set(existingMentions.map(m => m.postId));
// Find posts that mention the bot's handle
// Note: In a production system, this would be more efficient with full-text search
// or a dedicated mentions table updated on post creation
const mentionPattern = `@${bot.user.handle}`;
// Get recent posts (last 24 hours) that might contain mentions
const oneDayAgo = new Date();
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
const recentPosts = await db.query.posts.findMany({
where: and(
eq(posts.isRemoved, false)
),
with: {
author: {
columns: {
id: true,
handle: true,
displayName: true,
},
},
},
orderBy: [desc(posts.createdAt)],
limit: 1000, // Reasonable limit for scanning
});
// Filter posts that mention the bot and aren't already tracked
const newMentionPosts = recentPosts.filter(post =>
post.content.includes(mentionPattern) &&
!existingPostIds.has(post.id)
);
// Create mention records
const newMentions: Mention[] = [];
for (const post of newMentionPosts) {
const [mention] = await db.insert(botMentions).values({
botId,
postId: post.id,
authorId: post.userId,
content: post.content,
isProcessed: false,
isRemote: false, // Local mentions for now
}).returning();
newMentions.push({
id: mention.id,
botId: mention.botId,
postId: mention.postId,
authorId: mention.authorId,
content: mention.content,
isProcessed: mention.isProcessed,
processedAt: mention.processedAt,
responsePostId: mention.responsePostId,
isRemote: mention.isRemote,
remoteActorUrl: mention.remoteActorUrl,
createdAt: mention.createdAt,
});
}
return {
detected: newMentions.length > 0,
mentions: newMentions,
};
} catch (error) {
if (error instanceof MentionHandlerError) {
throw error;
}
throw new MentionHandlerError(
`Failed to detect mentions: ${error instanceof Error ? error.message : String(error)}`,
'DATABASE_ERROR',
error instanceof Error ? error : undefined
);
}
}
/**
* Get unprocessed mentions for a bot in chronological order.
*
* @param botId - The ID of the bot
* @returns Array of unprocessed mentions, oldest first
*
* Validates: Requirements 7.5
*/
export async function getUnprocessedMentions(botId: string): Promise<Mention[]> {
try {
const mentions = await db.query.botMentions.findMany({
where: and(
eq(botMentions.botId, botId),
eq(botMentions.isProcessed, false)
),
orderBy: [asc(botMentions.createdAt)], // Chronological order (oldest first)
});
return mentions.map(m => ({
id: m.id,
botId: m.botId,
postId: m.postId,
authorId: m.authorId,
content: m.content,
isProcessed: m.isProcessed,
processedAt: m.processedAt,
responsePostId: m.responsePostId,
isRemote: m.isRemote,
remoteActorUrl: m.remoteActorUrl,
createdAt: m.createdAt,
}));
} catch (error) {
throw new MentionHandlerError(
`Failed to get unprocessed mentions: ${error instanceof Error ? error.message : String(error)}`,
'DATABASE_ERROR',
error instanceof Error ? error : undefined
);
}
}
/**
* Get all mentions for a bot (processed and unprocessed).
*
* @param botId - The ID of the bot
* @returns Array of all mentions, newest first
*/
export async function getAllMentions(botId: string): Promise<Mention[]> {
try {
const mentions = await db.query.botMentions.findMany({
where: eq(botMentions.botId, botId),
orderBy: [desc(botMentions.createdAt)],
});
return mentions.map(m => ({
id: m.id,
botId: m.botId,
postId: m.postId,
authorId: m.authorId,
content: m.content,
isProcessed: m.isProcessed,
processedAt: m.processedAt,
responsePostId: m.responsePostId,
isRemote: m.isRemote,
remoteActorUrl: m.remoteActorUrl,
createdAt: m.createdAt,
}));
} catch (error) {
throw new MentionHandlerError(
`Failed to get mentions: ${error instanceof Error ? error.message : String(error)}`,
'DATABASE_ERROR',
error instanceof Error ? error : undefined
);
}
}
// ============================================
// CONVERSATION CONTEXT
// ============================================
/**
* Get conversation context for a mention.
* Retrieves the thread of posts leading up to the mention.
*
* @param postId - The ID of the post containing the mention
* @param maxDepth - Maximum number of parent posts to retrieve
* @returns Array of posts in the conversation thread
*
* Validates: Requirements 7.4
*/
export async function getConversationContext(
postId: string,
maxDepth: number = 5
): Promise<GeneratorPost[]> {
try {
const context: GeneratorPost[] = [];
let currentPostId: string | null = postId;
let depth = 0;
while (currentPostId && depth < maxDepth) {
const post: any = await db.query.posts.findFirst({
where: eq(posts.id, currentPostId),
with: {
author: {
columns: {
handle: true,
displayName: true,
},
},
},
});
if (!post) break;
// Add to context (we'll reverse later to get chronological order)
context.push({
id: post.id,
userId: post.userId,
content: post.content,
createdAt: post.createdAt,
author: {
handle: post.author.handle,
displayName: post.author.displayName,
},
});
// Move to parent post
currentPostId = post.replyToId;
depth++;
}
// Reverse to get chronological order (oldest first)
return context.reverse();
} catch (error) {
throw new MentionHandlerError(
`Failed to get conversation context: ${error instanceof Error ? error.message : String(error)}`,
'DATABASE_ERROR',
error instanceof Error ? error : undefined
);
}
}
// ============================================
// MENTION RESPONSE
// ============================================
/**
* Process a mention and generate a response.
* Checks rate limits, generates reply using LLM, and creates response post.
*
* @param mentionId - The ID of the mention to process
* @returns Response result with post ID or error
*
* Validates: Requirements 7.3, 7.4, 7.6
*/
export async function processMention(mentionId: string): Promise<MentionResponseResult> {
try {
// Get the mention
const mention = await db.query.botMentions.findFirst({
where: eq(botMentions.id, mentionId),
});
if (!mention) {
throw new MentionHandlerError(
`Mention not found: ${mentionId}`,
'MENTION_NOT_FOUND'
);
}
// Check if already processed
if (mention.isProcessed) {
return {
success: true,
responsePostId: mention.responsePostId || undefined,
};
}
// Check rate limits (Requirement 7.6)
const rateLimitCheck = await canReply(mention.botId);
if (!rateLimitCheck.allowed) {
throw new MentionHandlerError(
rateLimitCheck.reason || 'Rate limit exceeded',
'RATE_LIMITED'
);
}
// Get the bot
const bot = await db.query.bots.findFirst({
where: eq(bots.id, mention.botId),
with: {
user: {
columns: {
id: true,
handle: true,
},
},
},
});
if (!bot) {
throw new MentionHandlerError(
`Bot not found: ${mention.botId}`,
'BOT_NOT_FOUND'
);
}
// Get the mentioning post with author info
const mentionPost = await db.query.posts.findFirst({
where: eq(posts.id, mention.postId),
with: {
author: {
columns: {
handle: true,
displayName: true,
},
},
},
});
if (!mentionPost) {
throw new MentionHandlerError(
`Post not found: ${mention.postId}`,
'POST_NOT_FOUND'
);
}
// Get conversation context (Requirement 7.4)
const conversationContext = await getConversationContext(mention.postId);
// Remove the mention post itself from context (it will be passed separately)
const contextWithoutMention = conversationContext.filter(p => p.id !== mention.postId);
// Prepare bot for content generator
const generatorBot: GeneratorBot = {
id: bot.id,
name: bot.name,
handle: bot.user.handle,
personalityConfig: JSON.parse(bot.personalityConfig),
llmProvider: bot.llmProvider as any,
llmModel: bot.llmModel,
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
};
// Prepare mention post for generator
const generatorMentionPost: GeneratorPost = {
id: mentionPost.id,
userId: mentionPost.userId,
content: mentionPost.content,
createdAt: mentionPost.createdAt,
author: {
handle: mentionPost.author.handle,
displayName: mentionPost.author.displayName,
},
};
// Generate reply (Requirement 7.3)
const generator = new ContentGenerator(generatorBot);
const generatedReply = await generator.generateReply(
generatorMentionPost,
contextWithoutMention
);
// Create response post
const [responsePost] = await db.insert(posts).values({
userId: bot.user.id, // Bot posts as its associated user
content: generatedReply.text,
replyToId: mention.postId,
}).returning();
// Mark mention as processed
await db.update(botMentions)
.set({
isProcessed: true,
processedAt: new Date(),
responsePostId: responsePost.id,
})
.where(eq(botMentions.id, mentionId));
// Record reply for rate limiting
await recordReply(mention.botId);
return {
success: true,
responsePostId: responsePost.id,
};
} catch (error) {
if (error instanceof MentionHandlerError) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Process all unprocessed mentions for a bot in chronological order.
* Stops if rate limit is reached.
*
* @param botId - The ID of the bot
* @returns Array of response results
*
* Validates: Requirements 7.5, 7.6
*/
export async function processAllMentions(botId: string): Promise<MentionResponseResult[]> {
const mentions = await getUnprocessedMentions(botId);
const results: MentionResponseResult[] = [];
for (const mention of mentions) {
// Check rate limit before processing each mention
const rateLimitCheck = await canReply(botId);
if (!rateLimitCheck.allowed) {
// Stop processing if rate limited
results.push({
success: false,
error: rateLimitCheck.reason,
});
break;
}
const result = await processMention(mention.id);
results.push(result);
// Stop if processing failed
if (!result.success) {
break;
}
}
return results;
}
// ============================================
// MENTION STORAGE
// ============================================
/**
* Store a detected mention in the database.
* Used when mentions are detected from external sources (e.g., ActivityPub).
*
* @param data - Mention data to store
* @returns Created mention
*
* Validates: Requirements 7.1, 7.2
*/
export async function storeMention(data: {
botId: string;
postId: string;
authorId: string;
content: string;
isRemote?: boolean;
remoteActorUrl?: string;
}): Promise<Mention> {
try {
const [mention] = await db.insert(botMentions).values({
botId: data.botId,
postId: data.postId,
authorId: data.authorId,
content: data.content,
isProcessed: false,
isRemote: data.isRemote || false,
remoteActorUrl: data.remoteActorUrl || null,
}).returning();
return {
id: mention.id,
botId: mention.botId,
postId: mention.postId,
authorId: mention.authorId,
content: mention.content,
isProcessed: mention.isProcessed,
processedAt: mention.processedAt,
responsePostId: mention.responsePostId,
isRemote: mention.isRemote,
remoteActorUrl: mention.remoteActorUrl,
createdAt: mention.createdAt,
};
} catch (error) {
throw new MentionHandlerError(
`Failed to store mention: ${error instanceof Error ? error.message : String(error)}`,
'DATABASE_ERROR',
error instanceof Error ? error : undefined
);
}
}
/**
* Get a mention by ID.
*
* @param mentionId - The ID of the mention
* @returns Mention data or null if not found
*/
export async function getMentionById(mentionId: string): Promise<Mention | null> {
try {
const mention = await db.query.botMentions.findFirst({
where: eq(botMentions.id, mentionId),
});
if (!mention) {
return null;
}
return {
id: mention.id,
botId: mention.botId,
postId: mention.postId,
authorId: mention.authorId,
content: mention.content,
isProcessed: mention.isProcessed,
processedAt: mention.processedAt,
responsePostId: mention.responsePostId,
isRemote: mention.isRemote,
remoteActorUrl: mention.remoteActorUrl,
createdAt: mention.createdAt,
};
} catch (error) {
throw new MentionHandlerError(
`Failed to get mention: ${error instanceof Error ? error.message : String(error)}`,
'DATABASE_ERROR',
error instanceof Error ? error : undefined
);
}
}
+439
View File
@@ -0,0 +1,439 @@
/**
* Property-Based Tests for Personality Configuration Module
*
* Feature: bot-system
* - Property 9: Personality Configuration Persistence
*
* Tests the personality configuration storage and retrieval using fast-check
* for property-based testing.
*
* **Validates: Requirements 3.1, 3.3, 3.4**
*/
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import {
PersonalityConfig,
serializePersonalityConfig,
deserializePersonalityConfig,
validatePersonalityConfig,
isValidPersonalityConfig,
MIN_SYSTEM_PROMPT_LENGTH,
MAX_SYSTEM_PROMPT_LENGTH,
MIN_TEMPERATURE,
MAX_TEMPERATURE,
MIN_MAX_TOKENS,
MAX_MAX_TOKENS,
} from './personality';
// ============================================
// GENERATORS
// ============================================
/**
* Generator for valid system prompts.
* System prompts must be between MIN_SYSTEM_PROMPT_LENGTH and MAX_SYSTEM_PROMPT_LENGTH characters.
*/
const systemPromptArb = fc.string({
minLength: MIN_SYSTEM_PROMPT_LENGTH,
maxLength: Math.min(MAX_SYSTEM_PROMPT_LENGTH, 1000), // Cap at 1000 for test performance
}).filter(s => s.trim().length >= MIN_SYSTEM_PROMPT_LENGTH);
/**
* Generator for valid temperature values.
* Temperature must be between MIN_TEMPERATURE (0) and MAX_TEMPERATURE (2).
*/
const temperatureArb = fc.double({
min: MIN_TEMPERATURE,
max: MAX_TEMPERATURE,
noNaN: true,
noDefaultInfinity: true,
});
/**
* Generator for valid maxTokens values.
* MaxTokens must be an integer between MIN_MAX_TOKENS (1) and MAX_MAX_TOKENS (100000).
*/
const maxTokensArb = fc.integer({
min: MIN_MAX_TOKENS,
max: MAX_MAX_TOKENS,
});
/**
* Generator for valid response styles (optional).
* Response style must be a non-empty string of 100 characters or less if provided.
*/
const responseStyleArb = fc.option(
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
{ nil: undefined }
);
/**
* Generator for valid personality configurations.
* Combines all field generators to create complete valid configurations.
*/
const validPersonalityConfigArb: fc.Arbitrary<PersonalityConfig> = fc.record({
systemPrompt: systemPromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
responseStyle: responseStyleArb,
});
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 9: Personality Configuration Persistence', () => {
/**
* Property 9: Personality Configuration Persistence
*
* *For any* valid personality configuration, storing then retrieving the configuration
* SHALL produce an equivalent configuration object.
*
* **Validates: Requirements 3.1, 3.3, 3.4**
*/
it('serializing then deserializing a valid config produces an equivalent config', async () => {
await fc.assert(
fc.asyncProperty(validPersonalityConfigArb, async (config) => {
// Verify the generated config is valid
expect(isValidPersonalityConfig(config)).toBe(true);
// Serialize the configuration (simulates storage)
const serialized = serializePersonalityConfig(config);
// Deserialize the configuration (simulates retrieval)
const deserialized = deserializePersonalityConfig(serialized);
// The deserialized config should be equivalent to the original
expect(deserialized.systemPrompt).toBe(config.systemPrompt);
expect(deserialized.temperature).toBe(config.temperature);
expect(deserialized.maxTokens).toBe(config.maxTokens);
expect(deserialized.responseStyle).toBe(config.responseStyle);
}),
{ numRuns: 100 }
);
});
it('serialized config is a valid JSON string', async () => {
await fc.assert(
fc.asyncProperty(validPersonalityConfigArb, async (config) => {
// Serialize the configuration
const serialized = serializePersonalityConfig(config);
// The serialized value should be a string
expect(typeof serialized).toBe('string');
// The serialized value should be valid JSON
expect(() => JSON.parse(serialized)).not.toThrow();
}),
{ numRuns: 100 }
);
});
it('deserialized config passes validation', async () => {
await fc.assert(
fc.asyncProperty(validPersonalityConfigArb, async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// The deserialized config should pass validation
const validationResult = validatePersonalityConfig(deserialized);
expect(validationResult.valid).toBe(true);
expect(validationResult.errors).toHaveLength(0);
}),
{ numRuns: 100 }
);
});
it('round-trip preserves systemPrompt exactly (Requirement 3.1)', async () => {
await fc.assert(
fc.asyncProperty(validPersonalityConfigArb, async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// System prompt should be preserved exactly
// This validates Requirement 3.1: storing personality prompt
expect(deserialized.systemPrompt).toBe(config.systemPrompt);
expect(deserialized.systemPrompt.length).toBe(config.systemPrompt.length);
}),
{ numRuns: 100 }
);
});
it('round-trip preserves temperature exactly (Requirement 3.4)', async () => {
await fc.assert(
fc.asyncProperty(validPersonalityConfigArb, async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// Temperature should be preserved exactly
// This validates Requirement 3.4: temperature and other LLM parameters
expect(deserialized.temperature).toBe(config.temperature);
}),
{ numRuns: 100 }
);
});
it('round-trip preserves maxTokens exactly (Requirement 3.4)', async () => {
await fc.assert(
fc.asyncProperty(validPersonalityConfigArb, async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// MaxTokens should be preserved exactly
// This validates Requirement 3.4: other LLM parameters
expect(deserialized.maxTokens).toBe(config.maxTokens);
}),
{ numRuns: 100 }
);
});
it('round-trip preserves responseStyle when present (Requirement 3.4)', async () => {
// Use a generator that always includes responseStyle
const configWithStyleArb = fc.record({
systemPrompt: systemPromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
responseStyle: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
});
await fc.assert(
fc.asyncProperty(configWithStyleArb, async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// ResponseStyle should be preserved exactly
expect(deserialized.responseStyle).toBe(config.responseStyle);
}),
{ numRuns: 100 }
);
});
it('round-trip preserves undefined responseStyle', async () => {
// Use a generator that never includes responseStyle
const configWithoutStyleArb = fc.record({
systemPrompt: systemPromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
});
await fc.assert(
fc.asyncProperty(configWithoutStyleArb, async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// ResponseStyle should remain undefined
expect(deserialized.responseStyle).toBeUndefined();
}),
{ numRuns: 100 }
);
});
it('multiple round-trips produce identical results (idempotency)', async () => {
await fc.assert(
fc.asyncProperty(validPersonalityConfigArb, async (config) => {
// First round-trip
const serialized1 = serializePersonalityConfig(config);
const deserialized1 = deserializePersonalityConfig(serialized1);
// Second round-trip
const serialized2 = serializePersonalityConfig(deserialized1);
const deserialized2 = deserializePersonalityConfig(serialized2);
// Third round-trip
const serialized3 = serializePersonalityConfig(deserialized2);
const deserialized3 = deserializePersonalityConfig(serialized3);
// All deserialized configs should be equivalent
expect(deserialized1.systemPrompt).toBe(deserialized2.systemPrompt);
expect(deserialized2.systemPrompt).toBe(deserialized3.systemPrompt);
expect(deserialized1.temperature).toBe(deserialized2.temperature);
expect(deserialized2.temperature).toBe(deserialized3.temperature);
expect(deserialized1.maxTokens).toBe(deserialized2.maxTokens);
expect(deserialized2.maxTokens).toBe(deserialized3.maxTokens);
expect(deserialized1.responseStyle).toBe(deserialized2.responseStyle);
expect(deserialized2.responseStyle).toBe(deserialized3.responseStyle);
}),
{ numRuns: 100 }
);
});
it('config changes are reflected after re-serialization (Requirement 3.3)', async () => {
await fc.assert(
fc.asyncProperty(
validPersonalityConfigArb,
validPersonalityConfigArb,
async (originalConfig, updatedConfig) => {
// Serialize original config
const originalSerialized = serializePersonalityConfig(originalConfig);
// Simulate updating the config (Requirement 3.3: updates apply to future actions)
const updatedSerialized = serializePersonalityConfig(updatedConfig);
// Deserialize the updated config
const deserialized = deserializePersonalityConfig(updatedSerialized);
// The deserialized config should match the updated config, not the original
expect(deserialized.systemPrompt).toBe(updatedConfig.systemPrompt);
expect(deserialized.temperature).toBe(updatedConfig.temperature);
expect(deserialized.maxTokens).toBe(updatedConfig.maxTokens);
expect(deserialized.responseStyle).toBe(updatedConfig.responseStyle);
}
),
{ numRuns: 100 }
);
});
it('serialized configs with same values produce equivalent JSON', async () => {
await fc.assert(
fc.asyncProperty(validPersonalityConfigArb, async (config) => {
// Create a copy of the config
const configCopy: PersonalityConfig = {
systemPrompt: config.systemPrompt,
temperature: config.temperature,
maxTokens: config.maxTokens,
responseStyle: config.responseStyle,
};
// Serialize both
const serialized1 = serializePersonalityConfig(config);
const serialized2 = serializePersonalityConfig(configCopy);
// The serialized values should be identical
expect(serialized1).toBe(serialized2);
}),
{ numRuns: 100 }
);
});
it('handles special characters in systemPrompt correctly', async () => {
// Generator for system prompts with special characters
const specialChars = ['\n', '\t', '\r', '"', '\\', '/', '<', '>', '&', "'", '`', '{', '}', '[', ']'];
const specialCharPromptArb = fc.array(
fc.oneof(
fc.string({ minLength: 1, maxLength: 10 }),
fc.constantFrom(...specialChars)
),
{ minLength: MIN_SYSTEM_PROMPT_LENGTH, maxLength: 100 }
).map(arr => arr.join('')).filter(s => s.trim().length >= MIN_SYSTEM_PROMPT_LENGTH);
await fc.assert(
fc.asyncProperty(
fc.record({
systemPrompt: specialCharPromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
}),
async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// System prompt with special characters should be preserved
expect(deserialized.systemPrompt).toBe(config.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('handles unicode characters in systemPrompt correctly', async () => {
// Generator for system prompts with unicode characters
// Using a mix of ASCII and common unicode characters
const unicodeChars = ['é', 'ñ', 'ü', 'ö', 'ä', '中', '文', '日', '本', '語', '한', '글', '🤖', '👍', '🎉', '→', '←', '↑', '↓', '•', '©', '®', '™'];
const unicodePromptArb = fc.array(
fc.oneof(
fc.string({ minLength: 1, maxLength: 20 }),
fc.constantFrom(...unicodeChars)
),
{ minLength: MIN_SYSTEM_PROMPT_LENGTH, maxLength: 100 }
).map(arr => arr.join('')).filter(s => s.trim().length >= MIN_SYSTEM_PROMPT_LENGTH);
await fc.assert(
fc.asyncProperty(
fc.record({
systemPrompt: unicodePromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
}),
async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// System prompt with unicode should be preserved
expect(deserialized.systemPrompt).toBe(config.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('handles boundary temperature values correctly', async () => {
// Test with boundary temperature values
const boundaryTemperatureArb = fc.oneof(
fc.constant(MIN_TEMPERATURE),
fc.constant(MAX_TEMPERATURE),
fc.constant((MIN_TEMPERATURE + MAX_TEMPERATURE) / 2),
temperatureArb
);
await fc.assert(
fc.asyncProperty(
fc.record({
systemPrompt: systemPromptArb,
temperature: boundaryTemperatureArb,
maxTokens: maxTokensArb,
}),
async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// Temperature should be preserved exactly
expect(deserialized.temperature).toBe(config.temperature);
}
),
{ numRuns: 100 }
);
});
it('handles boundary maxTokens values correctly', async () => {
// Test with boundary maxTokens values
const boundaryMaxTokensArb = fc.oneof(
fc.constant(MIN_MAX_TOKENS),
fc.constant(MAX_MAX_TOKENS),
fc.constant(Math.floor((MIN_MAX_TOKENS + MAX_MAX_TOKENS) / 2)),
maxTokensArb
);
await fc.assert(
fc.asyncProperty(
fc.record({
systemPrompt: systemPromptArb,
temperature: temperatureArb,
maxTokens: boundaryMaxTokensArb,
}),
async (config) => {
// Serialize then deserialize
const serialized = serializePersonalityConfig(config);
const deserialized = deserializePersonalityConfig(serialized);
// MaxTokens should be preserved exactly
expect(deserialized.maxTokens).toBe(config.maxTokens);
}
),
{ numRuns: 100 }
);
});
});
+464
View File
@@ -0,0 +1,464 @@
/**
* Unit tests for Personality Configuration Module
*
* Tests validation, serialization, and prompt building functionality.
*/
import { describe, it, expect } from 'vitest';
import {
validateSystemPrompt,
validateTemperature,
validateMaxTokens,
validateResponseStyle,
validatePersonalityConfig,
isValidPersonalityConfig,
serializePersonalityConfig,
deserializePersonalityConfig,
buildPromptWithPersonality,
getPersonalityPreset,
getAllPersonalityPresets,
createDefaultPersonalityConfig,
PersonalityConfig,
PersonalityError,
MIN_SYSTEM_PROMPT_LENGTH,
MAX_SYSTEM_PROMPT_LENGTH,
MIN_TEMPERATURE,
MAX_TEMPERATURE,
MIN_MAX_TOKENS,
MAX_MAX_TOKENS,
} from './personality';
describe('Personality Configuration Module', () => {
// ============================================
// SYSTEM PROMPT VALIDATION
// ============================================
describe('validateSystemPrompt', () => {
it('should accept valid system prompts', () => {
const errors = validateSystemPrompt('You are a helpful assistant bot.');
expect(errors).toHaveLength(0);
});
it('should reject undefined system prompt', () => {
const errors = validateSystemPrompt(undefined);
expect(errors).toContain('System prompt is required');
});
it('should reject null system prompt', () => {
const errors = validateSystemPrompt(null);
expect(errors).toContain('System prompt is required');
});
it('should reject non-string system prompt', () => {
const errors = validateSystemPrompt(123);
expect(errors).toContain('System prompt must be a string');
});
it('should reject system prompt that is too short', () => {
const errors = validateSystemPrompt('Hi');
expect(errors).toContain(`System prompt must be at least ${MIN_SYSTEM_PROMPT_LENGTH} characters`);
});
it('should reject system prompt that is too long', () => {
const longPrompt = 'a'.repeat(MAX_SYSTEM_PROMPT_LENGTH + 1);
const errors = validateSystemPrompt(longPrompt);
expect(errors).toContain(`System prompt must be ${MAX_SYSTEM_PROMPT_LENGTH} characters or less`);
});
it('should accept system prompt at minimum length', () => {
const minPrompt = 'a'.repeat(MIN_SYSTEM_PROMPT_LENGTH);
const errors = validateSystemPrompt(minPrompt);
expect(errors).toHaveLength(0);
});
it('should accept system prompt at maximum length', () => {
const maxPrompt = 'a'.repeat(MAX_SYSTEM_PROMPT_LENGTH);
const errors = validateSystemPrompt(maxPrompt);
expect(errors).toHaveLength(0);
});
});
// ============================================
// TEMPERATURE VALIDATION
// ============================================
describe('validateTemperature', () => {
it('should accept valid temperature values', () => {
expect(validateTemperature(0)).toHaveLength(0);
expect(validateTemperature(0.5)).toHaveLength(0);
expect(validateTemperature(1)).toHaveLength(0);
expect(validateTemperature(1.5)).toHaveLength(0);
expect(validateTemperature(2)).toHaveLength(0);
});
it('should reject undefined temperature', () => {
const errors = validateTemperature(undefined);
expect(errors).toContain('Temperature is required');
});
it('should reject null temperature', () => {
const errors = validateTemperature(null);
expect(errors).toContain('Temperature is required');
});
it('should reject non-number temperature', () => {
const errors = validateTemperature('0.5');
expect(errors).toContain('Temperature must be a number');
});
it('should reject NaN temperature', () => {
const errors = validateTemperature(NaN);
expect(errors).toContain('Temperature must be a valid number');
});
it('should reject temperature below minimum', () => {
const errors = validateTemperature(-0.1);
expect(errors).toContain(`Temperature must be at least ${MIN_TEMPERATURE}`);
});
it('should reject temperature above maximum', () => {
const errors = validateTemperature(2.1);
expect(errors).toContain(`Temperature must be at most ${MAX_TEMPERATURE}`);
});
});
// ============================================
// MAX TOKENS VALIDATION
// ============================================
describe('validateMaxTokens', () => {
it('should accept valid maxTokens values', () => {
expect(validateMaxTokens(1)).toHaveLength(0);
expect(validateMaxTokens(100)).toHaveLength(0);
expect(validateMaxTokens(1000)).toHaveLength(0);
expect(validateMaxTokens(100000)).toHaveLength(0);
});
it('should reject undefined maxTokens', () => {
const errors = validateMaxTokens(undefined);
expect(errors).toContain('Max tokens is required');
});
it('should reject null maxTokens', () => {
const errors = validateMaxTokens(null);
expect(errors).toContain('Max tokens is required');
});
it('should reject non-number maxTokens', () => {
const errors = validateMaxTokens('100');
expect(errors).toContain('Max tokens must be a number');
});
it('should reject NaN maxTokens', () => {
const errors = validateMaxTokens(NaN);
expect(errors).toContain('Max tokens must be a valid number');
});
it('should reject non-integer maxTokens', () => {
const errors = validateMaxTokens(100.5);
expect(errors).toContain('Max tokens must be an integer');
});
it('should reject maxTokens below minimum', () => {
const errors = validateMaxTokens(0);
expect(errors).toContain(`Max tokens must be at least ${MIN_MAX_TOKENS}`);
});
it('should reject maxTokens above maximum', () => {
const errors = validateMaxTokens(100001);
expect(errors).toContain(`Max tokens must be at most ${MAX_MAX_TOKENS}`);
});
});
// ============================================
// RESPONSE STYLE VALIDATION
// ============================================
describe('validateResponseStyle', () => {
it('should accept undefined response style (optional)', () => {
const errors = validateResponseStyle(undefined);
expect(errors).toHaveLength(0);
});
it('should accept null response style (optional)', () => {
const errors = validateResponseStyle(null);
expect(errors).toHaveLength(0);
});
it('should accept valid response styles', () => {
expect(validateResponseStyle('formal')).toHaveLength(0);
expect(validateResponseStyle('casual')).toHaveLength(0);
expect(validateResponseStyle('custom style')).toHaveLength(0);
});
it('should reject non-string response style', () => {
const errors = validateResponseStyle(123);
expect(errors).toContain('Response style must be a string');
});
it('should reject empty response style', () => {
const errors = validateResponseStyle('');
expect(errors).toContain('Response style cannot be empty if provided');
});
it('should reject whitespace-only response style', () => {
const errors = validateResponseStyle(' ');
expect(errors).toContain('Response style cannot be empty if provided');
});
it('should reject response style that is too long', () => {
const longStyle = 'a'.repeat(101);
const errors = validateResponseStyle(longStyle);
expect(errors).toContain('Response style must be 100 characters or less');
});
});
// ============================================
// COMPLETE CONFIG VALIDATION
// ============================================
describe('validatePersonalityConfig', () => {
const validConfig: PersonalityConfig = {
systemPrompt: 'You are a helpful assistant bot.',
temperature: 0.7,
maxTokens: 500,
};
it('should accept valid configuration', () => {
const result = validatePersonalityConfig(validConfig);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should accept valid configuration with response style', () => {
const result = validatePersonalityConfig({
...validConfig,
responseStyle: 'formal',
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should reject non-object configuration', () => {
const result = validatePersonalityConfig('not an object');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Personality configuration must be an object');
});
it('should reject null configuration', () => {
const result = validatePersonalityConfig(null);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Personality configuration must be an object');
});
it('should collect all validation errors', () => {
const result = validatePersonalityConfig({
systemPrompt: 'Hi', // too short
temperature: 3, // too high
maxTokens: 0, // too low
responseStyle: '', // empty
});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(1);
});
});
describe('isValidPersonalityConfig', () => {
it('should return true for valid config', () => {
const config: PersonalityConfig = {
systemPrompt: 'You are a helpful assistant bot.',
temperature: 0.7,
maxTokens: 500,
};
expect(isValidPersonalityConfig(config)).toBe(true);
});
it('should return false for invalid config', () => {
expect(isValidPersonalityConfig(null)).toBe(false);
expect(isValidPersonalityConfig({ systemPrompt: 'Hi' })).toBe(false);
});
});
// ============================================
// SERIALIZATION
// ============================================
describe('serializePersonalityConfig', () => {
it('should serialize config to JSON string', () => {
const config: PersonalityConfig = {
systemPrompt: 'You are a helpful assistant bot.',
temperature: 0.7,
maxTokens: 500,
};
const serialized = serializePersonalityConfig(config);
expect(typeof serialized).toBe('string');
expect(JSON.parse(serialized)).toEqual(config);
});
it('should preserve all fields including optional ones', () => {
const config: PersonalityConfig = {
systemPrompt: 'You are a helpful assistant bot.',
temperature: 0.7,
maxTokens: 500,
responseStyle: 'formal',
};
const serialized = serializePersonalityConfig(config);
const parsed = JSON.parse(serialized);
expect(parsed.responseStyle).toBe('formal');
});
});
describe('deserializePersonalityConfig', () => {
it('should deserialize valid JSON to config', () => {
const config: PersonalityConfig = {
systemPrompt: 'You are a helpful assistant bot.',
temperature: 0.7,
maxTokens: 500,
};
const json = JSON.stringify(config);
const deserialized = deserializePersonalityConfig(json);
expect(deserialized).toEqual(config);
});
it('should throw PersonalityError for invalid JSON', () => {
expect(() => deserializePersonalityConfig('not json')).toThrow(PersonalityError);
});
it('should throw PersonalityError for invalid config', () => {
const invalidJson = JSON.stringify({ systemPrompt: 'Hi' });
expect(() => deserializePersonalityConfig(invalidJson)).toThrow(PersonalityError);
});
});
// ============================================
// PROMPT BUILDING
// ============================================
describe('buildPromptWithPersonality', () => {
const personality: PersonalityConfig = {
systemPrompt: 'You are a helpful assistant bot.',
temperature: 0.7,
maxTokens: 500,
};
it('should build basic prompt with personality', () => {
const result = buildPromptWithPersonality(personality);
expect(result.systemMessage).toContain('You are a helpful assistant bot.');
expect(result.temperature).toBe(0.7);
expect(result.maxTokens).toBe(500);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('system');
});
it('should include response style in system message', () => {
const personalityWithStyle: PersonalityConfig = {
...personality,
responseStyle: 'formal',
};
const result = buildPromptWithPersonality(personalityWithStyle);
expect(result.systemMessage).toContain('Response Style: formal');
});
it('should include context in system message', () => {
const result = buildPromptWithPersonality(personality, {
context: 'This is additional context.',
});
expect(result.systemMessage).toContain('Additional Context:');
expect(result.systemMessage).toContain('This is additional context.');
});
it('should include user message', () => {
const result = buildPromptWithPersonality(personality, {
userMessage: 'Hello, how are you?',
});
expect(result.messages).toHaveLength(2);
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toBe('Hello, how are you?');
});
it('should include source content', () => {
const result = buildPromptWithPersonality(personality, {
sourceContent: {
title: 'Test Article',
content: 'Article content here.',
url: 'https://example.com/article',
},
});
expect(result.messages).toHaveLength(2);
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toContain('Test Article');
expect(result.messages[1].content).toContain('Article content here.');
expect(result.messages[1].content).toContain('https://example.com/article');
});
it('should include conversation history', () => {
const result = buildPromptWithPersonality(personality, {
conversationHistory: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
],
userMessage: 'How are you?',
});
expect(result.messages).toHaveLength(4);
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toBe('Hello');
expect(result.messages[2].role).toBe('assistant');
expect(result.messages[2].content).toBe('Hi there!');
expect(result.messages[3].role).toBe('user');
expect(result.messages[3].content).toBe('How are you?');
});
});
// ============================================
// PRESETS
// ============================================
describe('getPersonalityPreset', () => {
it('should return preset by ID', () => {
const preset = getPersonalityPreset('news-curator');
expect(preset).toBeDefined();
expect(preset?.name).toBe('News Curator');
});
it('should return undefined for unknown preset', () => {
const preset = getPersonalityPreset('unknown-preset');
expect(preset).toBeUndefined();
});
});
describe('getAllPersonalityPresets', () => {
it('should return all presets', () => {
const presets = getAllPersonalityPresets();
expect(presets.length).toBeGreaterThan(0);
expect(presets.every(p => p.id && p.name && p.config)).toBe(true);
});
it('should return a copy of presets array', () => {
const presets1 = getAllPersonalityPresets();
const presets2 = getAllPersonalityPresets();
expect(presets1).not.toBe(presets2);
});
it('should have valid configs for all presets', () => {
const presets = getAllPersonalityPresets();
for (const preset of presets) {
expect(isValidPersonalityConfig(preset.config)).toBe(true);
}
});
});
describe('createDefaultPersonalityConfig', () => {
it('should create a valid default config', () => {
const config = createDefaultPersonalityConfig();
expect(isValidPersonalityConfig(config)).toBe(true);
});
it('should have reasonable default values', () => {
const config = createDefaultPersonalityConfig();
expect(config.systemPrompt.length).toBeGreaterThan(0);
expect(config.temperature).toBeGreaterThanOrEqual(0);
expect(config.temperature).toBeLessThanOrEqual(2);
expect(config.maxTokens).toBeGreaterThan(0);
});
});
});
+637
View File
@@ -0,0 +1,637 @@
/**
* Personality Configuration Module
*
* Handles validation, storage, and retrieval of bot personality configurations.
* Provides utilities for building LLM prompts with personality context.
*
* Requirements: 3.1, 3.3, 3.4
*/
import { db, bots } from '@/db';
import { eq } from 'drizzle-orm';
// ============================================
// TYPES
// ============================================
/**
* Personality configuration for a bot.
* Defines the bot's voice, tone, and behavior patterns.
*
* Validates: Requirements 3.1, 3.4
*/
export interface PersonalityConfig {
/** System prompt that defines the bot's personality and behavior */
systemPrompt: string;
/** Temperature for LLM generation (0-2, higher = more creative) */
temperature: number;
/** Maximum tokens for LLM response */
maxTokens: number;
/** Optional response style descriptor (e.g., "formal", "casual", "technical") */
responseStyle?: string;
}
/**
* Validation result for personality configuration.
*/
export interface PersonalityValidationResult {
valid: boolean;
errors: string[];
}
/**
* Personality preset template.
*/
export interface PersonalityPreset {
id: string;
name: string;
description: string;
config: PersonalityConfig;
}
// ============================================
// CONSTANTS
// ============================================
/** Minimum system prompt length */
export const MIN_SYSTEM_PROMPT_LENGTH = 10;
/** Maximum system prompt length */
export const MAX_SYSTEM_PROMPT_LENGTH = 10000;
/** Minimum temperature value */
export const MIN_TEMPERATURE = 0;
/** Maximum temperature value */
export const MAX_TEMPERATURE = 2;
/** Minimum max tokens value */
export const MIN_MAX_TOKENS = 1;
/** Maximum max tokens value */
export const MAX_MAX_TOKENS = 100000;
/** Valid response styles */
export const VALID_RESPONSE_STYLES = [
'formal',
'casual',
'technical',
'friendly',
'professional',
'humorous',
'educational',
'concise',
'detailed',
] as const;
export type ResponseStyle = typeof VALID_RESPONSE_STYLES[number];
// ============================================
// VALIDATION FUNCTIONS
// ============================================
/**
* Validate a system prompt.
*
* @param systemPrompt - The system prompt to validate
* @returns Array of validation errors (empty if valid)
*/
export function validateSystemPrompt(systemPrompt: unknown): string[] {
const errors: string[] = [];
if (systemPrompt === undefined || systemPrompt === null) {
errors.push('System prompt is required');
return errors;
}
if (typeof systemPrompt !== 'string') {
errors.push('System prompt must be a string');
return errors;
}
const trimmed = systemPrompt.trim();
if (trimmed.length < MIN_SYSTEM_PROMPT_LENGTH) {
errors.push(`System prompt must be at least ${MIN_SYSTEM_PROMPT_LENGTH} characters`);
}
if (trimmed.length > MAX_SYSTEM_PROMPT_LENGTH) {
errors.push(`System prompt must be ${MAX_SYSTEM_PROMPT_LENGTH} characters or less`);
}
return errors;
}
/**
* Validate a temperature value.
*
* @param temperature - The temperature to validate
* @returns Array of validation errors (empty if valid)
*/
export function validateTemperature(temperature: unknown): string[] {
const errors: string[] = [];
if (temperature === undefined || temperature === null) {
errors.push('Temperature is required');
return errors;
}
if (typeof temperature !== 'number') {
errors.push('Temperature must be a number');
return errors;
}
if (isNaN(temperature)) {
errors.push('Temperature must be a valid number');
return errors;
}
if (temperature < MIN_TEMPERATURE) {
errors.push(`Temperature must be at least ${MIN_TEMPERATURE}`);
}
if (temperature > MAX_TEMPERATURE) {
errors.push(`Temperature must be at most ${MAX_TEMPERATURE}`);
}
return errors;
}
/**
* Validate a maxTokens value.
*
* @param maxTokens - The maxTokens to validate
* @returns Array of validation errors (empty if valid)
*/
export function validateMaxTokens(maxTokens: unknown): string[] {
const errors: string[] = [];
if (maxTokens === undefined || maxTokens === null) {
errors.push('Max tokens is required');
return errors;
}
if (typeof maxTokens !== 'number') {
errors.push('Max tokens must be a number');
return errors;
}
if (isNaN(maxTokens)) {
errors.push('Max tokens must be a valid number');
return errors;
}
if (!Number.isInteger(maxTokens)) {
errors.push('Max tokens must be an integer');
return errors;
}
if (maxTokens < MIN_MAX_TOKENS) {
errors.push(`Max tokens must be at least ${MIN_MAX_TOKENS}`);
}
if (maxTokens > MAX_MAX_TOKENS) {
errors.push(`Max tokens must be at most ${MAX_MAX_TOKENS}`);
}
return errors;
}
/**
* Validate a response style.
*
* @param responseStyle - The response style to validate (optional)
* @returns Array of validation errors (empty if valid)
*/
export function validateResponseStyle(responseStyle: unknown): string[] {
const errors: string[] = [];
// Response style is optional
if (responseStyle === undefined || responseStyle === null) {
return errors;
}
if (typeof responseStyle !== 'string') {
errors.push('Response style must be a string');
return errors;
}
// Allow any non-empty string for custom styles
if (responseStyle.trim().length === 0) {
errors.push('Response style cannot be empty if provided');
}
if (responseStyle.length > 100) {
errors.push('Response style must be 100 characters or less');
}
return errors;
}
/**
* Validate a complete personality configuration.
*
* @param config - The personality configuration to validate
* @returns Validation result with errors
*
* Validates: Requirements 3.1, 3.4
*/
export function validatePersonalityConfig(config: unknown): PersonalityValidationResult {
const errors: string[] = [];
if (!config || typeof config !== 'object') {
return {
valid: false,
errors: ['Personality configuration must be an object'],
};
}
const configObj = config as Record<string, unknown>;
// Validate each field
errors.push(...validateSystemPrompt(configObj.systemPrompt));
errors.push(...validateTemperature(configObj.temperature));
errors.push(...validateMaxTokens(configObj.maxTokens));
errors.push(...validateResponseStyle(configObj.responseStyle));
return {
valid: errors.length === 0,
errors,
};
}
/**
* Check if a personality configuration is valid.
*
* @param config - The personality configuration to check
* @returns True if valid
*/
export function isValidPersonalityConfig(config: unknown): config is PersonalityConfig {
return validatePersonalityConfig(config).valid;
}
// ============================================
// STORAGE AND RETRIEVAL FUNCTIONS
// ============================================
/**
* Error thrown when personality operations fail.
*/
export class PersonalityError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'PersonalityError';
}
}
/**
* Serialize a personality configuration for database storage.
*
* @param config - The personality configuration to serialize
* @returns JSON string representation
*/
export function serializePersonalityConfig(config: PersonalityConfig): string {
return JSON.stringify(config);
}
/**
* Deserialize a personality configuration from database storage.
*
* @param json - The JSON string to deserialize
* @returns Parsed personality configuration
* @throws PersonalityError if parsing fails or config is invalid
*/
export function deserializePersonalityConfig(json: string): PersonalityConfig {
try {
const parsed = JSON.parse(json);
const validation = validatePersonalityConfig(parsed);
if (!validation.valid) {
throw new PersonalityError(
`Invalid personality configuration: ${validation.errors.join(', ')}`,
'INVALID_CONFIG'
);
}
return parsed as PersonalityConfig;
} catch (error) {
if (error instanceof PersonalityError) {
throw error;
}
throw new PersonalityError(
'Failed to parse personality configuration',
'PARSE_ERROR'
);
}
}
/**
* Get the personality configuration for a bot.
*
* @param botId - The ID of the bot
* @returns The personality configuration or null if bot not found
*
* Validates: Requirements 3.1, 3.3
*/
export async function getPersonalityConfig(botId: string): Promise<PersonalityConfig | null> {
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: {
personalityConfig: true,
},
});
if (!bot) {
return null;
}
return deserializePersonalityConfig(bot.personalityConfig);
}
/**
* Update the personality configuration for a bot.
*
* @param botId - The ID of the bot
* @param config - The new personality configuration
* @throws PersonalityError if validation fails or bot not found
*
* Validates: Requirements 3.1, 3.3
*/
export async function updatePersonalityConfig(
botId: string,
config: PersonalityConfig
): Promise<void> {
// Validate the configuration
const validation = validatePersonalityConfig(config);
if (!validation.valid) {
throw new PersonalityError(
`Invalid personality configuration: ${validation.errors.join(', ')}`,
'VALIDATION_ERROR'
);
}
// Check if bot exists
const existingBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { id: true },
});
if (!existingBot) {
throw new PersonalityError(`Bot not found: ${botId}`, 'BOT_NOT_FOUND');
}
// Update the personality configuration
await db
.update(bots)
.set({
personalityConfig: serializePersonalityConfig(config),
updatedAt: new Date(),
})
.where(eq(bots.id, botId));
}
// ============================================
// LLM PROMPT BUILDING
// ============================================
/**
* Options for building an LLM prompt.
*/
export interface PromptBuildOptions {
/** The user message or content to respond to */
userMessage?: string;
/** Additional context to include */
context?: string;
/** Source content being referenced */
sourceContent?: {
title: string;
content: string;
url: string;
};
/** Conversation history for replies */
conversationHistory?: Array<{
role: 'user' | 'assistant';
content: string;
}>;
}
/**
* Built LLM prompt ready for API call.
*/
export interface BuiltPrompt {
/** System message with personality */
systemMessage: string;
/** User/assistant messages */
messages: Array<{
role: 'user' | 'assistant' | 'system';
content: string;
}>;
/** Temperature setting */
temperature: number;
/** Max tokens setting */
maxTokens: number;
}
/**
* Build an LLM prompt with personality context.
*
* @param personality - The bot's personality configuration
* @param options - Options for building the prompt
* @returns Built prompt ready for LLM API call
*
* Validates: Requirements 3.2, 3.5
*/
export function buildPromptWithPersonality(
personality: PersonalityConfig,
options: PromptBuildOptions = {}
): BuiltPrompt {
const messages: BuiltPrompt['messages'] = [];
// Build system message with personality
let systemMessage = personality.systemPrompt;
// Add response style guidance if specified
if (personality.responseStyle) {
systemMessage += `\n\nResponse Style: ${personality.responseStyle}`;
}
// Add context if provided
if (options.context) {
systemMessage += `\n\nAdditional Context:\n${options.context}`;
}
messages.push({
role: 'system',
content: systemMessage,
});
// Add conversation history if provided
if (options.conversationHistory && options.conversationHistory.length > 0) {
for (const msg of options.conversationHistory) {
messages.push({
role: msg.role,
content: msg.content,
});
}
}
// Build user message
if (options.sourceContent) {
const sourceMessage = `Please create a post about the following content:
Title: ${options.sourceContent.title}
URL: ${options.sourceContent.url}
Content:
${options.sourceContent.content}`;
messages.push({
role: 'user',
content: sourceMessage,
});
} else if (options.userMessage) {
messages.push({
role: 'user',
content: options.userMessage,
});
}
return {
systemMessage,
messages,
temperature: personality.temperature,
maxTokens: personality.maxTokens,
};
}
// ============================================
// PERSONALITY PRESETS
// ============================================
/**
* Default personality presets for common bot types.
*/
export const PERSONALITY_PRESETS: PersonalityPreset[] = [
{
id: 'news-curator',
name: 'News Curator',
description: 'A professional news curator that shares and summarizes articles',
config: {
systemPrompt: `You are a professional news curator bot. Your role is to share interesting news articles with insightful commentary.
Guidelines:
- Provide brief, informative summaries of articles
- Add your own analysis or perspective when relevant
- Use a professional but accessible tone
- Include relevant hashtags when appropriate
- Keep posts concise and engaging`,
temperature: 0.7,
maxTokens: 500,
responseStyle: 'professional',
},
},
{
id: 'tech-enthusiast',
name: 'Tech Enthusiast',
description: 'An enthusiastic tech commentator that shares technology news',
config: {
systemPrompt: `You are an enthusiastic technology commentator bot. You love sharing exciting tech news and developments.
Guidelines:
- Show genuine excitement about technological innovations
- Explain technical concepts in accessible terms
- Engage with the tech community
- Share opinions on industry trends
- Use appropriate tech-related hashtags`,
temperature: 0.8,
maxTokens: 500,
responseStyle: 'friendly',
},
},
{
id: 'educational',
name: 'Educational Bot',
description: 'An educational bot that explains topics clearly',
config: {
systemPrompt: `You are an educational bot focused on sharing knowledge and explaining concepts clearly.
Guidelines:
- Break down complex topics into understandable parts
- Use examples and analogies when helpful
- Encourage curiosity and learning
- Cite sources when sharing facts
- Be patient and thorough in explanations`,
temperature: 0.6,
maxTokens: 800,
responseStyle: 'educational',
},
},
{
id: 'casual-commenter',
name: 'Casual Commenter',
description: 'A casual, friendly bot for general commentary',
config: {
systemPrompt: `You are a friendly, casual bot that engages in conversations and shares interesting content.
Guidelines:
- Be conversational and approachable
- Share your thoughts naturally
- Engage with others in a friendly manner
- Keep things light and positive
- Use emojis sparingly but appropriately`,
temperature: 0.9,
maxTokens: 300,
responseStyle: 'casual',
},
},
{
id: 'formal-analyst',
name: 'Formal Analyst',
description: 'A formal, analytical bot for serious commentary',
config: {
systemPrompt: `You are a formal analyst bot that provides thoughtful, well-reasoned commentary.
Guidelines:
- Maintain a professional, formal tone
- Provide balanced analysis
- Support opinions with reasoning
- Avoid casual language and slang
- Focus on substance over style`,
temperature: 0.5,
maxTokens: 600,
responseStyle: 'formal',
},
},
];
/**
* Get a personality preset by ID.
*
* @param presetId - The ID of the preset
* @returns The preset or undefined if not found
*/
export function getPersonalityPreset(presetId: string): PersonalityPreset | undefined {
return PERSONALITY_PRESETS.find(preset => preset.id === presetId);
}
/**
* Get all available personality presets.
*
* @returns Array of all presets
*/
export function getAllPersonalityPresets(): PersonalityPreset[] {
return [...PERSONALITY_PRESETS];
}
/**
* Create a default personality configuration.
*
* @returns A default personality configuration
*/
export function createDefaultPersonalityConfig(): PersonalityConfig {
return {
systemPrompt: 'You are a helpful bot that shares interesting content and engages with users.',
temperature: 0.7,
maxTokens: 500,
};
}
+534
View File
@@ -0,0 +1,534 @@
/**
* Property-Based Tests for Bot Posting Module
*
* Feature: bot-system
* - Property 38: Post Content Validation
*
* Tests that generated posts are validated against platform requirements
* (length, format) before publishing.
*
* **Validates: Requirements 11.5**
*/
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import {
validatePostContent,
sanitizePostContent,
POST_MAX_LENGTH,
POST_MIN_LENGTH,
MAX_URLS_PER_POST,
} from './posting';
// ============================================
// GENERATORS
// ============================================
/**
* Generator for valid post content (within length limits).
*/
const validPostContentArb = fc.string({
minLength: POST_MIN_LENGTH,
maxLength: POST_MAX_LENGTH,
}).filter(s => s.trim().length >= POST_MIN_LENGTH);
/**
* Generator for post content that exceeds maximum length.
*/
const tooLongPostContentArb = fc.string({
minLength: POST_MAX_LENGTH + 1,
maxLength: POST_MAX_LENGTH + 500,
});
/**
* Generator for post content that is too short (empty or whitespace).
*/
const tooShortPostContentArb = fc.oneof(
fc.constant(''),
fc.constant(' '),
fc.constant('\n\n'),
fc.constant('\t\t')
);
/**
* Generator for post content with forbidden patterns.
*/
const forbiddenContentArb = fc.oneof(
fc.constant('This is spam content'),
fc.constant('Check out this scam'),
fc.constant('Phishing attempt here'),
fc.string({ minLength: 10, maxLength: 100 }).map(s => `${s} spam ${s}`),
fc.string({ minLength: 10, maxLength: 100 }).map(s => `${s} scam ${s}`),
fc.string({ minLength: 10, maxLength: 100 }).map(s => `${s} phishing ${s}`)
);
/**
* Generator for URLs.
*/
const urlArb = fc.webUrl();
/**
* Generator for post content with too many URLs.
*/
const tooManyUrlsContentArb = fc.array(urlArb, {
minLength: MAX_URLS_PER_POST + 1,
maxLength: MAX_URLS_PER_POST + 5,
}).map(urls => urls.join(' '));
/**
* Generator for post content with acceptable number of URLs.
*/
const acceptableUrlsContentArb = fc.tuple(
fc.string({ minLength: 10, maxLength: 100 }),
fc.array(urlArb, { minLength: 0, maxLength: MAX_URLS_PER_POST })
).map(([text, urls]) => `${text} ${urls.join(' ')}`);
/**
* Generator for content with null bytes and special characters.
*/
const unsafeContentArb = fc.string({ minLength: 10, maxLength: 100 }).map(s =>
`${s}\0null byte\0${s}`
);
/**
* Generator for content with excessive whitespace.
*/
const excessiveWhitespaceArb = fc.string({ minLength: 10, maxLength: 100 }).map(s =>
`${s}\n\n\n\n\n${s}\n\n\n\n`
);
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 38: Post Content Validation', () => {
/**
* Property 38: Post Content Validation
*
* *For any* generated post, the content SHALL be validated against platform
* requirements (length, format) before publishing.
*
* **Validates: Requirements 11.5**
*/
it('validates that content within length limits is valid (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
validPostContentArb,
async (content) => {
const result = validatePostContent(content);
// Valid content should pass validation
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
}
),
{ numRuns: 100 }
);
});
it('rejects content exceeding maximum length (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
tooLongPostContentArb,
async (content) => {
// Only test if trimmed content exceeds max length
if (content.trim().length <= POST_MAX_LENGTH) {
return; // Skip this test case
}
const result = validatePostContent(content);
// Content exceeding max length should fail validation
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
// Should have specific error about length
const hasLengthError = result.errors.some(err =>
err.includes('must not exceed') || err.includes('characters')
);
expect(hasLengthError).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('rejects content below minimum length (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
tooShortPostContentArb,
async (content) => {
const result = validatePostContent(content);
// Content below min length should fail validation
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
// Should have error about content being required, minimum length, or empty/whitespace
const hasRelevantError = result.errors.some(err =>
err.includes('required') || err.includes('at least') ||
err.includes('character') || err.includes('empty') || err.includes('whitespace')
);
expect(hasRelevantError).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('rejects content with forbidden patterns (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
forbiddenContentArb,
async (content) => {
const result = validatePostContent(content);
// Content with forbidden patterns should fail validation
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
// Should have error about forbidden content
const hasForbiddenError = result.errors.some(err =>
err.includes('forbidden')
);
expect(hasForbiddenError).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('rejects content with too many URLs (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
tooManyUrlsContentArb,
async (content) => {
// Only test if content is within length limits
if (content.length > POST_MAX_LENGTH) {
return; // Skip this test case
}
const result = validatePostContent(content);
// Content with too many URLs should fail validation
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
// Should have error about URL count
const hasUrlError = result.errors.some(err =>
err.includes('URL') || err.includes('urls')
);
expect(hasUrlError).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('accepts content with acceptable number of URLs (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
acceptableUrlsContentArb,
async (content) => {
// Only test if content is within length limits
if (content.length > POST_MAX_LENGTH || content.trim().length < POST_MIN_LENGTH) {
return; // Skip this test case
}
const result = validatePostContent(content);
// Content with acceptable URLs should pass validation
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
}
),
{ numRuns: 100 }
);
});
it('validation is deterministic for same input (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 0, maxLength: POST_MAX_LENGTH + 100 }),
async (content) => {
const result1 = validatePostContent(content);
const result2 = validatePostContent(content);
// Same input should produce same validation result
expect(result1.valid).toBe(result2.valid);
expect(result1.errors).toEqual(result2.errors);
}
),
{ numRuns: 100 }
);
});
it('validation handles null and undefined gracefully (Requirement 11.5)', async () => {
const nullResult = validatePostContent(null as any);
const undefinedResult = validatePostContent(undefined as any);
// Both should fail validation with appropriate error
expect(nullResult.valid).toBe(false);
expect(nullResult.errors.length).toBeGreaterThan(0);
expect(undefinedResult.valid).toBe(false);
expect(undefinedResult.errors.length).toBeGreaterThan(0);
});
it('validation handles non-string types gracefully (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.oneof(
fc.integer(),
fc.boolean(),
fc.object(),
fc.array(fc.string())
),
async (nonString) => {
const result = validatePostContent(nonString as any);
// Non-string content should fail validation
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
}
),
{ numRuns: 100 }
);
});
it('validation trims whitespace before checking length (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
validPostContentArb,
fc.string({ minLength: 0, maxLength: 50 }).filter(s => /^\s+$/.test(s) || s === ''),
async (validContent, whitespace) => {
const contentWithWhitespace = `${whitespace}${validContent}${whitespace}`;
const result = validatePostContent(contentWithWhitespace);
// Should validate based on trimmed length
const trimmedLength = contentWithWhitespace.trim().length;
if (trimmedLength >= POST_MIN_LENGTH && trimmedLength <= POST_MAX_LENGTH) {
expect(result.valid).toBe(true);
} else {
expect(result.valid).toBe(false);
}
}
),
{ numRuns: 100 }
);
});
it('validation provides specific error messages (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.oneof(
tooLongPostContentArb.filter(s => s.trim().length > POST_MAX_LENGTH), // Only truly too long
tooShortPostContentArb,
forbiddenContentArb.filter(s => s.trim().length >= POST_MIN_LENGTH && s.trim().length <= POST_MAX_LENGTH) // Valid length but forbidden
),
async (invalidContent) => {
const result = validatePostContent(invalidContent);
// Invalid content should have specific error messages
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
// Each error should be a non-empty string
result.errors.forEach(error => {
expect(typeof error).toBe('string');
expect(error.length).toBeGreaterThan(0);
});
}
),
{ numRuns: 100 }
);
});
it('sanitization removes null bytes (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
unsafeContentArb,
async (content) => {
const sanitized = sanitizePostContent(content);
// Sanitized content should not contain null bytes
expect(sanitized).not.toContain('\0');
}
),
{ numRuns: 100 }
);
});
it('sanitization normalizes line breaks (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 10, maxLength: 100 }).map(s =>
`${s}\r\n${s}\r\n${s}`
),
async (content) => {
const sanitized = sanitizePostContent(content);
// Sanitized content should not contain \r\n
expect(sanitized).not.toContain('\r\n');
// Should only have \n for line breaks
if (sanitized.includes('\n')) {
expect(sanitized.split('\n').length).toBeGreaterThan(1);
}
}
),
{ numRuns: 100 }
);
});
it('sanitization removes excessive whitespace (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
excessiveWhitespaceArb,
async (content) => {
const sanitized = sanitizePostContent(content);
// Sanitized content should not have more than 2 consecutive newlines
expect(sanitized).not.toMatch(/\n{3,}/);
}
),
{ numRuns: 100 }
);
});
it('sanitization trims leading and trailing whitespace (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 10, maxLength: 100 }),
fc.string({ minLength: 1, maxLength: 20 }).filter(s => /^\s+$/.test(s)),
async (content, whitespace) => {
const contentWithWhitespace = `${whitespace}${content}${whitespace}`;
const sanitized = sanitizePostContent(contentWithWhitespace);
// Sanitized content should be trimmed
expect(sanitized).toBe(sanitized.trim());
expect(sanitized).not.toMatch(/^\s/);
expect(sanitized).not.toMatch(/\s$/);
}
),
{ numRuns: 100 }
);
});
it('sanitization is idempotent (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 0, maxLength: 200 }),
async (content) => {
const sanitized1 = sanitizePostContent(content);
const sanitized2 = sanitizePostContent(sanitized1);
// Sanitizing twice should produce the same result
expect(sanitized1).toBe(sanitized2);
}
),
{ numRuns: 100 }
);
});
it('sanitization preserves valid content structure (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
validPostContentArb,
async (content) => {
const sanitized = sanitizePostContent(content);
// Sanitized valid content should still be valid
// (assuming it doesn't have null bytes or excessive whitespace)
if (!content.includes('\0') && !content.match(/\n{3,}/)) {
expect(sanitized.trim()).toBe(content.trim());
}
}
),
{ numRuns: 100 }
);
});
it('validation constants are properly defined (Requirement 11.5)', async () => {
// Verify constants are reasonable
expect(POST_MAX_LENGTH).toBe(400);
expect(POST_MIN_LENGTH).toBe(1);
expect(MAX_URLS_PER_POST).toBe(5);
// Verify relationships
expect(POST_MAX_LENGTH).toBeGreaterThan(POST_MIN_LENGTH);
expect(MAX_URLS_PER_POST).toBeGreaterThan(0);
});
it('validation rejects content at boundary conditions (Requirement 11.5)', async () => {
// Test exact boundary: POST_MAX_LENGTH
const exactMaxContent = 'a'.repeat(POST_MAX_LENGTH);
const exactMaxResult = validatePostContent(exactMaxContent);
expect(exactMaxResult.valid).toBe(true);
// Test one over boundary: POST_MAX_LENGTH + 1
const overMaxContent = 'a'.repeat(POST_MAX_LENGTH + 1);
const overMaxResult = validatePostContent(overMaxContent);
expect(overMaxResult.valid).toBe(false);
// Test exact minimum: POST_MIN_LENGTH
const exactMinContent = 'a'.repeat(POST_MIN_LENGTH);
const exactMinResult = validatePostContent(exactMinContent);
expect(exactMinResult.valid).toBe(true);
// Test below minimum: empty string
const belowMinContent = '';
const belowMinResult = validatePostContent(belowMinContent);
expect(belowMinResult.valid).toBe(false);
});
it('validation handles mixed invalid conditions (Requirement 11.5)', async () => {
await fc.assert(
fc.asyncProperty(
fc.tuple(
fc.boolean(), // too long?
fc.boolean(), // has forbidden content?
fc.boolean() // too many URLs?
),
async ([tooLong, forbidden, tooManyUrls]) => {
let content = 'Test content ';
if (tooLong) {
content = 'a'.repeat(POST_MAX_LENGTH + 10);
}
if (forbidden) {
content += ' spam ';
}
if (tooManyUrls) {
const urls = Array(MAX_URLS_PER_POST + 2).fill('https://example.com');
content += urls.join(' ');
}
const result = validatePostContent(content);
// If any condition is invalid, validation should fail
if (tooLong || forbidden || tooManyUrls) {
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
}
}
),
{ numRuns: 100 }
);
});
it('validation error count matches number of violations (Requirement 11.5)', async () => {
// Content that is too long AND has forbidden content
const multiViolationContent = 'spam '.repeat(100); // Too long and has "spam"
const result = validatePostContent(multiViolationContent);
expect(result.valid).toBe(false);
// Should have at least 2 errors (length and forbidden content)
expect(result.errors.length).toBeGreaterThanOrEqual(1);
});
});
File diff suppressed because it is too large Load Diff
+905
View File
@@ -0,0 +1,905 @@
/**
* Property-Based Tests for Rate Limit Enforcement
*
* Feature: bot-system, Property 19: Rate Limit Enforcement
*
* Tests the rate limiting functionality for bot posts using fast-check
* for property-based testing.
*
* **Validates: Requirements 5.6, 10.1, 10.2, 10.4**
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fc from 'fast-check';
import {
RATE_LIMITS,
canPost,
recordPost,
canReply,
recordReply,
getRemainingQuota,
getDailyWindowStart,
getHourlyWindowStart,
resetRateLimits,
} from './rateLimiter';
// ============================================
// MOCK SETUP
// ============================================
// In-memory storage for mocking database operations
interface MockRateLimitWindow {
id: string;
botId: string;
windowType: 'daily' | 'hourly';
windowStart: Date;
postCount: number;
replyCount: number;
createdAt: Date;
}
interface MockBot {
id: string;
lastPostAt: Date | null;
}
let mockRateLimitWindows: MockRateLimitWindow[] = [];
let mockBots: Map<string, MockBot> = new Map();
let mockActivityLogs: Array<{ botId: string; action: string; details: string; success: boolean; errorMessage?: string }> = [];
// Mock the database module
vi.mock('@/db', () => {
return {
db: {
query: {
botRateLimits: {
findFirst: vi.fn(async ({ where }: any) => {
// Find matching window based on botId, windowType, and windowStart
const window = mockRateLimitWindows.find(w => {
// This is a simplified mock - in real tests we'd parse the where clause
return true; // Will be overridden per test
});
return window || null;
}),
findMany: vi.fn(async () => mockRateLimitWindows),
},
bots: {
findFirst: vi.fn(async ({ where }: any) => {
// Return the mock bot
return null; // Will be overridden per test
}),
},
},
insert: vi.fn(() => ({
values: vi.fn((values: any) => ({
returning: vi.fn(async () => {
const newWindow: MockRateLimitWindow = {
id: `rate-limit-${Date.now()}-${Math.random()}`,
botId: values.botId,
windowType: values.windowType,
windowStart: values.windowStart,
postCount: values.postCount || 0,
replyCount: values.replyCount || 0,
createdAt: new Date(),
};
mockRateLimitWindows.push(newWindow);
return [newWindow];
}),
})),
})),
update: vi.fn(() => ({
set: vi.fn((updates: any) => ({
where: vi.fn(async () => {
// Update would be applied here
}),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(async () => {
mockRateLimitWindows = [];
}),
})),
},
bots: { id: 'id' },
botRateLimits: {
id: 'id',
botId: 'botId',
windowType: 'windowType',
windowStart: 'windowStart',
},
botActivityLogs: {},
};
});
// ============================================
// HELPER FUNCTIONS FOR TESTING
// ============================================
/**
* Reset all mock state before each test.
*/
function resetMockState(): void {
mockRateLimitWindows = [];
mockBots = new Map();
mockActivityLogs = [];
}
/**
* Configure mock to simulate a bot with a specific post count for the day.
*/
async function configureMockPostCount(botId: string, postCount: number): Promise<void> {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue({
id: 'rate-1',
botId,
windowType: 'daily',
windowStart: getDailyWindowStart(),
postCount,
replyCount: 0,
createdAt: new Date(),
} as any);
}
/**
* Configure mock to simulate a bot's last post time.
*/
async function configureMockLastPostAt(botId: string, lastPostAt: Date | null): Promise<void> {
const { db } = await import('@/db');
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt } as any);
}
/**
* Configure mock for both post count and last post time.
*/
async function configureMockBotState(
botId: string,
postCount: number,
lastPostAt: Date | null
): Promise<void> {
const { db } = await import('@/db');
if (postCount === 0) {
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(null as any);
} else {
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue({
id: 'rate-1',
botId,
windowType: 'daily',
windowStart: getDailyWindowStart(),
postCount,
replyCount: 0,
createdAt: new Date(),
} as any);
}
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt } as any);
}
/**
* Configure mock for reply count in the hourly window.
*/
async function configureMockReplyCount(
botId: string,
replyCount: number
): Promise<void> {
const { db } = await import('@/db');
if (replyCount === 0) {
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(null as any);
} else {
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue({
id: 'rate-1',
botId,
windowType: 'hourly',
windowStart: getHourlyWindowStart(),
postCount: 0,
replyCount,
createdAt: new Date(),
} as any);
}
}
// ============================================
// GENERATORS
// ============================================
/**
* Generator for valid bot IDs (UUIDs).
*/
const botIdArb = fc.uuid();
/**
* Generator for post counts at or above the daily limit (50).
* These should trigger rate limit rejection.
*/
const atOrAboveDailyLimitArb = fc.integer({ min: RATE_LIMITS.MAX_POSTS_PER_DAY, max: 200 });
/**
* Generator for post counts below the daily limit.
* These should be allowed (assuming interval is met).
*/
const belowDailyLimitArb = fc.integer({ min: 0, max: RATE_LIMITS.MAX_POSTS_PER_DAY - 1 });
/**
* Generator for time intervals in minutes that are below the minimum (< 5 minutes).
* These should trigger rate limit rejection.
*/
const belowMinIntervalMinutesArb = fc.integer({ min: 0, max: RATE_LIMITS.MIN_POST_INTERVAL_MINUTES - 1 });
/**
* Generator for time intervals in minutes that meet or exceed the minimum (>= 5 minutes).
* These should be allowed (assuming daily limit is not reached).
*/
const atOrAboveMinIntervalMinutesArb = fc.integer({ min: RATE_LIMITS.MIN_POST_INTERVAL_MINUTES, max: 1440 });
/**
* Generator for scenarios that should be rejected:
* - Daily limit reached (>= 50 posts)
* - OR interval not met (< 5 minutes since last post)
*/
const rejectionScenarioArb = fc.oneof(
// Scenario 1: Daily limit reached, regardless of interval
fc.record({
postCount: atOrAboveDailyLimitArb,
minutesSinceLastPost: fc.integer({ min: 0, max: 1440 }),
reason: fc.constant('daily_limit' as const),
}),
// Scenario 2: Interval not met, regardless of post count
fc.record({
postCount: belowDailyLimitArb,
minutesSinceLastPost: belowMinIntervalMinutesArb,
reason: fc.constant('interval' as const),
}),
// Scenario 3: Both limits violated
fc.record({
postCount: atOrAboveDailyLimitArb,
minutesSinceLastPost: belowMinIntervalMinutesArb,
reason: fc.constant('both' as const),
})
);
/**
* Generator for scenarios that should be allowed:
* - Below daily limit (< 50 posts)
* - AND interval met (>= 5 minutes since last post OR no previous post)
*/
const allowedScenarioArb = fc.record({
postCount: belowDailyLimitArb,
minutesSinceLastPost: fc.oneof(
atOrAboveMinIntervalMinutesArb,
fc.constant(null as number | null) // No previous post
),
});
/**
* Generator for a sequence of post attempts to test cumulative rate limiting.
*/
const postSequenceArb = fc.array(
fc.record({
delayMinutes: fc.integer({ min: 0, max: 30 }),
}),
{ minLength: 1, maxLength: 60 }
);
// ============================================
// REPLY RATE LIMITING GENERATORS
// ============================================
/**
* Generator for reply counts at or above the hourly limit (20).
* These should trigger rate limit rejection.
*/
const atOrAboveHourlyReplyLimitArb = fc.integer({ min: RATE_LIMITS.MAX_REPLIES_PER_HOUR, max: 200 });
/**
* Generator for reply counts below the hourly limit.
* These should be allowed.
*/
const belowHourlyReplyLimitArb = fc.integer({ min: 0, max: RATE_LIMITS.MAX_REPLIES_PER_HOUR - 1 });
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 19: Rate Limit Enforcement', () => {
/**
* Property 19: Rate Limit Enforcement
*
* *For any* bot, posting more than 50 times per day OR posting within 5 minutes
* of the last post SHALL be rejected.
*
* **Validates: Requirements 5.6, 10.1, 10.2, 10.4**
*/
beforeEach(() => {
vi.clearAllMocks();
resetMockState();
});
describe('Daily limit enforcement (Requirement 10.1)', () => {
/**
* Property: For any bot that has posted 50 or more times today,
* the next post attempt SHALL be rejected.
*/
it('rejects posting when daily limit of 50 posts is reached', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, atOrAboveDailyLimitArb, async (botId, postCount) => {
// Configure mock: bot has reached or exceeded daily limit
await configureMockBotState(botId, postCount, null);
const result = await canPost(botId);
expect(result.allowed).toBe(false);
expect(result.reason).toBeDefined();
expect(result.reason).toContain('Daily post limit');
expect(result.reason).toContain('50');
expect(result.retryAfterSeconds).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
/**
* Property: For any bot that has posted fewer than 50 times today
* and meets the interval requirement, posting SHALL be allowed.
*/
it('allows posting when below daily limit and interval is met', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, belowDailyLimitArb, async (botId, postCount) => {
// Configure mock: bot is below daily limit and has no recent post
await configureMockBotState(botId, postCount, null);
const result = await canPost(botId);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
}),
{ numRuns: 100 }
);
});
/**
* Property: The daily limit is exactly 50 posts per day.
*/
it('enforces exactly 50 posts per day limit', async () => {
const botId = 'test-bot-exact-limit';
// At 49 posts, should be allowed
await configureMockBotState(botId, 49, null);
const resultAt49 = await canPost(botId);
expect(resultAt49.allowed).toBe(true);
// At 50 posts, should be rejected
await configureMockBotState(botId, 50, null);
const resultAt50 = await canPost(botId);
expect(resultAt50.allowed).toBe(false);
expect(resultAt50.reason).toContain('Daily post limit');
});
});
describe('Minimum interval enforcement (Requirements 5.6, 10.2)', () => {
/**
* Property: For any bot that posted less than 5 minutes ago,
* the next post attempt SHALL be rejected.
*/
it('rejects posting within 5 minutes of last post', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, belowMinIntervalMinutesArb, async (botId, minutesAgo) => {
// Configure mock: bot posted recently (within 5 minutes)
const lastPostAt = new Date(Date.now() - minutesAgo * 60 * 1000);
await configureMockBotState(botId, 0, lastPostAt);
const result = await canPost(botId);
expect(result.allowed).toBe(false);
expect(result.reason).toBeDefined();
expect(result.reason).toContain('Minimum interval');
expect(result.retryAfterSeconds).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
/**
* Property: For any bot that posted 5 or more minutes ago,
* the interval requirement is satisfied.
*/
it('allows posting when 5 or more minutes have passed since last post', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, atOrAboveMinIntervalMinutesArb, async (botId, minutesAgo) => {
// Configure mock: bot posted long enough ago
const lastPostAt = new Date(Date.now() - minutesAgo * 60 * 1000);
await configureMockBotState(botId, 0, lastPostAt);
const result = await canPost(botId);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
}),
{ numRuns: 100 }
);
});
/**
* Property: The minimum interval is exactly 5 minutes.
*/
it('enforces exactly 5 minute minimum interval', async () => {
const botId = 'test-bot-exact-interval';
// At 4 minutes 59 seconds ago, should be rejected
const justUnder5Min = new Date(Date.now() - (4 * 60 + 59) * 1000);
await configureMockBotState(botId, 0, justUnder5Min);
const resultUnder = await canPost(botId);
expect(resultUnder.allowed).toBe(false);
// At 5 minutes ago, should be allowed
const exactly5Min = new Date(Date.now() - 5 * 60 * 1000);
await configureMockBotState(botId, 0, exactly5Min);
const resultExact = await canPost(botId);
expect(resultExact.allowed).toBe(true);
});
/**
* Property: Bots with no previous posts have no interval restriction.
*/
it('allows first post for bots with no posting history', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, async (botId) => {
// Configure mock: bot has never posted
await configureMockBotState(botId, 0, null);
const result = await canPost(botId);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
}),
{ numRuns: 100 }
);
});
});
describe('Combined rate limit scenarios', () => {
/**
* Property: For any scenario where daily limit is reached OR interval is not met,
* posting SHALL be rejected.
*/
it('rejects posting when either limit is violated', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, rejectionScenarioArb, async (botId, scenario) => {
// Configure mock based on scenario
const lastPostAt = scenario.minutesSinceLastPost !== null
? new Date(Date.now() - scenario.minutesSinceLastPost * 60 * 1000)
: null;
await configureMockBotState(botId, scenario.postCount, lastPostAt);
const result = await canPost(botId);
expect(result.allowed).toBe(false);
expect(result.reason).toBeDefined();
expect(result.retryAfterSeconds).toBeGreaterThan(0);
// Verify the correct reason is given based on which limit was hit first
// (interval is checked before daily limit in the implementation)
if (scenario.reason === 'interval' || scenario.reason === 'both') {
if (lastPostAt !== null) {
expect(result.reason).toContain('Minimum interval');
}
}
}),
{ numRuns: 100 }
);
});
/**
* Property: For any scenario where both limits are satisfied,
* posting SHALL be allowed.
*/
it('allows posting when both limits are satisfied', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, allowedScenarioArb, async (botId, scenario) => {
// Configure mock based on scenario
const lastPostAt = scenario.minutesSinceLastPost !== null
? new Date(Date.now() - scenario.minutesSinceLastPost * 60 * 1000)
: null;
await configureMockBotState(botId, scenario.postCount, lastPostAt);
const result = await canPost(botId);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
}),
{ numRuns: 100 }
);
});
});
describe('Rate limit constants validation', () => {
/**
* Property: Rate limit constants match the requirements.
*/
it('has correct rate limit constants per requirements', () => {
// Requirement 10.1: Maximum 50 posts per day
expect(RATE_LIMITS.MAX_POSTS_PER_DAY).toBe(50);
// Requirement 10.2: Minimum 5 minute interval
expect(RATE_LIMITS.MIN_POST_INTERVAL_MINUTES).toBe(5);
// Requirement 7.6: Maximum 20 replies per hour
expect(RATE_LIMITS.MAX_REPLIES_PER_HOUR).toBe(20);
});
});
describe('Retry-after calculation', () => {
/**
* Property: When rate limited, retryAfterSeconds provides a valid wait time.
*/
it('provides valid retry-after seconds when rate limited', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, rejectionScenarioArb, async (botId, scenario) => {
const lastPostAt = scenario.minutesSinceLastPost !== null
? new Date(Date.now() - scenario.minutesSinceLastPost * 60 * 1000)
: null;
await configureMockBotState(botId, scenario.postCount, lastPostAt);
const result = await canPost(botId);
if (!result.allowed) {
expect(result.retryAfterSeconds).toBeDefined();
expect(result.retryAfterSeconds).toBeGreaterThan(0);
// For interval violations, retry should be <= 5 minutes
if (scenario.reason === 'interval' && lastPostAt !== null) {
expect(result.retryAfterSeconds).toBeLessThanOrEqual(5 * 60);
}
// For daily limit violations, retry should be <= 24 hours
if (scenario.reason === 'daily_limit') {
expect(result.retryAfterSeconds).toBeLessThanOrEqual(24 * 60 * 60);
}
}
}),
{ numRuns: 100 }
);
});
});
describe('Remaining quota accuracy', () => {
/**
* Property: getRemainingQuota returns accurate remaining posts.
*/
it('returns accurate remaining daily quota', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, belowDailyLimitArb, async (botId, postCount) => {
await configureMockBotState(botId, postCount, null);
const quota = await getRemainingQuota(botId);
expect(quota.daily).toBe(RATE_LIMITS.MAX_POSTS_PER_DAY - postCount);
expect(quota.daily).toBeGreaterThanOrEqual(0);
expect(quota.daily).toBeLessThanOrEqual(RATE_LIMITS.MAX_POSTS_PER_DAY);
}),
{ numRuns: 100 }
);
});
/**
* Property: getRemainingQuota returns 0 when limit is reached.
*/
it('returns 0 remaining quota when daily limit is reached', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, atOrAboveDailyLimitArb, async (botId, postCount) => {
await configureMockBotState(botId, postCount, null);
const quota = await getRemainingQuota(botId);
expect(quota.daily).toBe(0);
}),
{ numRuns: 100 }
);
});
/**
* Property: nextPostAllowedInSeconds is accurate based on last post time.
*/
it('returns accurate next post allowed time', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, belowMinIntervalMinutesArb, async (botId, minutesAgo) => {
const lastPostAt = new Date(Date.now() - minutesAgo * 60 * 1000);
await configureMockBotState(botId, 0, lastPostAt);
const quota = await getRemainingQuota(botId);
// Should have some wait time remaining
expect(quota.nextPostAllowedInSeconds).toBeGreaterThan(0);
// Wait time should be approximately (5 - minutesAgo) minutes
const expectedWaitSeconds = (RATE_LIMITS.MIN_POST_INTERVAL_MINUTES - minutesAgo) * 60;
// Allow 2 second tolerance for test execution time
expect(quota.nextPostAllowedInSeconds).toBeGreaterThanOrEqual(expectedWaitSeconds - 2);
expect(quota.nextPostAllowedInSeconds).toBeLessThanOrEqual(expectedWaitSeconds + 2);
}),
{ numRuns: 100 }
);
});
});
describe('Edge cases', () => {
/**
* Property: Rate limiting works correctly at boundary values.
*/
it('handles boundary values correctly', async () => {
const botId = 'test-bot-boundaries';
// Test at exactly 0 posts
await configureMockBotState(botId, 0, null);
const resultAt0 = await canPost(botId);
expect(resultAt0.allowed).toBe(true);
// Test at exactly MAX_POSTS_PER_DAY - 1
await configureMockBotState(botId, RATE_LIMITS.MAX_POSTS_PER_DAY - 1, null);
const resultAtMax1 = await canPost(botId);
expect(resultAtMax1.allowed).toBe(true);
// Test at exactly MAX_POSTS_PER_DAY
await configureMockBotState(botId, RATE_LIMITS.MAX_POSTS_PER_DAY, null);
const resultAtMax = await canPost(botId);
expect(resultAtMax.allowed).toBe(false);
});
/**
* Property: Rate limiting handles very large post counts gracefully.
*/
it('handles very large post counts gracefully', async () => {
await fc.assert(
fc.asyncProperty(
botIdArb,
fc.integer({ min: 1000, max: 1000000 }),
async (botId, postCount) => {
await configureMockBotState(botId, postCount, null);
const result = await canPost(botId);
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Daily post limit');
}
),
{ numRuns: 100 }
);
});
/**
* Property: Rate limiting handles posts from far in the past correctly.
*/
it('allows posting when last post was long ago', async () => {
await fc.assert(
fc.asyncProperty(
botIdArb,
fc.integer({ min: 60, max: 10080 }), // 1 hour to 1 week in minutes
async (botId, minutesAgo) => {
const lastPostAt = new Date(Date.now() - minutesAgo * 60 * 1000);
await configureMockBotState(botId, 0, lastPostAt);
const result = await canPost(botId);
expect(result.allowed).toBe(true);
}
),
{ numRuns: 100 }
);
});
});
});
// ============================================
// PROPERTY 25: REPLY RATE LIMITING
// ============================================
describe('Feature: bot-system, Property 25: Reply Rate Limiting', () => {
/**
* Property 25: Reply Rate Limiting
*
* *For any* bot, replying more than 20 times per hour SHALL be rejected.
*
* **Validates: Requirements 7.6**
*/
beforeEach(() => {
vi.clearAllMocks();
resetMockState();
});
describe('Hourly reply limit enforcement (Requirement 7.6)', () => {
/**
* Property: For any bot that has replied 20 or more times this hour,
* the next reply attempt SHALL be rejected.
*/
it('rejects replying when hourly limit of 20 replies is reached', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, atOrAboveHourlyReplyLimitArb, async (botId, replyCount) => {
// Configure mock: bot has reached or exceeded hourly reply limit
await configureMockReplyCount(botId, replyCount);
const result = await canReply(botId);
expect(result.allowed).toBe(false);
expect(result.reason).toBeDefined();
expect(result.reason).toContain('Hourly reply limit');
expect(result.reason).toContain('20');
expect(result.retryAfterSeconds).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
/**
* Property: For any bot that has replied fewer than 20 times this hour,
* replying SHALL be allowed.
*/
it('allows replying when below hourly limit', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, belowHourlyReplyLimitArb, async (botId, replyCount) => {
// Configure mock: bot is below hourly reply limit
await configureMockReplyCount(botId, replyCount);
const result = await canReply(botId);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
}),
{ numRuns: 100 }
);
});
/**
* Property: The hourly reply limit is exactly 20 replies per hour.
*/
it('enforces exactly 20 replies per hour limit', async () => {
const botId = 'test-bot-exact-reply-limit';
// At 19 replies, should be allowed
await configureMockReplyCount(botId, 19);
const resultAt19 = await canReply(botId);
expect(resultAt19.allowed).toBe(true);
// At 20 replies, should be rejected
await configureMockReplyCount(botId, 20);
const resultAt20 = await canReply(botId);
expect(resultAt20.allowed).toBe(false);
expect(resultAt20.reason).toContain('Hourly reply limit');
});
/**
* Property: Bots with no previous replies this hour have no restriction.
*/
it('allows first reply for bots with no reply history this hour', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, async (botId) => {
// Configure mock: bot has no replies this hour
await configureMockReplyCount(botId, 0);
const result = await canReply(botId);
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
}),
{ numRuns: 100 }
);
});
});
describe('Retry-after calculation for replies', () => {
/**
* Property: When reply rate limited, retryAfterSeconds provides a valid wait time
* that is at most 1 hour (3600 seconds).
*/
it('provides valid retry-after seconds when reply rate limited', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, atOrAboveHourlyReplyLimitArb, async (botId, replyCount) => {
await configureMockReplyCount(botId, replyCount);
const result = await canReply(botId);
expect(result.allowed).toBe(false);
expect(result.retryAfterSeconds).toBeDefined();
expect(result.retryAfterSeconds).toBeGreaterThan(0);
// Retry should be at most 1 hour (until next hourly window)
expect(result.retryAfterSeconds).toBeLessThanOrEqual(60 * 60);
}),
{ numRuns: 100 }
);
});
});
describe('Remaining reply quota accuracy', () => {
/**
* Property: getRemainingQuota returns accurate remaining hourly replies.
*/
it('returns accurate remaining hourly reply quota', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, belowHourlyReplyLimitArb, async (botId, replyCount) => {
await configureMockReplyCount(botId, replyCount);
const quota = await getRemainingQuota(botId);
expect(quota.hourly).toBe(RATE_LIMITS.MAX_REPLIES_PER_HOUR - replyCount);
expect(quota.hourly).toBeGreaterThanOrEqual(0);
expect(quota.hourly).toBeLessThanOrEqual(RATE_LIMITS.MAX_REPLIES_PER_HOUR);
}),
{ numRuns: 100 }
);
});
/**
* Property: getRemainingQuota returns 0 hourly quota when reply limit is reached.
*/
it('returns 0 remaining hourly quota when reply limit is reached', async () => {
await fc.assert(
fc.asyncProperty(botIdArb, atOrAboveHourlyReplyLimitArb, async (botId, replyCount) => {
await configureMockReplyCount(botId, replyCount);
const quota = await getRemainingQuota(botId);
expect(quota.hourly).toBe(0);
}),
{ numRuns: 100 }
);
});
});
describe('Reply rate limit edge cases', () => {
/**
* Property: Reply rate limiting works correctly at boundary values.
*/
it('handles boundary values correctly for replies', async () => {
const botId = 'test-bot-reply-boundaries';
// Test at exactly 0 replies
await configureMockReplyCount(botId, 0);
const resultAt0 = await canReply(botId);
expect(resultAt0.allowed).toBe(true);
// Test at exactly MAX_REPLIES_PER_HOUR - 1
await configureMockReplyCount(botId, RATE_LIMITS.MAX_REPLIES_PER_HOUR - 1);
const resultAtMax1 = await canReply(botId);
expect(resultAtMax1.allowed).toBe(true);
// Test at exactly MAX_REPLIES_PER_HOUR
await configureMockReplyCount(botId, RATE_LIMITS.MAX_REPLIES_PER_HOUR);
const resultAtMax = await canReply(botId);
expect(resultAtMax.allowed).toBe(false);
});
/**
* Property: Reply rate limiting handles very large reply counts gracefully.
*/
it('handles very large reply counts gracefully', async () => {
await fc.assert(
fc.asyncProperty(
botIdArb,
fc.integer({ min: 1000, max: 1000000 }),
async (botId, replyCount) => {
await configureMockReplyCount(botId, replyCount);
const result = await canReply(botId);
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Hourly reply limit');
}
),
{ numRuns: 100 }
);
});
});
describe('Reply rate limit constants validation', () => {
/**
* Property: Reply rate limit constant matches Requirement 7.6.
*/
it('has correct reply rate limit constant per Requirement 7.6', () => {
// Requirement 7.6: Maximum 20 replies per hour
expect(RATE_LIMITS.MAX_REPLIES_PER_HOUR).toBe(20);
});
});
});
+434
View File
@@ -0,0 +1,434 @@
/**
* Unit Tests for Rate Limiter Service
*
* Tests the rate limiting functionality for bot posts and replies.
*
* Requirements: 5.6, 7.6, 10.1, 10.2, 10.4
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
RATE_LIMITS,
getDailyWindowStart,
getHourlyWindowStart,
canPost,
canReply,
recordPost,
recordReply,
getRemainingQuota,
getPostCount,
resetRateLimits,
} from './rateLimiter';
// Mock the database module
vi.mock('@/db', () => {
const mockBotRateLimits: Record<string, any> = {};
const mockBots: Record<string, any> = {};
const mockActivityLogs: any[] = [];
return {
db: {
query: {
botRateLimits: {
findFirst: vi.fn(async ({ where }: any) => {
// Return mock data based on the query
return null;
}),
findMany: vi.fn(async () => []),
},
bots: {
findFirst: vi.fn(async ({ where }: any) => {
return { lastPostAt: null };
}),
},
},
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(async () => [{
id: 'rate-limit-1',
botId: 'bot-1',
windowType: 'daily',
windowStart: new Date(),
postCount: 0,
replyCount: 0,
}]),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(async () => {}),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(async () => {}),
})),
},
bots: { id: 'id' },
botRateLimits: {
id: 'id',
botId: 'botId',
windowType: 'windowType',
windowStart: 'windowStart'
},
botActivityLogs: {},
};
});
describe('Rate Limiter Constants', () => {
it('should have correct rate limit values', () => {
expect(RATE_LIMITS.MAX_POSTS_PER_DAY).toBe(50);
expect(RATE_LIMITS.MIN_POST_INTERVAL_MINUTES).toBe(5);
expect(RATE_LIMITS.MAX_REPLIES_PER_HOUR).toBe(20);
});
});
describe('Window Start Calculations', () => {
describe('getDailyWindowStart', () => {
it('should return midnight UTC for the given date', () => {
const testDate = new Date('2024-03-15T14:30:45.123Z');
const windowStart = getDailyWindowStart(testDate);
expect(windowStart.getUTCHours()).toBe(0);
expect(windowStart.getUTCMinutes()).toBe(0);
expect(windowStart.getUTCSeconds()).toBe(0);
expect(windowStart.getUTCMilliseconds()).toBe(0);
expect(windowStart.getUTCDate()).toBe(15);
expect(windowStart.getUTCMonth()).toBe(2); // March (0-indexed)
});
it('should use current date when no date provided', () => {
const windowStart = getDailyWindowStart();
const now = new Date();
expect(windowStart.getUTCDate()).toBe(now.getUTCDate());
expect(windowStart.getUTCHours()).toBe(0);
});
it('should handle dates near midnight correctly', () => {
const nearMidnight = new Date('2024-03-15T23:59:59.999Z');
const windowStart = getDailyWindowStart(nearMidnight);
expect(windowStart.getUTCDate()).toBe(15);
expect(windowStart.getUTCHours()).toBe(0);
});
});
describe('getHourlyWindowStart', () => {
it('should return the start of the hour for the given date', () => {
const testDate = new Date('2024-03-15T14:30:45.123Z');
const windowStart = getHourlyWindowStart(testDate);
expect(windowStart.getUTCHours()).toBe(14);
expect(windowStart.getUTCMinutes()).toBe(0);
expect(windowStart.getUTCSeconds()).toBe(0);
expect(windowStart.getUTCMilliseconds()).toBe(0);
});
it('should use current date when no date provided', () => {
const windowStart = getHourlyWindowStart();
const now = new Date();
expect(windowStart.getUTCHours()).toBe(now.getUTCHours());
expect(windowStart.getUTCMinutes()).toBe(0);
});
it('should handle times near the hour boundary', () => {
const nearHour = new Date('2024-03-15T14:59:59.999Z');
const windowStart = getHourlyWindowStart(nearHour);
expect(windowStart.getUTCHours()).toBe(14);
expect(windowStart.getUTCMinutes()).toBe(0);
});
});
});
describe('canPost', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should allow posting when no previous posts exist', async () => {
const { db } = await import('@/db');
// Mock: no rate limit window exists, no last post
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(undefined);
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt: null } as any);
const result = await canPost('bot-1');
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
});
it('should deny posting when daily limit is reached', async () => {
const { db } = await import('@/db');
// Mock: daily limit reached
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue({
id: 'rate-1',
botId: 'bot-1',
windowType: 'daily',
windowStart: getDailyWindowStart(),
postCount: 50,
replyCount: 0,
createdAt: new Date(),
} as any);
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt: null } as any);
const result = await canPost('bot-1');
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Daily post limit');
expect(result.reason).toContain('50');
expect(result.retryAfterSeconds).toBeGreaterThan(0);
});
it('should deny posting when minimum interval not met', async () => {
const { db } = await import('@/db');
// Mock: last post was 2 minutes ago
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(undefined);
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt: twoMinutesAgo } as any);
const result = await canPost('bot-1');
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Minimum interval');
expect(result.retryAfterSeconds).toBeGreaterThan(0);
expect(result.retryAfterSeconds).toBeLessThanOrEqual(3 * 60); // Should be ~3 minutes
});
it('should allow posting when minimum interval has passed', async () => {
const { db } = await import('@/db');
// Mock: last post was 6 minutes ago
const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(undefined);
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt: sixMinutesAgo } as any);
const result = await canPost('bot-1');
expect(result.allowed).toBe(true);
});
});
describe('canReply', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should allow replying when no previous replies exist', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(undefined);
const result = await canReply('bot-1');
expect(result.allowed).toBe(true);
expect(result.reason).toBeUndefined();
});
it('should deny replying when hourly limit is reached', async () => {
const { db } = await import('@/db');
// Mock: hourly limit reached
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue({
id: 'rate-1',
botId: 'bot-1',
windowType: 'hourly',
windowStart: getHourlyWindowStart(),
postCount: 0,
replyCount: 20,
createdAt: new Date(),
} as any);
const result = await canReply('bot-1');
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Hourly reply limit');
expect(result.reason).toContain('20');
expect(result.retryAfterSeconds).toBeGreaterThan(0);
});
it('should allow replying when under hourly limit', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue({
id: 'rate-1',
botId: 'bot-1',
windowType: 'hourly',
windowStart: getHourlyWindowStart(),
postCount: 0,
replyCount: 10,
createdAt: new Date(),
} as any);
const result = await canReply('bot-1');
expect(result.allowed).toBe(true);
});
});
describe('recordPost', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should create a new window and increment post count', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(undefined);
await recordPost('bot-1');
// Should have called insert to create window
expect(db.insert).toHaveBeenCalled();
// Should have called update to increment count
expect(db.update).toHaveBeenCalled();
});
it('should increment existing window post count', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue({
id: 'rate-1',
botId: 'bot-1',
windowType: 'daily',
windowStart: getDailyWindowStart(),
postCount: 5,
replyCount: 0,
createdAt: new Date(),
} as any);
await recordPost('bot-1');
// Should have called update to increment count
expect(db.update).toHaveBeenCalled();
});
});
describe('recordReply', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should create a new window and increment reply count', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(undefined);
await recordReply('bot-1');
// Should have called insert to create window
expect(db.insert).toHaveBeenCalled();
// Should have called update to increment count
expect(db.update).toHaveBeenCalled();
});
});
describe('getRemainingQuota', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return full quota when no posts or replies exist', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(undefined);
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt: null } as any);
const quota = await getRemainingQuota('bot-1');
expect(quota.daily).toBe(50);
expect(quota.hourly).toBe(20);
expect(quota.nextPostAllowedInSeconds).toBe(0);
});
it('should return reduced quota based on usage', async () => {
const { db } = await import('@/db');
// First call for daily, second for hourly
vi.mocked(db.query.botRateLimits.findFirst)
.mockResolvedValueOnce({
id: 'rate-1',
botId: 'bot-1',
windowType: 'daily',
windowStart: getDailyWindowStart(),
postCount: 10,
replyCount: 0,
createdAt: new Date(),
} as any)
.mockResolvedValueOnce({
id: 'rate-2',
botId: 'bot-1',
windowType: 'hourly',
windowStart: getHourlyWindowStart(),
postCount: 0,
replyCount: 5,
createdAt: new Date(),
} as any);
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt: null } as any);
const quota = await getRemainingQuota('bot-1');
expect(quota.daily).toBe(40);
expect(quota.hourly).toBe(15);
});
it('should return wait time when minimum interval not met', async () => {
const { db } = await import('@/db');
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
vi.mocked(db.query.botRateLimits.findFirst).mockResolvedValue(undefined);
vi.mocked(db.query.bots.findFirst).mockResolvedValue({ lastPostAt: twoMinutesAgo } as any);
const quota = await getRemainingQuota('bot-1');
expect(quota.nextPostAllowedInSeconds).toBeGreaterThan(0);
expect(quota.nextPostAllowedInSeconds).toBeLessThanOrEqual(3 * 60);
});
});
describe('getPostCount', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return 0 when no windows exist', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findMany).mockResolvedValue([]);
const count = await getPostCount('bot-1', 24);
expect(count).toBe(0);
});
it('should sum post counts from multiple windows', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botRateLimits.findMany).mockResolvedValue([
{ postCount: 10 } as any,
{ postCount: 15 } as any,
]);
const count = await getPostCount('bot-1', 48);
expect(count).toBe(25);
});
});
describe('resetRateLimits', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should delete all rate limit records for a bot', async () => {
const { db } = await import('@/db');
await resetRateLimits('bot-1');
expect(db.delete).toHaveBeenCalled();
});
});
+388
View File
@@ -0,0 +1,388 @@
/**
* Rate Limiter Service
*
* Enforces posting and reply rate limits for bots to prevent spam and abuse.
* Tracks post counts per day and reply counts per hour.
*
* Requirements: 5.6, 7.6, 10.1, 10.2, 10.4
*/
import { db, bots, botRateLimits, botActivityLogs } from '@/db';
import { eq, and, gte, desc } from 'drizzle-orm';
// ============================================
// RATE LIMIT CONSTANTS
// ============================================
export const RATE_LIMITS = {
/** Maximum posts per bot per day (Requirement 10.1) */
MAX_POSTS_PER_DAY: 50,
/** Minimum interval between posts in minutes (Requirement 10.2) */
MIN_POST_INTERVAL_MINUTES: 5,
/** Maximum replies per bot per hour (Requirement 7.6) */
MAX_REPLIES_PER_HOUR: 20,
} as const;
// ============================================
// TYPES
// ============================================
export interface RateLimitCheckResult {
/** Whether the action is allowed */
allowed: boolean;
/** Reason for denial if not allowed */
reason?: string;
/** Seconds until the action would be allowed (for retry-after headers) */
retryAfterSeconds?: number;
}
export interface RemainingQuota {
/** Remaining posts for the current day */
daily: number;
/** Remaining replies for the current hour */
hourly: number;
/** Seconds until next post is allowed (0 if allowed now) */
nextPostAllowedInSeconds: number;
}
export type WindowType = 'daily' | 'hourly';
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Get the start of the current day (UTC midnight).
*/
export function getDailyWindowStart(date: Date = new Date()): Date {
const start = new Date(date);
start.setUTCHours(0, 0, 0, 0);
return start;
}
/**
* Get the start of the current hour.
*/
export function getHourlyWindowStart(date: Date = new Date()): Date {
const start = new Date(date);
start.setUTCMinutes(0, 0, 0);
return start;
}
/**
* Calculate seconds until the minimum post interval has passed.
*/
function getSecondsUntilNextPostAllowed(lastPostAt: Date | null): number {
if (!lastPostAt) {
return 0;
}
const minIntervalMs = RATE_LIMITS.MIN_POST_INTERVAL_MINUTES * 60 * 1000;
const nextAllowedTime = new Date(lastPostAt.getTime() + minIntervalMs);
const now = new Date();
if (nextAllowedTime <= now) {
return 0;
}
return Math.ceil((nextAllowedTime.getTime() - now.getTime()) / 1000);
}
/**
* Log a rate limit violation to the activity log.
* Requirement 10.4: Log violations when rate limits are exceeded.
*/
async function logRateLimitViolation(
botId: string,
action: 'post' | 'reply',
reason: string
): Promise<void> {
try {
await db.insert(botActivityLogs).values({
botId,
action: 'rate_limited',
details: JSON.stringify({
attemptedAction: action,
reason,
timestamp: new Date().toISOString(),
}),
success: false,
errorMessage: reason,
});
} catch (error) {
// Don't throw on logging failure - rate limiting should still work
console.error('Failed to log rate limit violation:', error);
}
}
// ============================================
// RATE LIMIT WINDOW MANAGEMENT
// ============================================
/**
* Get or create a rate limit window record for a bot.
*/
async function getOrCreateWindow(
botId: string,
windowType: WindowType,
windowStart: Date
): Promise<typeof botRateLimits.$inferSelect> {
// Try to find existing window
const existing = await db.query.botRateLimits.findFirst({
where: and(
eq(botRateLimits.botId, botId),
eq(botRateLimits.windowType, windowType),
eq(botRateLimits.windowStart, windowStart)
),
});
if (existing) {
return existing;
}
// Create new window
const [created] = await db.insert(botRateLimits).values({
botId,
windowType,
windowStart,
postCount: 0,
replyCount: 0,
}).returning();
return created;
}
/**
* Get the current post count for a bot in the daily window.
*/
async function getDailyPostCount(botId: string): Promise<number> {
const windowStart = getDailyWindowStart();
const window = await db.query.botRateLimits.findFirst({
where: and(
eq(botRateLimits.botId, botId),
eq(botRateLimits.windowType, 'daily'),
eq(botRateLimits.windowStart, windowStart)
),
});
return window?.postCount ?? 0;
}
/**
* Get the current reply count for a bot in the hourly window.
*/
async function getHourlyReplyCount(botId: string): Promise<number> {
const windowStart = getHourlyWindowStart();
const window = await db.query.botRateLimits.findFirst({
where: and(
eq(botRateLimits.botId, botId),
eq(botRateLimits.windowType, 'hourly'),
eq(botRateLimits.windowStart, windowStart)
),
});
return window?.replyCount ?? 0;
}
/**
* Get the last post timestamp for a bot.
*/
async function getLastPostAt(botId: string): Promise<Date | null> {
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { lastPostAt: true },
});
return bot?.lastPostAt ?? null;
}
// ============================================
// RATE LIMITER FUNCTIONS
// ============================================
/**
* Check if a bot can create a new post.
*
* Enforces:
* - Maximum 50 posts per day (Requirement 10.1)
* - Minimum 5 minute interval between posts (Requirement 10.2, 5.6)
*
* @param botId - The ID of the bot
* @returns Result indicating if posting is allowed
*
* Validates: Requirements 5.6, 10.1, 10.2, 10.4
*/
export async function canPost(botId: string): Promise<RateLimitCheckResult> {
// Check minimum interval between posts (Requirement 10.2, 5.6)
const lastPostAt = await getLastPostAt(botId);
const secondsUntilAllowed = getSecondsUntilNextPostAllowed(lastPostAt);
if (secondsUntilAllowed > 0) {
const reason = `Minimum interval not met. Please wait ${secondsUntilAllowed} seconds before posting again.`;
await logRateLimitViolation(botId, 'post', reason);
return {
allowed: false,
reason,
retryAfterSeconds: secondsUntilAllowed,
};
}
// Check daily post limit (Requirement 10.1)
const dailyPostCount = await getDailyPostCount(botId);
if (dailyPostCount >= RATE_LIMITS.MAX_POSTS_PER_DAY) {
// Calculate seconds until midnight UTC
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
tomorrow.setUTCHours(0, 0, 0, 0);
const secondsUntilReset = Math.ceil((tomorrow.getTime() - now.getTime()) / 1000);
const reason = `Daily post limit of ${RATE_LIMITS.MAX_POSTS_PER_DAY} reached. Resets at midnight UTC.`;
await logRateLimitViolation(botId, 'post', reason);
return {
allowed: false,
reason,
retryAfterSeconds: secondsUntilReset,
};
}
return { allowed: true };
}
/**
* Check if a bot can create a new reply.
*
* Enforces:
* - Maximum 20 replies per hour (Requirement 7.6)
*
* @param botId - The ID of the bot
* @returns Result indicating if replying is allowed
*
* Validates: Requirements 7.6, 10.4
*/
export async function canReply(botId: string): Promise<RateLimitCheckResult> {
// Check hourly reply limit (Requirement 7.6)
const hourlyReplyCount = await getHourlyReplyCount(botId);
if (hourlyReplyCount >= RATE_LIMITS.MAX_REPLIES_PER_HOUR) {
// Calculate seconds until next hour
const now = new Date();
const nextHour = new Date(now);
nextHour.setUTCHours(nextHour.getUTCHours() + 1);
nextHour.setUTCMinutes(0, 0, 0);
const secondsUntilReset = Math.ceil((nextHour.getTime() - now.getTime()) / 1000);
const reason = `Hourly reply limit of ${RATE_LIMITS.MAX_REPLIES_PER_HOUR} reached. Resets at the top of the hour.`;
await logRateLimitViolation(botId, 'reply', reason);
return {
allowed: false,
reason,
retryAfterSeconds: secondsUntilReset,
};
}
return { allowed: true };
}
/**
* Record a post action for rate limiting.
* Updates the daily post count and the bot's lastPostAt timestamp.
*
* @param botId - The ID of the bot
*
* Validates: Requirements 5.6, 10.1, 10.2
*/
export async function recordPost(botId: string): Promise<void> {
const windowStart = getDailyWindowStart();
// Get or create the daily window
const window = await getOrCreateWindow(botId, 'daily', windowStart);
// Increment post count
await db
.update(botRateLimits)
.set({ postCount: window.postCount + 1 })
.where(eq(botRateLimits.id, window.id));
// Update bot's lastPostAt timestamp
await db
.update(bots)
.set({ lastPostAt: new Date(), updatedAt: new Date() })
.where(eq(bots.id, botId));
}
/**
* Record a reply action for rate limiting.
* Updates the hourly reply count.
*
* @param botId - The ID of the bot
*
* Validates: Requirements 7.6
*/
export async function recordReply(botId: string): Promise<void> {
const windowStart = getHourlyWindowStart();
// Get or create the hourly window
const window = await getOrCreateWindow(botId, 'hourly', windowStart);
// Increment reply count
await db
.update(botRateLimits)
.set({ replyCount: window.replyCount + 1 })
.where(eq(botRateLimits.id, window.id));
}
/**
* Get the remaining quota for a bot.
*
* @param botId - The ID of the bot
* @returns Remaining daily posts and hourly replies
*
* Validates: Requirements 5.6, 7.6, 10.1, 10.2
*/
export async function getRemainingQuota(botId: string): Promise<RemainingQuota> {
const [dailyPostCount, hourlyReplyCount, lastPostAt] = await Promise.all([
getDailyPostCount(botId),
getHourlyReplyCount(botId),
getLastPostAt(botId),
]);
return {
daily: Math.max(0, RATE_LIMITS.MAX_POSTS_PER_DAY - dailyPostCount),
hourly: Math.max(0, RATE_LIMITS.MAX_REPLIES_PER_HOUR - hourlyReplyCount),
nextPostAllowedInSeconds: getSecondsUntilNextPostAllowed(lastPostAt),
};
}
/**
* Get the post count for a bot within a specified time window.
*
* @param botId - The ID of the bot
* @param windowHours - Number of hours to look back
* @returns Total post count in the window
*/
export async function getPostCount(botId: string, windowHours: number): Promise<number> {
const windowStart = new Date();
windowStart.setTime(windowStart.getTime() - windowHours * 60 * 60 * 1000);
// Get all daily windows that overlap with the requested time range
const windows = await db.query.botRateLimits.findMany({
where: and(
eq(botRateLimits.botId, botId),
eq(botRateLimits.windowType, 'daily'),
gte(botRateLimits.windowStart, getDailyWindowStart(windowStart))
),
});
return windows.reduce((sum, w) => sum + w.postCount, 0);
}
/**
* Reset rate limits for a bot (for testing or admin purposes).
*
* @param botId - The ID of the bot
*/
export async function resetRateLimits(botId: string): Promise<void> {
await db.delete(botRateLimits).where(eq(botRateLimits.botId, botId));
}
+649
View File
@@ -0,0 +1,649 @@
/**
* Property-Based Tests for RSS Parsing Correctness
*
* Feature: bot-system, Property 12: RSS Parsing Correctness
*
* Tests that RSS/Atom feed parsing correctly extracts all items with their
* titles, content, URLs, and publication dates using fast-check for
* property-based testing.
*
* **Validates: Requirements 4.2**
*/
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import {
parseRSSFeed,
parseFeedItems,
FeedItem,
ParsedFeed,
} from './rssParser';
// ============================================
// TYPES FOR GENERATORS
// ============================================
interface GeneratedFeedItem {
id: string;
title: string;
content: string;
url: string;
publishedAt: Date;
}
interface GeneratedFeed {
title: string;
description: string;
link: string;
items: GeneratedFeedItem[];
feedType: 'rss2' | 'atom';
}
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Escape special XML characters in text content.
*/
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Format a date as RFC 822 (RSS 2.0 format).
*/
function formatRFC822(date: Date): string {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const day = days[date.getUTCDay()];
const dayNum = String(date.getUTCDate()).padStart(2, '0');
const month = months[date.getUTCMonth()];
const year = date.getUTCFullYear();
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
return `${day}, ${dayNum} ${month} ${year} ${hours}:${minutes}:${seconds} GMT`;
}
/**
* Format a date as ISO 8601 (Atom format).
*/
function formatISO8601(date: Date): string {
return date.toISOString();
}
/**
* Generate RSS 2.0 XML from a generated feed.
*/
function generateRSS2XML(feed: GeneratedFeed): string {
const itemsXml = feed.items.map(item => `
<item>
<title>${escapeXml(item.title)}</title>
<link>${escapeXml(item.url)}</link>
<description>${escapeXml(item.content)}</description>
<pubDate>${formatRFC822(item.publishedAt)}</pubDate>
<guid>${escapeXml(item.id)}</guid>
</item>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${escapeXml(feed.title)}</title>
<link>${escapeXml(feed.link)}</link>
<description>${escapeXml(feed.description)}</description>${itemsXml}
</channel>
</rss>`;
}
/**
* Generate Atom XML from a generated feed.
*/
function generateAtomXML(feed: GeneratedFeed): string {
const entriesXml = feed.items.map(item => `
<entry>
<title>${escapeXml(item.title)}</title>
<link href="${escapeXml(item.url)}" rel="alternate"/>
<id>${escapeXml(item.id)}</id>
<published>${formatISO8601(item.publishedAt)}</published>
<updated>${formatISO8601(item.publishedAt)}</updated>
<summary>${escapeXml(item.content)}</summary>
</entry>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${escapeXml(feed.title)}</title>
<subtitle>${escapeXml(feed.description)}</subtitle>
<link href="${escapeXml(feed.link)}" rel="alternate"/>
<id>urn:uuid:${feed.title.replace(/\s+/g, '-').toLowerCase()}</id>
<updated>${formatISO8601(new Date())}</updated>${entriesXml}
</feed>`;
}
/**
* Normalize a string for comparison (trim whitespace, normalize spaces).
*/
function normalizeString(str: string): string {
return str.trim().replace(/\s+/g, ' ');
}
/**
* Compare two dates allowing for small differences due to parsing.
* Returns true if dates are within 1 second of each other.
*/
function datesAreClose(date1: Date, date2: Date): boolean {
const diff = Math.abs(date1.getTime() - date2.getTime());
return diff < 1000; // Within 1 second
}
// ============================================
// GENERATORS
// ============================================
/**
* Generator for safe text content (no XML special characters that could break parsing).
* Generates alphanumeric strings with spaces.
*/
const safeTextArb = fc.stringMatching(/^[a-zA-Z0-9 ]{1,100}$/)
.filter(s => s.trim().length > 0);
/**
* Generator for feed/item titles.
*/
const titleArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9 ]{0,49}$/)
.filter(s => s.trim().length > 0);
/**
* Generator for content/description text.
*/
const contentArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9 .,!?]{0,199}$/)
.filter(s => s.trim().length > 0);
/**
* Generator for valid URLs.
*/
const urlArb = fc.stringMatching(/^[a-z0-9]{3,20}$/)
.map(s => `https://example.com/${s}`);
/**
* Generator for unique IDs.
*/
const idArb = fc.stringMatching(/^[a-z0-9]{8,16}$/)
.map(s => `item-${s}`);
/**
* Generator for dates within a reasonable range (last 5 years).
* Uses integer timestamps to avoid NaN date issues.
*/
const dateArb = fc.integer({
min: new Date('2020-01-01T00:00:00Z').getTime(),
max: new Date('2025-12-31T23:59:59Z').getTime(),
}).map(timestamp => new Date(timestamp));
/**
* Generator for a single feed item.
*/
const feedItemArb: fc.Arbitrary<GeneratedFeedItem> = fc.record({
id: idArb,
title: titleArb,
content: contentArb,
url: urlArb,
publishedAt: dateArb,
});
/**
* Generator for a list of unique feed items (1-10 items).
*/
const feedItemsArb = fc.array(feedItemArb, { minLength: 1, maxLength: 10 })
.map(items => {
// Ensure unique IDs and URLs
const seenIds = new Set<string>();
const seenUrls = new Set<string>();
return items.filter(item => {
if (seenIds.has(item.id) || seenUrls.has(item.url)) {
return false;
}
seenIds.add(item.id);
seenUrls.add(item.url);
return true;
});
})
.filter(items => items.length > 0);
/**
* Generator for feed type.
*/
const feedTypeArb = fc.constantFrom<'rss2' | 'atom'>('rss2', 'atom');
/**
* Generator for a complete feed.
*/
const feedArb: fc.Arbitrary<GeneratedFeed> = fc.record({
title: titleArb,
description: contentArb,
link: urlArb,
items: feedItemsArb,
feedType: feedTypeArb,
});
/**
* Generator for RSS 2.0 feeds specifically.
*/
const rss2FeedArb: fc.Arbitrary<GeneratedFeed> = fc.record({
title: titleArb,
description: contentArb,
link: urlArb,
items: feedItemsArb,
feedType: fc.constant<'rss2'>('rss2'),
});
/**
* Generator for Atom feeds specifically.
*/
const atomFeedArb: fc.Arbitrary<GeneratedFeed> = fc.record({
title: titleArb,
description: contentArb,
link: urlArb,
items: feedItemsArb,
feedType: fc.constant<'atom'>('atom'),
});
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 12: RSS Parsing Correctness', () => {
/**
* Property 12: RSS Parsing Correctness
*
* *For any* valid RSS feed XML, parsing SHALL extract all items with their
* titles, content, URLs, and publication dates.
*
* **Validates: Requirements 4.2**
*/
describe('RSS 2.0 Feed Parsing', () => {
it('extracts all items from valid RSS 2.0 feeds', () => {
fc.assert(
fc.property(rss2FeedArb, (generatedFeed) => {
const xml = generateRSS2XML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
expect(result.feed).toBeDefined();
expect(result.feed!.items.length).toBe(generatedFeed.items.length);
}),
{ numRuns: 100 }
);
});
it('extracts correct titles from RSS 2.0 items', () => {
fc.assert(
fc.property(rss2FeedArb, (generatedFeed) => {
const xml = generateRSS2XML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
const parsedTitles = result.feed!.items.map(item => normalizeString(item.title));
const expectedTitles = generatedFeed.items.map(item => normalizeString(item.title));
// All expected titles should be present in parsed titles
for (const expectedTitle of expectedTitles) {
expect(parsedTitles).toContain(expectedTitle);
}
}),
{ numRuns: 100 }
);
});
it('extracts correct content from RSS 2.0 items', () => {
fc.assert(
fc.property(rss2FeedArb, (generatedFeed) => {
const xml = generateRSS2XML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
const parsedContents = result.feed!.items.map(item => normalizeString(item.content));
const expectedContents = generatedFeed.items.map(item => normalizeString(item.content));
// All expected contents should be present in parsed contents
for (const expectedContent of expectedContents) {
expect(parsedContents).toContain(expectedContent);
}
}),
{ numRuns: 100 }
);
});
it('extracts correct URLs from RSS 2.0 items', () => {
fc.assert(
fc.property(rss2FeedArb, (generatedFeed) => {
const xml = generateRSS2XML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
const parsedUrls = result.feed!.items.map(item => item.url);
const expectedUrls = generatedFeed.items.map(item => item.url);
// All expected URLs should be present in parsed URLs
for (const expectedUrl of expectedUrls) {
expect(parsedUrls).toContain(expectedUrl);
}
}),
{ numRuns: 100 }
);
});
it('extracts correct publication dates from RSS 2.0 items', () => {
fc.assert(
fc.property(rss2FeedArb, (generatedFeed) => {
const xml = generateRSS2XML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
// For each generated item, find the corresponding parsed item and check date
for (const generatedItem of generatedFeed.items) {
const parsedItem = result.feed!.items.find(
item => item.url === generatedItem.url
);
expect(parsedItem).toBeDefined();
expect(parsedItem!.publishedAt).toBeInstanceOf(Date);
expect(datesAreClose(parsedItem!.publishedAt, generatedItem.publishedAt)).toBe(true);
}
}),
{ numRuns: 100 }
);
});
it('extracts correct feed metadata from RSS 2.0 feeds', () => {
fc.assert(
fc.property(rss2FeedArb, (generatedFeed) => {
const xml = generateRSS2XML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
expect(result.feed!.feedType).toBe('rss2');
expect(normalizeString(result.feed!.title)).toBe(normalizeString(generatedFeed.title));
expect(normalizeString(result.feed!.description)).toBe(normalizeString(generatedFeed.description));
expect(result.feed!.link).toBe(generatedFeed.link);
}),
{ numRuns: 100 }
);
});
});
describe('Atom Feed Parsing', () => {
it('extracts all items from valid Atom feeds', () => {
fc.assert(
fc.property(atomFeedArb, (generatedFeed) => {
const xml = generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
expect(result.feed).toBeDefined();
expect(result.feed!.items.length).toBe(generatedFeed.items.length);
}),
{ numRuns: 100 }
);
});
it('extracts correct titles from Atom entries', () => {
fc.assert(
fc.property(atomFeedArb, (generatedFeed) => {
const xml = generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
const parsedTitles = result.feed!.items.map(item => normalizeString(item.title));
const expectedTitles = generatedFeed.items.map(item => normalizeString(item.title));
for (const expectedTitle of expectedTitles) {
expect(parsedTitles).toContain(expectedTitle);
}
}),
{ numRuns: 100 }
);
});
it('extracts correct content from Atom entries', () => {
fc.assert(
fc.property(atomFeedArb, (generatedFeed) => {
const xml = generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
const parsedContents = result.feed!.items.map(item => normalizeString(item.content));
const expectedContents = generatedFeed.items.map(item => normalizeString(item.content));
for (const expectedContent of expectedContents) {
expect(parsedContents).toContain(expectedContent);
}
}),
{ numRuns: 100 }
);
});
it('extracts correct URLs from Atom entries', () => {
fc.assert(
fc.property(atomFeedArb, (generatedFeed) => {
const xml = generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
const parsedUrls = result.feed!.items.map(item => item.url);
const expectedUrls = generatedFeed.items.map(item => item.url);
for (const expectedUrl of expectedUrls) {
expect(parsedUrls).toContain(expectedUrl);
}
}),
{ numRuns: 100 }
);
});
it('extracts correct publication dates from Atom entries', () => {
fc.assert(
fc.property(atomFeedArb, (generatedFeed) => {
const xml = generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
for (const generatedItem of generatedFeed.items) {
const parsedItem = result.feed!.items.find(
item => item.url === generatedItem.url
);
expect(parsedItem).toBeDefined();
expect(parsedItem!.publishedAt).toBeInstanceOf(Date);
expect(datesAreClose(parsedItem!.publishedAt, generatedItem.publishedAt)).toBe(true);
}
}),
{ numRuns: 100 }
);
});
it('extracts correct feed metadata from Atom feeds', () => {
fc.assert(
fc.property(atomFeedArb, (generatedFeed) => {
const xml = generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
expect(result.feed!.feedType).toBe('atom');
expect(normalizeString(result.feed!.title)).toBe(normalizeString(generatedFeed.title));
expect(normalizeString(result.feed!.description)).toBe(normalizeString(generatedFeed.description));
}),
{ numRuns: 100 }
);
});
});
describe('Cross-format parsing properties', () => {
it('all parsed items have required fields (title, content, url, publishedAt)', () => {
fc.assert(
fc.property(feedArb, (generatedFeed) => {
const xml = generatedFeed.feedType === 'rss2'
? generateRSS2XML(generatedFeed)
: generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
for (const item of result.feed!.items) {
// Title should be a non-empty string
expect(typeof item.title).toBe('string');
expect(item.title.length).toBeGreaterThan(0);
// Content should be a string (can be empty in some cases)
expect(typeof item.content).toBe('string');
// URL should be a valid URL string
expect(typeof item.url).toBe('string');
expect(item.url.startsWith('http')).toBe(true);
// PublishedAt should be a valid Date
expect(item.publishedAt).toBeInstanceOf(Date);
expect(isNaN(item.publishedAt.getTime())).toBe(false);
// ID should be present
expect(typeof item.id).toBe('string');
expect(item.id.length).toBeGreaterThan(0);
}
}),
{ numRuns: 100 }
);
});
it('item count is preserved during parsing', () => {
fc.assert(
fc.property(feedArb, (generatedFeed) => {
const xml = generatedFeed.feedType === 'rss2'
? generateRSS2XML(generatedFeed)
: generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
expect(result.feed!.items.length).toBe(generatedFeed.items.length);
}),
{ numRuns: 100 }
);
});
it('parseFeedItems returns same items as parseRSSFeed', () => {
fc.assert(
fc.property(feedArb, (generatedFeed) => {
const xml = generatedFeed.feedType === 'rss2'
? generateRSS2XML(generatedFeed)
: generateAtomXML(generatedFeed);
const fullResult = parseRSSFeed(xml);
const itemsOnly = parseFeedItems(xml);
expect(fullResult.success).toBe(true);
expect(itemsOnly.length).toBe(fullResult.feed!.items.length);
// Items should have the same URLs
const fullUrls = fullResult.feed!.items.map(i => i.url).sort();
const itemUrls = itemsOnly.map(i => i.url).sort();
expect(fullUrls).toEqual(itemUrls);
}),
{ numRuns: 100 }
);
});
it('parsing is deterministic - same input produces same output', () => {
fc.assert(
fc.property(feedArb, (generatedFeed) => {
const xml = generatedFeed.feedType === 'rss2'
? generateRSS2XML(generatedFeed)
: generateAtomXML(generatedFeed);
const result1 = parseRSSFeed(xml);
const result2 = parseRSSFeed(xml);
expect(result1.success).toBe(result2.success);
expect(result1.feed!.items.length).toBe(result2.feed!.items.length);
// Same items in same order
for (let i = 0; i < result1.feed!.items.length; i++) {
expect(result1.feed!.items[i].id).toBe(result2.feed!.items[i].id);
expect(result1.feed!.items[i].title).toBe(result2.feed!.items[i].title);
expect(result1.feed!.items[i].url).toBe(result2.feed!.items[i].url);
}
}),
{ numRuns: 100 }
);
});
});
describe('Data integrity properties', () => {
it('no data loss - all generated item data is recoverable', () => {
fc.assert(
fc.property(feedArb, (generatedFeed) => {
const xml = generatedFeed.feedType === 'rss2'
? generateRSS2XML(generatedFeed)
: generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
// Create maps for easy lookup
const parsedByUrl = new Map(
result.feed!.items.map(item => [item.url, item])
);
// Verify each generated item can be found with correct data
for (const generatedItem of generatedFeed.items) {
const parsedItem = parsedByUrl.get(generatedItem.url);
expect(parsedItem).toBeDefined();
expect(normalizeString(parsedItem!.title)).toBe(normalizeString(generatedItem.title));
expect(normalizeString(parsedItem!.content)).toBe(normalizeString(generatedItem.content));
expect(datesAreClose(parsedItem!.publishedAt, generatedItem.publishedAt)).toBe(true);
}
}),
{ numRuns: 100 }
);
});
it('unique items remain unique after parsing', () => {
fc.assert(
fc.property(feedArb, (generatedFeed) => {
const xml = generatedFeed.feedType === 'rss2'
? generateRSS2XML(generatedFeed)
: generateAtomXML(generatedFeed);
const result = parseRSSFeed(xml);
expect(result.success).toBe(true);
// Check that all URLs are unique
const urls = result.feed!.items.map(item => item.url);
const uniqueUrls = new Set(urls);
expect(uniqueUrls.size).toBe(urls.length);
// Check that all IDs are unique
const ids = result.feed!.items.map(item => item.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
}),
{ numRuns: 100 }
);
});
});
});
+648
View File
@@ -0,0 +1,648 @@
/**
* Unit Tests for RSS Feed Parser
*
* Tests RSS 2.0, Atom, and RSS 1.0 feed parsing with various edge cases.
*
* Requirements: 4.2
* Validates: Property 12 - RSS Parsing Correctness
*/
import { describe, it, expect } from 'vitest';
import {
parseRSSFeed,
parseFeedItems,
isValidFeed,
getFeedMetadata,
detectFeedType,
parseDate,
RSSParseError,
FeedItem,
ParsedFeed,
} from './rssParser';
// ============================================
// TEST DATA - RSS 2.0 FEEDS
// ============================================
const validRSS2Feed = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Test RSS Feed</title>
<link>https://example.com</link>
<description>A test RSS feed for unit testing</description>
<item>
<title>First Article</title>
<link>https://example.com/article1</link>
<description>This is the first article content.</description>
<pubDate>Mon, 15 Jan 2024 10:30:00 GMT</pubDate>
<guid>article-1</guid>
</item>
<item>
<title>Second Article</title>
<link>https://example.com/article2</link>
<description>This is the second article content.</description>
<pubDate>Tue, 16 Jan 2024 14:00:00 GMT</pubDate>
<guid>article-2</guid>
</item>
</channel>
</rss>`;
const rss2WithCDATA = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>CDATA Test Feed</title>
<link>https://example.com</link>
<description>Testing CDATA content</description>
<item>
<title><![CDATA[Article with <Special> Characters]]></title>
<link>https://example.com/cdata-article</link>
<description><![CDATA[<p>HTML content with <strong>tags</strong></p>]]></description>
<pubDate>Wed, 17 Jan 2024 09:00:00 GMT</pubDate>
<guid>cdata-article</guid>
</item>
</channel>
</rss>`;
const rss2WithContentEncoded = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Content Encoded Feed</title>
<link>https://example.com</link>
<description>Testing content:encoded</description>
<item>
<title>Full Content Article</title>
<link>https://example.com/full-content</link>
<description>Short description</description>
<content:encoded><![CDATA[<p>This is the full article content with more details.</p>]]></content:encoded>
<pubDate>Thu, 18 Jan 2024 11:00:00 GMT</pubDate>
<guid>full-content-article</guid>
</item>
</channel>
</rss>`;
// ============================================
// TEST DATA - ATOM FEEDS
// ============================================
const validAtomFeed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test Atom Feed</title>
<subtitle>A test Atom feed for unit testing</subtitle>
<link href="https://example.com" rel="alternate"/>
<id>urn:uuid:test-feed-id</id>
<updated>2024-01-15T10:30:00Z</updated>
<entry>
<title>First Entry</title>
<link href="https://example.com/entry1" rel="alternate"/>
<id>urn:uuid:entry-1</id>
<published>2024-01-15T10:30:00Z</published>
<updated>2024-01-15T10:30:00Z</updated>
<summary>This is the first entry summary.</summary>
</entry>
<entry>
<title>Second Entry</title>
<link href="https://example.com/entry2" rel="alternate"/>
<id>urn:uuid:entry-2</id>
<published>2024-01-16T14:00:00Z</published>
<updated>2024-01-16T14:00:00Z</updated>
<content type="html">This is the second entry content.</content>
</entry>
</feed>`;
const atomWithContent = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Content Feed</title>
<link href="https://example.com"/>
<entry>
<title>Content Entry</title>
<link href="https://example.com/content-entry"/>
<id>content-entry-1</id>
<updated>2024-01-17T09:00:00Z</updated>
<content type="html"><![CDATA[<p>Full HTML content here</p>]]></content>
</entry>
</feed>`;
// ============================================
// TEST DATA - RSS 1.0 (RDF) FEEDS
// ============================================
const validRSS1Feed = `<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Test RSS 1.0 Feed</title>
<link>https://example.com</link>
<description>A test RSS 1.0 feed</description>
</channel>
<item rdf:about="https://example.com/rdf-item1">
<title>RDF Item 1</title>
<link>https://example.com/rdf-item1</link>
<description>First RDF item description</description>
<dc:date>2024-01-15T10:30:00Z</dc:date>
</item>
</rdf:RDF>`;
// ============================================
// TEST DATA - MALFORMED FEEDS
// ============================================
const malformedFeed = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Malformed Feed</title>
<item>
<title>Item without closing tag
<link>https://example.com/broken</link>
</item>
<item>
<title>Valid Item</title>
<link>https://example.com/valid</link>
<description>This item is valid</description>
</item>
</channel>
</rss>`;
const feedWithMissingFields = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Incomplete Feed</title>
<item>
<link>https://example.com/no-title</link>
<description>Item without title</description>
</item>
<item>
<title>Item without link</title>
<description>This item has no link</description>
</item>
<item>
<title></title>
<description></description>
</item>
</channel>
</rss>`;
const feedWithInvalidDates = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Invalid Dates Feed</title>
<item>
<title>Item with invalid date</title>
<link>https://example.com/invalid-date</link>
<pubDate>not-a-date</pubDate>
</item>
<item>
<title>Item with empty date</title>
<link>https://example.com/empty-date</link>
<pubDate></pubDate>
</item>
</channel>
</rss>`;
// ============================================
// FEED TYPE DETECTION TESTS
// ============================================
describe('detectFeedType', () => {
it('should detect RSS 2.0 feeds', () => {
expect(detectFeedType(validRSS2Feed)).toBe('rss2');
expect(detectFeedType('<rss version="2.0"><channel></channel></rss>')).toBe('rss2');
});
it('should detect Atom feeds', () => {
expect(detectFeedType(validAtomFeed)).toBe('atom');
expect(detectFeedType('<feed xmlns="http://www.w3.org/2005/Atom"></feed>')).toBe('atom');
});
it('should detect RSS 1.0 (RDF) feeds', () => {
expect(detectFeedType(validRSS1Feed)).toBe('rss1');
});
it('should return unknown for unrecognized formats', () => {
expect(detectFeedType('<html><body>Not a feed</body></html>')).toBe('unknown');
expect(detectFeedType('plain text')).toBe('unknown');
});
});
// ============================================
// DATE PARSING TESTS
// ============================================
describe('parseDate', () => {
it('should parse ISO 8601 dates', () => {
const date = parseDate('2024-01-15T10:30:00Z');
expect(date).toBeInstanceOf(Date);
expect(date?.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should parse ISO 8601 dates with timezone offset', () => {
const date = parseDate('2024-01-15T10:30:00+00:00');
expect(date).toBeInstanceOf(Date);
});
it('should parse RFC 822 dates', () => {
const date = parseDate('Mon, 15 Jan 2024 10:30:00 GMT');
expect(date).toBeInstanceOf(Date);
expect(date?.getUTCFullYear()).toBe(2024);
expect(date?.getUTCMonth()).toBe(0); // January
expect(date?.getUTCDate()).toBe(15);
});
it('should parse simple date formats', () => {
const date = parseDate('2024-01-15');
expect(date).toBeInstanceOf(Date);
expect(date?.getUTCFullYear()).toBe(2024);
});
it('should return null for invalid dates', () => {
expect(parseDate('not-a-date')).toBe(null);
expect(parseDate('')).toBe(null);
expect(parseDate(null)).toBe(null);
});
it('should handle whitespace', () => {
const date = parseDate(' 2024-01-15T10:30:00Z ');
expect(date).toBeInstanceOf(Date);
});
});
// ============================================
// RSS 2.0 PARSING TESTS
// ============================================
describe('parseRSSFeed - RSS 2.0', () => {
it('should parse a valid RSS 2.0 feed', () => {
const result = parseRSSFeed(validRSS2Feed);
expect(result.success).toBe(true);
expect(result.feed).toBeDefined();
expect(result.feed?.feedType).toBe('rss2');
expect(result.feed?.title).toBe('Test RSS Feed');
expect(result.feed?.description).toBe('A test RSS feed for unit testing');
expect(result.feed?.link).toBe('https://example.com');
expect(result.feed?.items).toHaveLength(2);
});
it('should extract all item fields correctly', () => {
const result = parseRSSFeed(validRSS2Feed);
const firstItem = result.feed?.items[0];
expect(firstItem?.title).toBe('First Article');
expect(firstItem?.url).toBe('https://example.com/article1');
expect(firstItem?.content).toBe('This is the first article content.');
expect(firstItem?.id).toBe('article-1');
expect(firstItem?.publishedAt).toBeInstanceOf(Date);
});
it('should handle CDATA sections', () => {
const result = parseRSSFeed(rss2WithCDATA);
expect(result.success).toBe(true);
expect(result.feed?.items[0]?.title).toBe('Article with <Special> Characters');
// HTML should be stripped from content
expect(result.feed?.items[0]?.content).toBe('HTML content with tags');
});
it('should prefer content:encoded over description', () => {
const result = parseRSSFeed(rss2WithContentEncoded);
expect(result.success).toBe(true);
// content:encoded should be used, HTML stripped
expect(result.feed?.items[0]?.content).toBe('This is the full article content with more details.');
});
});
// ============================================
// ATOM PARSING TESTS
// ============================================
describe('parseRSSFeed - Atom', () => {
it('should parse a valid Atom feed', () => {
const result = parseRSSFeed(validAtomFeed);
expect(result.success).toBe(true);
expect(result.feed).toBeDefined();
expect(result.feed?.feedType).toBe('atom');
expect(result.feed?.title).toBe('Test Atom Feed');
expect(result.feed?.description).toBe('A test Atom feed for unit testing');
expect(result.feed?.items).toHaveLength(2);
});
it('should extract Atom entry fields correctly', () => {
const result = parseRSSFeed(validAtomFeed);
const firstEntry = result.feed?.items[0];
expect(firstEntry?.title).toBe('First Entry');
expect(firstEntry?.id).toBe('urn:uuid:entry-1');
expect(firstEntry?.content).toBe('This is the first entry summary.');
expect(firstEntry?.publishedAt).toBeInstanceOf(Date);
});
it('should handle Atom content element', () => {
const result = parseRSSFeed(atomWithContent);
expect(result.success).toBe(true);
expect(result.feed?.items[0]?.content).toBe('Full HTML content here');
});
});
// ============================================
// RSS 1.0 PARSING TESTS
// ============================================
describe('parseRSSFeed - RSS 1.0', () => {
it('should parse a valid RSS 1.0 feed', () => {
const result = parseRSSFeed(validRSS1Feed);
expect(result.success).toBe(true);
expect(result.feed).toBeDefined();
expect(result.feed?.feedType).toBe('rss1');
expect(result.feed?.title).toBe('Test RSS 1.0 Feed');
expect(result.feed?.items).toHaveLength(1);
});
it('should extract RSS 1.0 item fields', () => {
const result = parseRSSFeed(validRSS1Feed);
const item = result.feed?.items[0];
expect(item?.title).toBe('RDF Item 1');
expect(item?.url).toBe('https://example.com/rdf-item1');
expect(item?.content).toBe('First RDF item description');
});
});
// ============================================
// MALFORMED FEED HANDLING TESTS
// ============================================
describe('parseRSSFeed - Malformed feeds', () => {
it('should handle feeds with missing fields gracefully', () => {
const result = parseRSSFeed(feedWithMissingFields);
expect(result.success).toBe(true);
// Should parse items that have at least title or description
expect(result.feed?.items.length).toBeGreaterThan(0);
// Items without both title and content should be skipped
expect(result.warnings.length).toBeGreaterThan(0);
});
it('should handle feeds with invalid dates', () => {
const result = parseRSSFeed(feedWithInvalidDates);
expect(result.success).toBe(true);
// Items should still be parsed with default dates
expect(result.feed?.items).toHaveLength(2);
// Should have warnings about unparseable dates
expect(result.warnings.some(w => w.includes('date'))).toBe(true);
});
it('should reject empty input', () => {
expect(parseRSSFeed('').success).toBe(false);
expect(parseRSSFeed(' ').success).toBe(false);
});
it('should reject non-XML input', () => {
const result = parseRSSFeed('This is not XML');
expect(result.success).toBe(false);
expect(result.error).toContain('XML');
});
it('should reject null/undefined input', () => {
expect(parseRSSFeed(null as unknown as string).success).toBe(false);
expect(parseRSSFeed(undefined as unknown as string).success).toBe(false);
});
it('should handle HTML instead of XML', () => {
const html = '<html><head><title>Not a feed</title></head><body>Content</body></html>';
const result = parseRSSFeed(html);
// Should fail or return empty items
expect(result.feed?.items.length || 0).toBe(0);
});
});
// ============================================
// HELPER FUNCTION TESTS
// ============================================
describe('parseFeedItems', () => {
it('should return items array for valid feed', () => {
const items = parseFeedItems(validRSS2Feed);
expect(Array.isArray(items)).toBe(true);
expect(items).toHaveLength(2);
expect(items[0].title).toBe('First Article');
});
it('should throw RSSParseError for invalid feed', () => {
expect(() => parseFeedItems('')).toThrow(RSSParseError);
expect(() => parseFeedItems('not xml')).toThrow(RSSParseError);
});
});
describe('isValidFeed', () => {
it('should return true for valid feeds', () => {
expect(isValidFeed(validRSS2Feed)).toBe(true);
expect(isValidFeed(validAtomFeed)).toBe(true);
expect(isValidFeed(validRSS1Feed)).toBe(true);
});
it('should return false for invalid feeds', () => {
expect(isValidFeed('')).toBe(false);
expect(isValidFeed('not xml')).toBe(false);
expect(isValidFeed('<html></html>')).toBe(false);
});
});
describe('getFeedMetadata', () => {
it('should return metadata for valid feed', () => {
const metadata = getFeedMetadata(validRSS2Feed);
expect(metadata).not.toBeNull();
expect(metadata?.title).toBe('Test RSS Feed');
expect(metadata?.description).toBe('A test RSS feed for unit testing');
expect(metadata?.link).toBe('https://example.com');
expect(metadata?.feedType).toBe('rss2');
});
it('should return null for invalid feed', () => {
expect(getFeedMetadata('')).toBeNull();
expect(getFeedMetadata('not xml')).toBeNull();
});
});
// ============================================
// EDGE CASE TESTS
// ============================================
describe('Edge cases', () => {
it('should handle XML entities', () => {
const feedWithEntities = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Entity &amp; Test</title>
<item>
<title>Article &lt;1&gt;</title>
<link>https://example.com/test</link>
<description>Testing &quot;quotes&quot; and &apos;apostrophes&apos;</description>
</item>
</channel>
</rss>`;
const result = parseRSSFeed(feedWithEntities);
expect(result.success).toBe(true);
expect(result.feed?.title).toBe('Entity & Test');
expect(result.feed?.items[0]?.title).toBe('Article <1>');
expect(result.feed?.items[0]?.content).toContain('"quotes"');
});
it('should handle numeric character references', () => {
const feedWithNumericRefs = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Numeric &#65; Test</title>
<item>
<title>Test &#x41; Item</title>
<link>https://example.com/test</link>
<description>Content</description>
</item>
</channel>
</rss>`;
const result = parseRSSFeed(feedWithNumericRefs);
expect(result.success).toBe(true);
expect(result.feed?.title).toBe('Numeric A Test');
expect(result.feed?.items[0]?.title).toBe('Test A Item');
});
it('should handle feeds with no items', () => {
const emptyFeed = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Empty Feed</title>
<link>https://example.com</link>
<description>A feed with no items</description>
</channel>
</rss>`;
const result = parseRSSFeed(emptyFeed);
expect(result.success).toBe(true);
expect(result.feed?.items).toHaveLength(0);
});
it('should handle very long content', () => {
const longContent = 'A'.repeat(10000);
const feedWithLongContent = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Long Content Feed</title>
<item>
<title>Long Article</title>
<link>https://example.com/long</link>
<description>${longContent}</description>
</item>
</channel>
</rss>`;
const result = parseRSSFeed(feedWithLongContent);
expect(result.success).toBe(true);
expect(result.feed?.items[0]?.content.length).toBe(10000);
});
it('should generate IDs when guid is missing', () => {
const feedWithoutGuid = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>No GUID Feed</title>
<item>
<title>Article without GUID</title>
<link>https://example.com/no-guid</link>
<description>Content</description>
</item>
</channel>
</rss>`;
const result = parseRSSFeed(feedWithoutGuid);
expect(result.success).toBe(true);
// Should use link as ID or generate one
expect(result.feed?.items[0]?.id).toBeTruthy();
});
it('should strip HTML tags from content', () => {
const feedWithHtml = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>HTML Content Feed</title>
<item>
<title>HTML Article</title>
<link>https://example.com/html</link>
<description><![CDATA[<p>Paragraph with <strong>bold</strong> and <a href="#">link</a></p>]]></description>
</item>
</channel>
</rss>`;
const result = parseRSSFeed(feedWithHtml);
expect(result.success).toBe(true);
expect(result.feed?.items[0]?.content).toBe('Paragraph with bold and link');
});
});
// ============================================
// REAL-WORLD FEED STRUCTURE TESTS
// ============================================
describe('Real-world feed structures', () => {
it('should handle WordPress RSS feeds', () => {
const wordpressFeed = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>WordPress Blog</title>
<link>https://blog.example.com</link>
<description>A WordPress blog</description>
<atom:link href="https://blog.example.com/feed/" rel="self" type="application/rss+xml"/>
<item>
<title>Blog Post Title</title>
<link>https://blog.example.com/post-1/</link>
<dc:creator>Author Name</dc:creator>
<pubDate>Fri, 19 Jan 2024 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://blog.example.com/?p=123</guid>
<description>Short excerpt...</description>
<content:encoded><![CDATA[<p>Full post content here.</p>]]></content:encoded>
</item>
</channel>
</rss>`;
const result = parseRSSFeed(wordpressFeed);
expect(result.success).toBe(true);
expect(result.feed?.items[0]?.content).toBe('Full post content here.');
});
it('should handle GitHub Atom feeds', () => {
const githubFeed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<id>tag:github.com,2008:https://github.com/user/repo/releases</id>
<link type="text/html" rel="alternate" href="https://github.com/user/repo/releases"/>
<link type="application/atom+xml" rel="self" href="https://github.com/user/repo/releases.atom"/>
<title>Release notes from repo</title>
<updated>2024-01-19T10:00:00Z</updated>
<entry>
<id>tag:github.com,2008:Repository/123/v1.0.0</id>
<updated>2024-01-19T10:00:00Z</updated>
<link rel="alternate" type="text/html" href="https://github.com/user/repo/releases/tag/v1.0.0"/>
<title>v1.0.0</title>
<content type="html">Release notes content</content>
<author>
<name>username</name>
</author>
</entry>
</feed>`;
const result = parseRSSFeed(githubFeed);
expect(result.success).toBe(true);
expect(result.feed?.feedType).toBe('atom');
expect(result.feed?.items[0]?.title).toBe('v1.0.0');
});
});
+694
View File
@@ -0,0 +1,694 @@
/**
* RSS Feed Parser
*
* Parses RSS 2.0 and Atom feeds to extract feed items with title, content, URL, and publication date.
* Handles malformed feeds gracefully with detailed error reporting.
*
* Requirements: 4.2
* Validates: Property 12 - RSS Parsing Correctness
*/
// ============================================
// TYPES
// ============================================
/**
* Represents a parsed feed item.
*/
export interface FeedItem {
/** Unique identifier for the item (guid, id, or generated from URL) */
id: string;
/** Title of the item */
title: string;
/** Content or description of the item */
content: string;
/** URL link to the item */
url: string;
/** Publication date of the item */
publishedAt: Date;
}
/**
* Represents a parsed feed with metadata and items.
*/
export interface ParsedFeed {
/** Feed title */
title: string;
/** Feed description */
description: string;
/** Feed link/URL */
link: string;
/** Feed type detected */
feedType: 'rss2' | 'atom' | 'rss1' | 'unknown';
/** Parsed feed items */
items: FeedItem[];
/** Any warnings encountered during parsing */
warnings: string[];
}
/**
* Result of a feed parsing operation.
*/
export interface ParseResult {
success: boolean;
feed?: ParsedFeed;
error?: string;
warnings: string[];
}
// ============================================
// ERROR CLASSES
// ============================================
/**
* Error thrown when RSS parsing fails.
*/
export class RSSParseError extends Error {
constructor(
message: string,
public code: string,
public details?: string
) {
super(message);
this.name = 'RSSParseError';
}
}
// ============================================
// XML PARSING UTILITIES
// ============================================
/**
* Simple XML tag content extractor.
* Extracts content between opening and closing tags.
*/
function extractTagContent(xml: string, tagName: string): string | null {
// Handle namespaced tags (e.g., content:encoded, dc:creator)
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Try to match with CDATA first
const cdataPattern = new RegExp(
`<${escapedTag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${escapedTag}>`,
'i'
);
const cdataMatch = xml.match(cdataPattern);
if (cdataMatch) {
return cdataMatch[1];
}
// Try regular content
const pattern = new RegExp(
`<${escapedTag}[^>]*>([\\s\\S]*?)</${escapedTag}>`,
'i'
);
const match = xml.match(pattern);
if (match) {
return decodeXMLEntities(match[1].trim());
}
// Try self-closing tag with content attribute (for Atom links)
const selfClosingPattern = new RegExp(
`<${escapedTag}[^>]*/>`,
'i'
);
const selfClosingMatch = xml.match(selfClosingPattern);
if (selfClosingMatch) {
return null; // Self-closing tags don't have content
}
return null;
}
/**
* Extract attribute value from an XML tag.
*/
function extractAttribute(xml: string, tagName: string, attrName: string): string | null {
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedAttr = attrName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(
`<${escapedTag}[^>]*\\s${escapedAttr}=["']([^"']*)["'][^>]*>`,
'i'
);
const match = xml.match(pattern);
return match ? decodeXMLEntities(match[1]) : null;
}
/**
* Extract all occurrences of a tag.
*/
function extractAllTags(xml: string, tagName: string): string[] {
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(
`<${escapedTag}[^>]*>[\\s\\S]*?</${escapedTag}>|<${escapedTag}[^>]*/>`,
'gi'
);
const matches = xml.match(pattern);
return matches || [];
}
/**
* Decode common XML entities.
*/
function decodeXMLEntities(text: string): string {
if (!text) return '';
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
}
/**
* Strip HTML tags from content.
*/
function stripHtmlTags(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').trim();
}
/**
* Generate a unique ID from URL or content.
*/
function generateId(url: string, title: string): string {
const input = url || title || Date.now().toString();
// Simple hash function for ID generation
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return `generated-${Math.abs(hash).toString(36)}`;
}
// ============================================
// DATE PARSING
// ============================================
/**
* Parse various date formats commonly found in RSS/Atom feeds.
*/
export function parseDate(dateStr: string | null): Date | null {
if (!dateStr || typeof dateStr !== 'string') {
return null;
}
const trimmed = dateStr.trim();
if (!trimmed) {
return null;
}
// Try ISO 8601 format (Atom standard)
// e.g., 2024-01-15T10:30:00Z, 2024-01-15T10:30:00+00:00
const isoDate = new Date(trimmed);
if (!isNaN(isoDate.getTime())) {
return isoDate;
}
// Try RFC 822 format (RSS 2.0 standard)
// e.g., Mon, 15 Jan 2024 10:30:00 GMT
const rfc822Pattern = /^(?:\w{3},?\s+)?(\d{1,2})\s+(\w{3})\s+(\d{4})\s+(\d{2}):(\d{2}):?(\d{2})?\s*(\w+|[+-]\d{4})?$/i;
const rfc822Match = trimmed.match(rfc822Pattern);
if (rfc822Match) {
const [, day, monthStr, year, hour, minute, second = '00', tz = 'GMT'] = rfc822Match;
const months: Record<string, number> = {
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11
};
const month = months[monthStr.toLowerCase()];
if (month !== undefined) {
const dateString = `${year}-${String(month + 1).padStart(2, '0')}-${day.padStart(2, '0')}T${hour}:${minute}:${second}`;
const parsed = new Date(dateString + (tz === 'GMT' || tz === 'UTC' ? 'Z' : ''));
if (!isNaN(parsed.getTime())) {
return parsed;
}
}
}
// Try simple date formats
// e.g., 2024-01-15, 01/15/2024
const simpleDatePattern = /^(\d{4})-(\d{2})-(\d{2})$/;
const simpleMatch = trimmed.match(simpleDatePattern);
if (simpleMatch) {
const parsed = new Date(trimmed + 'T00:00:00Z');
if (!isNaN(parsed.getTime())) {
return parsed;
}
}
return null;
}
// ============================================
// FEED TYPE DETECTION
// ============================================
/**
* Detect the type of feed from XML content.
*/
export function detectFeedType(xml: string): 'rss2' | 'atom' | 'rss1' | 'unknown' {
const trimmed = xml.trim();
// Check for Atom feed
if (/<feed[^>]*xmlns=["']http:\/\/www\.w3\.org\/2005\/Atom["']/i.test(trimmed) ||
/<feed[^>]*>/i.test(trimmed) && /<entry[^>]*>/i.test(trimmed)) {
return 'atom';
}
// Check for RSS 2.0
if (/<rss[^>]*version=["']2\.0["']/i.test(trimmed) ||
/<rss[^>]*>/i.test(trimmed)) {
return 'rss2';
}
// Check for RSS 1.0 (RDF-based)
if (/<rdf:RDF/i.test(trimmed) && /<item[^>]*>/i.test(trimmed)) {
return 'rss1';
}
// Check for generic RSS indicators
if (/<channel[^>]*>/i.test(trimmed) && /<item[^>]*>/i.test(trimmed)) {
return 'rss2';
}
return 'unknown';
}
// ============================================
// RSS 2.0 PARSER
// ============================================
/**
* Parse an RSS 2.0 feed.
*/
function parseRSS2(xml: string, warnings: string[]): ParsedFeed {
// Extract channel info
const channelMatch = xml.match(/<channel[^>]*>([\s\S]*?)<\/channel>/i);
const channelContent = channelMatch ? channelMatch[1] : xml;
const title = extractTagContent(channelContent, 'title') || 'Untitled Feed';
const description = extractTagContent(channelContent, 'description') || '';
const link = extractTagContent(channelContent, 'link') || '';
// Extract items
const itemTags = extractAllTags(xml, 'item');
const items: FeedItem[] = [];
for (const itemXml of itemTags) {
try {
const item = parseRSS2Item(itemXml, warnings);
if (item) {
items.push(item);
}
} catch (err) {
warnings.push(`Failed to parse item: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
return {
title,
description,
link,
feedType: 'rss2',
items,
warnings,
};
}
/**
* Parse a single RSS 2.0 item.
*/
function parseRSS2Item(itemXml: string, warnings: string[]): FeedItem | null {
const title = extractTagContent(itemXml, 'title') || '';
const link = extractTagContent(itemXml, 'link') || '';
const guid = extractTagContent(itemXml, 'guid') || '';
// Try multiple content sources
let content = extractTagContent(itemXml, 'content:encoded') ||
extractTagContent(itemXml, 'content') ||
extractTagContent(itemXml, 'description') ||
'';
// Strip HTML for plain text content
const plainContent = stripHtmlTags(content);
// Parse publication date
const pubDateStr = extractTagContent(itemXml, 'pubDate') ||
extractTagContent(itemXml, 'dc:date') ||
extractTagContent(itemXml, 'date');
let publishedAt = parseDate(pubDateStr);
if (!publishedAt) {
publishedAt = new Date(); // Default to now if no date found
if (pubDateStr) {
warnings.push(`Could not parse date: ${pubDateStr}`);
}
}
// Generate ID if not present
const id = guid || link || generateId(link, title);
// Skip items without title and content
if (!title && !plainContent) {
warnings.push('Skipping item with no title or content');
return null;
}
return {
id,
title: title || 'Untitled',
content: plainContent,
url: link,
publishedAt,
};
}
// ============================================
// ATOM PARSER
// ============================================
/**
* Parse an Atom feed.
*/
function parseAtom(xml: string, warnings: string[]): ParsedFeed {
// Extract feed info
const title = extractTagContent(xml, 'title') || 'Untitled Feed';
const subtitle = extractTagContent(xml, 'subtitle') || '';
// Atom uses <link> with href attribute
let link = extractAttribute(xml, 'link[^>]*rel=["\'](alternate|self)["\']', 'href') ||
extractAttribute(xml, 'link', 'href') ||
'';
// Extract entries
const entryTags = extractAllTags(xml, 'entry');
const items: FeedItem[] = [];
for (const entryXml of entryTags) {
try {
const item = parseAtomEntry(entryXml, warnings);
if (item) {
items.push(item);
}
} catch (err) {
warnings.push(`Failed to parse entry: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
return {
title,
description: subtitle,
link,
feedType: 'atom',
items,
warnings,
};
}
/**
* Parse a single Atom entry.
*/
function parseAtomEntry(entryXml: string, warnings: string[]): FeedItem | null {
const title = extractTagContent(entryXml, 'title') || '';
const id = extractTagContent(entryXml, 'id') || '';
// Atom link is in href attribute
const link = extractAttribute(entryXml, 'link[^>]*rel=["\'](alternate)["\']', 'href') ||
extractAttribute(entryXml, 'link', 'href') ||
'';
// Try multiple content sources
let content = extractTagContent(entryXml, 'content') ||
extractTagContent(entryXml, 'summary') ||
'';
// Strip HTML for plain text content
const plainContent = stripHtmlTags(content);
// Parse dates (Atom uses updated and published)
const publishedStr = extractTagContent(entryXml, 'published') ||
extractTagContent(entryXml, 'updated') ||
extractTagContent(entryXml, 'issued');
let publishedAt = parseDate(publishedStr);
if (!publishedAt) {
publishedAt = new Date();
if (publishedStr) {
warnings.push(`Could not parse date: ${publishedStr}`);
}
}
// Generate ID if not present
const itemId = id || link || generateId(link, title);
// Skip entries without title and content
if (!title && !plainContent) {
warnings.push('Skipping entry with no title or content');
return null;
}
return {
id: itemId,
title: title || 'Untitled',
content: plainContent,
url: link,
publishedAt,
};
}
// ============================================
// RSS 1.0 PARSER
// ============================================
/**
* Parse an RSS 1.0 (RDF) feed.
*/
function parseRSS1(xml: string, warnings: string[]): ParsedFeed {
// Extract channel info
const channelMatch = xml.match(/<channel[^>]*>([\s\S]*?)<\/channel>/i);
const channelContent = channelMatch ? channelMatch[1] : '';
const title = extractTagContent(channelContent, 'title') || 'Untitled Feed';
const description = extractTagContent(channelContent, 'description') || '';
const link = extractTagContent(channelContent, 'link') || '';
// Extract items
const itemTags = extractAllTags(xml, 'item');
const items: FeedItem[] = [];
for (const itemXml of itemTags) {
try {
const item = parseRSS1Item(itemXml, warnings);
if (item) {
items.push(item);
}
} catch (err) {
warnings.push(`Failed to parse item: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
return {
title,
description,
link,
feedType: 'rss1',
items,
warnings,
};
}
/**
* Parse a single RSS 1.0 item.
*/
function parseRSS1Item(itemXml: string, warnings: string[]): FeedItem | null {
const title = extractTagContent(itemXml, 'title') || '';
const link = extractTagContent(itemXml, 'link') || '';
const description = extractTagContent(itemXml, 'description') || '';
// RSS 1.0 uses rdf:about as identifier
const about = extractAttribute(itemXml, 'item', 'rdf:about') || '';
// Parse date (RSS 1.0 often uses dc:date)
const dateStr = extractTagContent(itemXml, 'dc:date') ||
extractTagContent(itemXml, 'date');
let publishedAt = parseDate(dateStr);
if (!publishedAt) {
publishedAt = new Date();
if (dateStr) {
warnings.push(`Could not parse date: ${dateStr}`);
}
}
const id = about || link || generateId(link, title);
const plainContent = stripHtmlTags(description);
if (!title && !plainContent) {
warnings.push('Skipping item with no title or content');
return null;
}
return {
id,
title: title || 'Untitled',
content: plainContent,
url: link,
publishedAt,
};
}
// ============================================
// MAIN PARSER FUNCTION
// ============================================
/**
* Parse an RSS or Atom feed from XML string.
*
* Supports:
* - RSS 2.0
* - Atom 1.0
* - RSS 1.0 (RDF)
*
* @param xml - The XML content of the feed
* @returns ParseResult with parsed feed or error
*
* Validates: Requirements 4.2
*/
export function parseRSSFeed(xml: string): ParseResult {
const warnings: string[] = [];
// Validate input
if (!xml || typeof xml !== 'string') {
return {
success: false,
error: 'Invalid input: XML content is required',
warnings,
};
}
const trimmed = xml.trim();
if (!trimmed) {
return {
success: false,
error: 'Invalid input: XML content is empty',
warnings,
};
}
// Check for XML declaration or root element
if (!trimmed.startsWith('<?xml') && !trimmed.startsWith('<')) {
return {
success: false,
error: 'Invalid XML: Content does not appear to be XML',
warnings,
};
}
try {
// Detect feed type
const feedType = detectFeedType(trimmed);
if (feedType === 'unknown') {
// Try to parse anyway, might be a non-standard feed
warnings.push('Could not detect feed type, attempting RSS 2.0 parsing');
}
let feed: ParsedFeed;
switch (feedType) {
case 'atom':
feed = parseAtom(trimmed, warnings);
break;
case 'rss1':
feed = parseRSS1(trimmed, warnings);
break;
case 'rss2':
case 'unknown':
default:
feed = parseRSS2(trimmed, warnings);
break;
}
// Validate we got at least some content
if (feed.items.length === 0 && !feed.title) {
return {
success: false,
error: 'Failed to parse feed: No items or feed information found',
warnings,
};
}
return {
success: true,
feed,
warnings,
};
} catch (err) {
return {
success: false,
error: `Parse error: ${err instanceof Error ? err.message : 'Unknown error'}`,
warnings,
};
}
}
/**
* Parse RSS feed and return only the items.
* Convenience function for when you only need the items.
*
* @param xml - The XML content of the feed
* @returns Array of FeedItem or throws RSSParseError
*
* Validates: Requirements 4.2
*/
export function parseFeedItems(xml: string): FeedItem[] {
const result = parseRSSFeed(xml);
if (!result.success || !result.feed) {
throw new RSSParseError(
result.error || 'Failed to parse feed',
'PARSE_ERROR',
result.warnings.join('; ')
);
}
return result.feed.items;
}
/**
* Validate that a string is a valid RSS/Atom feed.
*
* @param xml - The XML content to validate
* @returns True if the content is a valid feed
*/
export function isValidFeed(xml: string): boolean {
const result = parseRSSFeed(xml);
return result.success && result.feed !== undefined && result.feed.items.length > 0;
}
/**
* Get feed metadata without parsing all items.
* Useful for quick feed validation.
*
* @param xml - The XML content of the feed
* @returns Feed metadata or null if invalid
*/
export function getFeedMetadata(xml: string): { title: string; description: string; link: string; feedType: string } | null {
const result = parseRSSFeed(xml);
if (!result.success || !result.feed) {
return null;
}
return {
title: result.feed.title,
description: result.feed.description,
link: result.feed.link,
feedType: result.feed.feedType,
};
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Input Sanitization Module
*
* Prevents injection attacks (SQL, XSS, command injection).
* Applied to all user inputs.
*
* Requirements: 10.5
*/
// ============================================
// SANITIZATION FUNCTIONS
// ============================================
/**
* Sanitize string input to prevent XSS attacks.
* Escapes HTML special characters.
*
* @param input - Raw input string
* @returns Sanitized string
*
* Validates: Requirements 10.5
*/
export function sanitizeHTML(input: string): string {
if (!input) return '';
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}
/**
* Sanitize input to prevent command injection.
* Removes shell metacharacters.
*
* @param input - Raw input string
* @returns Sanitized string
*
* Validates: Requirements 10.5
*/
export function sanitizeCommand(input: string): string {
if (!input) return '';
// Remove shell metacharacters
return input.replace(/[;&|`$(){}[\]<>\\]/g, '');
}
/**
* Sanitize URL input.
* Ensures URL is safe and well-formed.
*
* @param input - Raw URL string
* @returns Sanitized URL or null if invalid
*
* Validates: Requirements 10.5
*/
export function sanitizeURL(input: string): string | null {
if (!input) return null;
try {
const url = new URL(input);
// Only allow http and https protocols
if (!['http:', 'https:'].includes(url.protocol)) {
return null;
}
return url.toString();
} catch {
return null;
}
}
/**
* Sanitize bot name input.
* Removes potentially dangerous characters.
*
* @param input - Raw name string
* @returns Sanitized name
*
* Validates: Requirements 10.5
*/
export function sanitizeBotName(input: string): string {
if (!input) return '';
// Allow alphanumeric, spaces, hyphens, underscores
return input.replace(/[^a-zA-Z0-9\s\-_]/g, '').trim();
}
/**
* Sanitize bot handle input.
* Ensures handle follows valid format.
*
* @param input - Raw handle string
* @returns Sanitized handle
*
* Validates: Requirements 10.5
*/
export function sanitizeBotHandle(input: string): string {
if (!input) return '';
// Allow only alphanumeric and underscores, convert to lowercase
return input.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
}
/**
* Sanitize JSON input.
* Validates and parses JSON safely.
*
* @param input - Raw JSON string
* @returns Parsed object or null if invalid
*
* Validates: Requirements 10.5
*/
export function sanitizeJSON<T = any>(input: string): T | null {
if (!input) return null;
try {
return JSON.parse(input) as T;
} catch {
return null;
}
}
/**
* Sanitize integer input.
* Ensures value is a valid integer within bounds.
*
* @param input - Raw input
* @param min - Minimum allowed value
* @param max - Maximum allowed value
* @returns Sanitized integer or null if invalid
*
* Validates: Requirements 10.5
*/
export function sanitizeInteger(
input: string | number,
min?: number,
max?: number
): number | null {
const num = typeof input === 'string' ? parseInt(input, 10) : input;
if (isNaN(num) || !isFinite(num)) {
return null;
}
if (min !== undefined && num < min) {
return null;
}
if (max !== undefined && num > max) {
return null;
}
return num;
}
/**
* Sanitize float input.
* Ensures value is a valid float within bounds.
*
* @param input - Raw input
* @param min - Minimum allowed value
* @param max - Maximum allowed value
* @returns Sanitized float or null if invalid
*
* Validates: Requirements 10.5
*/
export function sanitizeFloat(
input: string | number,
min?: number,
max?: number
): number | null {
const num = typeof input === 'string' ? parseFloat(input) : input;
if (isNaN(num) || !isFinite(num)) {
return null;
}
if (min !== undefined && num < min) {
return null;
}
if (max !== undefined && num > max) {
return null;
}
return num;
}
/**
* Sanitize email input.
* Validates email format.
*
* @param input - Raw email string
* @returns Sanitized email or null if invalid
*
* Validates: Requirements 10.5
*/
export function sanitizeEmail(input: string): string | null {
if (!input) return null;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(input)) {
return null;
}
return input.toLowerCase().trim();
}
/**
* Validate and sanitize bot configuration input.
* Comprehensive sanitization for bot creation/update.
*
* @param input - Raw configuration object
* @returns Sanitized configuration
*
* Validates: Requirements 10.5
*/
export interface BotConfigInput {
name?: string;
handle?: string;
bio?: string;
avatarUrl?: string;
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
}
export function sanitizeBotConfig(input: BotConfigInput): BotConfigInput {
const sanitized: BotConfigInput = {};
if (input.name) {
sanitized.name = sanitizeBotName(input.name);
}
if (input.handle) {
sanitized.handle = sanitizeBotHandle(input.handle);
}
if (input.bio) {
sanitized.bio = sanitizeHTML(input.bio);
}
if (input.avatarUrl) {
const url = sanitizeURL(input.avatarUrl);
if (url) {
sanitized.avatarUrl = url;
}
}
if (input.systemPrompt) {
sanitized.systemPrompt = input.systemPrompt.trim();
}
if (input.temperature !== undefined) {
const temp = sanitizeFloat(input.temperature, 0, 2);
if (temp !== null) {
sanitized.temperature = temp;
}
}
if (input.maxTokens !== undefined) {
const tokens = sanitizeInteger(input.maxTokens, 1, 100000);
if (tokens !== null) {
sanitized.maxTokens = tokens;
}
}
return sanitized;
}
File diff suppressed because it is too large Load Diff
+871
View File
@@ -0,0 +1,871 @@
/**
* Unit Tests for Scheduler Service
*
* Tests the scheduling functionality for bot posts including
* interval, time-of-day, and cron-like schedules.
*
* Requirements: 5.1, 5.2, 5.3, 5.4, 5.5
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
ScheduleConfig,
isValidTimeFormat,
isValidCronExpression,
validateIntervalMinutes,
validateTimes,
validateCronExpression,
validateScheduleConfig,
normalizeTime,
isValidTimezone,
parseTime,
parseCronExpression,
parseScheduleConfig,
serializeScheduleConfig,
isIntervalDue,
isTimesDue,
isCronDue,
isDue,
MIN_INTERVAL_MINUTES,
MAX_INTERVAL_MINUTES,
MAX_TIMES_PER_DAY,
} from './scheduler';
// ============================================
// TIME FORMAT VALIDATION
// ============================================
describe('isValidTimeFormat', () => {
it('should accept valid HH:MM format', () => {
expect(isValidTimeFormat('00:00')).toBe(true);
expect(isValidTimeFormat('12:30')).toBe(true);
expect(isValidTimeFormat('23:59')).toBe(true);
expect(isValidTimeFormat('09:05')).toBe(true);
});
it('should accept H:MM format (single digit hour)', () => {
expect(isValidTimeFormat('0:00')).toBe(true);
expect(isValidTimeFormat('9:30')).toBe(true);
});
it('should reject invalid time formats', () => {
expect(isValidTimeFormat('')).toBe(false);
expect(isValidTimeFormat('24:00')).toBe(false);
expect(isValidTimeFormat('12:60')).toBe(false);
expect(isValidTimeFormat('12')).toBe(false);
expect(isValidTimeFormat('12:30:00')).toBe(false);
expect(isValidTimeFormat('abc')).toBe(false);
expect(isValidTimeFormat('12:3')).toBe(false);
});
it('should reject null and undefined', () => {
expect(isValidTimeFormat(null as any)).toBe(false);
expect(isValidTimeFormat(undefined as any)).toBe(false);
});
});
// ============================================
// CRON EXPRESSION VALIDATION
// ============================================
describe('isValidCronExpression', () => {
it('should accept valid cron expressions', () => {
expect(isValidCronExpression('* * * * *')).toBe(true);
expect(isValidCronExpression('0 12 * * *')).toBe(true);
expect(isValidCronExpression('30 9 1 * 1')).toBe(true);
expect(isValidCronExpression('0 0 1 1 0')).toBe(true);
});
it('should reject invalid cron expressions', () => {
expect(isValidCronExpression('')).toBe(false);
expect(isValidCronExpression('* * *')).toBe(false);
expect(isValidCronExpression('60 * * * *')).toBe(false);
expect(isValidCronExpression('* 24 * * *')).toBe(false);
expect(isValidCronExpression('* * 32 * *')).toBe(false);
expect(isValidCronExpression('* * * 13 *')).toBe(false);
expect(isValidCronExpression('* * * * 7')).toBe(false);
});
it('should reject null and undefined', () => {
expect(isValidCronExpression(null as any)).toBe(false);
expect(isValidCronExpression(undefined as any)).toBe(false);
});
});
// ============================================
// INTERVAL VALIDATION
// ============================================
describe('validateIntervalMinutes', () => {
it('should accept valid intervals', () => {
expect(validateIntervalMinutes(5)).toEqual([]);
expect(validateIntervalMinutes(30)).toEqual([]);
expect(validateIntervalMinutes(60)).toEqual([]);
expect(validateIntervalMinutes(1440)).toEqual([]);
expect(validateIntervalMinutes(MAX_INTERVAL_MINUTES)).toEqual([]);
});
it('should reject intervals below minimum', () => {
const errors = validateIntervalMinutes(4);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('at least');
});
it('should reject intervals above maximum', () => {
const errors = validateIntervalMinutes(MAX_INTERVAL_MINUTES + 1);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('at most');
});
it('should reject non-integer values', () => {
const errors = validateIntervalMinutes(30.5);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('integer');
});
it('should reject non-number values', () => {
expect(validateIntervalMinutes('30').length).toBeGreaterThan(0);
expect(validateIntervalMinutes(null).length).toBeGreaterThan(0);
expect(validateIntervalMinutes(undefined).length).toBeGreaterThan(0);
});
});
// ============================================
// TIMES VALIDATION
// ============================================
describe('validateTimes', () => {
it('should accept valid times arrays', () => {
expect(validateTimes(['09:00'])).toEqual([]);
expect(validateTimes(['09:00', '12:00', '18:00'])).toEqual([]);
expect(validateTimes(['00:00', '23:59'])).toEqual([]);
});
it('should reject empty arrays', () => {
const errors = validateTimes([]);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('empty');
});
it('should reject arrays with too many times', () => {
const times = Array(MAX_TIMES_PER_DAY + 1).fill('12:00');
const errors = validateTimes(times);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('Maximum');
});
it('should reject invalid time formats', () => {
const errors = validateTimes(['25:00']);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('HH:MM');
});
it('should reject duplicate times', () => {
const errors = validateTimes(['09:00', '09:00']);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('Duplicate');
});
it('should reject non-array values', () => {
expect(validateTimes('09:00' as any).length).toBeGreaterThan(0);
expect(validateTimes(null).length).toBeGreaterThan(0);
expect(validateTimes(undefined).length).toBeGreaterThan(0);
});
});
// ============================================
// CRON EXPRESSION VALIDATION
// ============================================
describe('validateCronExpression', () => {
it('should accept valid cron expressions', () => {
expect(validateCronExpression('0 12 * * *')).toEqual([]);
expect(validateCronExpression('* * * * *')).toEqual([]);
});
it('should reject invalid cron expressions', () => {
const errors = validateCronExpression('invalid');
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain('Invalid cron');
});
it('should reject non-string values', () => {
expect(validateCronExpression(123 as any).length).toBeGreaterThan(0);
expect(validateCronExpression(null).length).toBeGreaterThan(0);
expect(validateCronExpression(undefined).length).toBeGreaterThan(0);
});
});
// ============================================
// SCHEDULE CONFIG VALIDATION
// ============================================
describe('validateScheduleConfig', () => {
describe('interval schedules', () => {
it('should accept valid interval config', () => {
const config: ScheduleConfig = {
type: 'interval',
intervalMinutes: 30,
};
const result = validateScheduleConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should reject interval config without intervalMinutes', () => {
const config = { type: 'interval' };
const result = validateScheduleConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
describe('times schedules', () => {
it('should accept valid times config', () => {
const config: ScheduleConfig = {
type: 'times',
times: ['09:00', '18:00'],
};
const result = validateScheduleConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should accept times config with timezone', () => {
const config: ScheduleConfig = {
type: 'times',
times: ['09:00'],
timezone: 'America/New_York',
};
const result = validateScheduleConfig(config);
expect(result.valid).toBe(true);
});
it('should reject times config without times array', () => {
const config = { type: 'times' };
const result = validateScheduleConfig(config);
expect(result.valid).toBe(false);
});
});
describe('cron schedules', () => {
it('should accept valid cron config', () => {
const config: ScheduleConfig = {
type: 'cron',
cronExpression: '0 12 * * *',
};
const result = validateScheduleConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should reject cron config without expression', () => {
const config = { type: 'cron' };
const result = validateScheduleConfig(config);
expect(result.valid).toBe(false);
});
});
describe('invalid configs', () => {
it('should reject invalid type', () => {
const config = { type: 'invalid' };
const result = validateScheduleConfig(config);
expect(result.valid).toBe(false);
expect(result.errors[0]).toContain('Invalid schedule type');
});
it('should reject missing type', () => {
const config = { intervalMinutes: 30 };
const result = validateScheduleConfig(config);
expect(result.valid).toBe(false);
});
it('should reject non-object values', () => {
expect(validateScheduleConfig(null).valid).toBe(false);
expect(validateScheduleConfig('string').valid).toBe(false);
expect(validateScheduleConfig(123).valid).toBe(false);
});
it('should reject invalid timezone', () => {
const config = {
type: 'times',
times: ['09:00'],
timezone: 'Invalid/Timezone',
};
const result = validateScheduleConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('timezone'))).toBe(true);
});
});
});
// ============================================
// HELPER FUNCTIONS
// ============================================
describe('normalizeTime', () => {
it('should normalize single digit hours', () => {
expect(normalizeTime('9:00')).toBe('09:00');
expect(normalizeTime('0:30')).toBe('00:30');
});
it('should keep double digit hours unchanged', () => {
expect(normalizeTime('12:00')).toBe('12:00');
expect(normalizeTime('23:59')).toBe('23:59');
});
it('should trim whitespace', () => {
expect(normalizeTime(' 09:00 ')).toBe('09:00');
});
});
describe('isValidTimezone', () => {
it('should accept valid timezones', () => {
expect(isValidTimezone('UTC')).toBe(true);
expect(isValidTimezone('America/New_York')).toBe(true);
expect(isValidTimezone('Europe/London')).toBe(true);
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
});
it('should reject invalid timezones', () => {
expect(isValidTimezone('Invalid/Timezone')).toBe(false);
expect(isValidTimezone('NotATimezone')).toBe(false);
});
});
describe('parseTime', () => {
it('should parse time strings correctly', () => {
expect(parseTime('09:30')).toEqual({ hours: 9, minutes: 30 });
expect(parseTime('23:59')).toEqual({ hours: 23, minutes: 59 });
expect(parseTime('0:00')).toEqual({ hours: 0, minutes: 0 });
});
});
describe('parseCronExpression', () => {
it('should parse cron expressions correctly', () => {
const result = parseCronExpression('30 9 1 6 5');
expect(result).toEqual({
minute: '30',
hour: '9',
dayOfMonth: '1',
month: '6',
dayOfWeek: '5',
});
});
it('should handle wildcards', () => {
const result = parseCronExpression('* * * * *');
expect(result).toEqual({
minute: '*',
hour: '*',
dayOfMonth: '*',
month: '*',
dayOfWeek: '*',
});
});
});
// ============================================
// SCHEDULE SERIALIZATION
// ============================================
describe('parseScheduleConfig', () => {
it('should parse valid JSON config', () => {
const json = JSON.stringify({
type: 'interval',
intervalMinutes: 30,
});
const result = parseScheduleConfig(json);
expect(result).toEqual({
type: 'interval',
intervalMinutes: 30,
});
});
it('should return null for invalid JSON', () => {
expect(parseScheduleConfig('invalid json')).toBeNull();
});
it('should return null for invalid config', () => {
const json = JSON.stringify({ type: 'invalid' });
expect(parseScheduleConfig(json)).toBeNull();
});
it('should return null for null input', () => {
expect(parseScheduleConfig(null)).toBeNull();
});
});
describe('serializeScheduleConfig', () => {
it('should serialize config to JSON', () => {
const config: ScheduleConfig = {
type: 'interval',
intervalMinutes: 30,
};
const json = serializeScheduleConfig(config);
expect(JSON.parse(json)).toEqual(config);
});
});
// ============================================
// IS DUE CHECKS
// ============================================
describe('isIntervalDue', () => {
it('should be due when never posted', () => {
const result = isIntervalDue(30, null);
expect(result.isDue).toBe(true);
expect(result.reason).toContain('No previous post');
});
it('should be due when interval has elapsed', () => {
const thirtyOneMinutesAgo = new Date(Date.now() - 31 * 60 * 1000);
const result = isIntervalDue(30, thirtyOneMinutesAgo);
expect(result.isDue).toBe(true);
expect(result.reason).toContain('Interval elapsed');
});
it('should not be due when interval has not elapsed', () => {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const result = isIntervalDue(30, tenMinutesAgo);
expect(result.isDue).toBe(false);
expect(result.nextDueAt).toBeDefined();
});
it('should calculate correct next due time', () => {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const result = isIntervalDue(30, tenMinutesAgo);
// Next due should be ~20 minutes from now
const expectedNextDue = new Date(tenMinutesAgo.getTime() + 30 * 60 * 1000);
expect(result.nextDueAt?.getTime()).toBeCloseTo(expectedNextDue.getTime(), -3);
});
});
describe('isTimesDue', () => {
it('should not be due when no times match current time', () => {
// Use a time that's definitely not now
const result = isTimesDue(['03:00'], 'UTC', null);
// This test is time-dependent, so we just check it returns a valid result
expect(typeof result.isDue).toBe('boolean');
});
it('should handle multiple times', () => {
const result = isTimesDue(['09:00', '12:00', '18:00'], 'UTC', null);
expect(typeof result.isDue).toBe('boolean');
expect(result.reason).toBeDefined();
});
});
describe('isCronDue', () => {
it('should handle wildcard cron expression', () => {
// Wildcard should always match
const result = isCronDue('* * * * *', 'UTC', null);
expect(result.isDue).toBe(true);
});
it('should not be due when cron does not match', () => {
// Use a specific time that's unlikely to match
const result = isCronDue('0 3 15 6 *', 'UTC', null);
// This is time-dependent
expect(typeof result.isDue).toBe('boolean');
});
it('should not be due if already posted this minute', () => {
const justNow = new Date(Date.now() - 30 * 1000); // 30 seconds ago
const result = isCronDue('* * * * *', 'UTC', justNow);
expect(result.isDue).toBe(false);
expect(result.reason).toContain('Already posted');
});
});
describe('isDue', () => {
it('should delegate to isIntervalDue for interval type', () => {
const config: ScheduleConfig = {
type: 'interval',
intervalMinutes: 30,
};
const result = isDue(config, null);
expect(result.isDue).toBe(true);
});
it('should delegate to isTimesDue for times type', () => {
const config: ScheduleConfig = {
type: 'times',
times: ['09:00', '18:00'],
};
const result = isDue(config, null);
expect(typeof result.isDue).toBe('boolean');
});
it('should delegate to isCronDue for cron type', () => {
const config: ScheduleConfig = {
type: 'cron',
cronExpression: '* * * * *',
};
const result = isDue(config, null);
expect(result.isDue).toBe(true);
});
it('should return not due for missing interval config', () => {
const config: ScheduleConfig = {
type: 'interval',
};
const result = isDue(config, null);
expect(result.isDue).toBe(false);
expect(result.reason).toContain('Missing interval');
});
it('should return not due for missing times config', () => {
const config: ScheduleConfig = {
type: 'times',
times: [],
};
const result = isDue(config, null);
expect(result.isDue).toBe(false);
expect(result.reason).toContain('Missing times');
});
it('should return not due for missing cron config', () => {
const config: ScheduleConfig = {
type: 'cron',
};
const result = isDue(config, null);
expect(result.isDue).toBe(false);
expect(result.reason).toContain('Missing cron');
});
it('should use provided timezone', () => {
const config: ScheduleConfig = {
type: 'times',
times: ['09:00'],
timezone: 'America/New_York',
};
const result = isDue(config, null);
expect(typeof result.isDue).toBe('boolean');
});
});
// ============================================
// DATABASE INTEGRATION TESTS (MOCKED)
// ============================================
// Mock the database module
vi.mock('@/db', () => {
return {
db: {
query: {
bots: {
findFirst: vi.fn(async () => null),
findMany: vi.fn(async () => []),
},
botContentSources: {
findMany: vi.fn(async () => []),
},
botContentItems: {
findFirst: vi.fn(async () => null),
},
},
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(async () => {}),
})),
})),
},
bots: { id: 'id', isActive: 'isActive', isSuspended: 'isSuspended', scheduleConfig: 'scheduleConfig' },
botContentSources: { id: 'id', botId: 'botId', isActive: 'isActive' },
botContentItems: { id: 'id', sourceId: 'sourceId', isProcessed: 'isProcessed' },
};
});
// Mock the rate limiter
vi.mock('./rateLimiter', () => ({
canPost: vi.fn(async () => ({ allowed: true })),
}));
import {
hasUnprocessedContent,
getNextUnprocessedContent,
processScheduledPosts,
getBotSchedule,
updateBotSchedule,
removeBotSchedule,
} from './scheduler';
describe('hasUnprocessedContent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when no sources exist', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([]);
const result = await hasUnprocessedContent('bot-1');
expect(result).toBe(false);
});
it('should return true when unprocessed content exists', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([
{ id: 'source-1' } as any,
]);
vi.mocked(db.query.botContentItems.findFirst).mockResolvedValue({
id: 'item-1',
} as any);
const result = await hasUnprocessedContent('bot-1');
expect(result).toBe(true);
});
it('should return false when all content is processed', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([
{ id: 'source-1' } as any,
]);
vi.mocked(db.query.botContentItems.findFirst).mockResolvedValue(undefined);
const result = await hasUnprocessedContent('bot-1');
expect(result).toBe(false);
});
});
describe('getNextUnprocessedContent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return null when no sources exist', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([]);
const result = await getNextUnprocessedContent('bot-1');
expect(result).toBeNull();
});
it('should return the oldest unprocessed item', async () => {
const { db } = await import('@/db');
const mockItem = {
id: 'item-1',
sourceId: 'source-1',
title: 'Test Title',
content: 'Test Content',
url: 'https://example.com',
publishedAt: new Date('2024-01-01'),
};
vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([
{ id: 'source-1' } as any,
]);
vi.mocked(db.query.botContentItems.findFirst).mockResolvedValue(mockItem as any);
const result = await getNextUnprocessedContent('bot-1');
expect(result).toEqual(mockItem);
});
});
describe('processScheduledPosts', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return empty result when no active bots', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.bots.findMany).mockResolvedValue([]);
const result = await processScheduledPosts();
expect(result.processed).toBe(0);
expect(result.skipped).toBe(0);
expect(result.errors).toEqual([]);
});
it('should skip bots without schedule config', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.bots.findMany).mockResolvedValue([
{
id: 'bot-1',
scheduleConfig: null,
lastPostAt: null,
} as any,
]);
const result = await processScheduledPosts();
expect(result.skipped).toBe(1);
expect(result.details[0].status).toBe('skipped_not_due');
});
it('should skip bots when schedule is not due', async () => {
const { db } = await import('@/db');
const recentPost = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes ago
vi.mocked(db.query.bots.findMany).mockResolvedValue([
{
id: 'bot-1',
scheduleConfig: JSON.stringify({
type: 'interval',
intervalMinutes: 30,
}),
lastPostAt: recentPost,
} as any,
]);
const result = await processScheduledPosts();
expect(result.skipped).toBe(1);
expect(result.details[0].status).toBe('skipped_not_due');
});
it('should skip bots when rate limited', async () => {
const { db } = await import('@/db');
const { canPost } = await import('./rateLimiter');
vi.mocked(db.query.bots.findMany).mockResolvedValue([
{
id: 'bot-1',
scheduleConfig: JSON.stringify({
type: 'interval',
intervalMinutes: 30,
}),
lastPostAt: null,
} as any,
]);
vi.mocked(canPost).mockResolvedValue({
allowed: false,
reason: 'Daily limit reached',
});
const result = await processScheduledPosts();
expect(result.skipped).toBe(1);
expect(result.details[0].status).toBe('skipped_rate_limit');
});
it('should skip bots when no content available', async () => {
const { db } = await import('@/db');
const { canPost } = await import('./rateLimiter');
vi.mocked(db.query.bots.findMany).mockResolvedValue([
{
id: 'bot-1',
scheduleConfig: JSON.stringify({
type: 'interval',
intervalMinutes: 30,
}),
lastPostAt: null,
} as any,
]);
vi.mocked(canPost).mockResolvedValue({ allowed: true });
vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([]);
const result = await processScheduledPosts();
expect(result.skipped).toBe(1);
expect(result.details[0].status).toBe('skipped_no_content');
});
it('should process bots with due schedule and available content', async () => {
const { db } = await import('@/db');
const { canPost } = await import('./rateLimiter');
vi.mocked(db.query.bots.findMany).mockResolvedValue([
{
id: 'bot-1',
scheduleConfig: JSON.stringify({
type: 'interval',
intervalMinutes: 30,
}),
lastPostAt: null,
} as any,
]);
vi.mocked(canPost).mockResolvedValue({ allowed: true });
vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([
{ id: 'source-1' } as any,
]);
vi.mocked(db.query.botContentItems.findFirst).mockResolvedValue({
id: 'item-1',
sourceId: 'source-1',
title: 'Test Title',
content: 'Test Content',
url: 'https://example.com',
publishedAt: new Date(),
} as any);
const result = await processScheduledPosts();
expect(result.processed).toBe(1);
expect(result.details[0].status).toBe('posted');
});
});
describe('getBotSchedule', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return null when bot not found', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.bots.findFirst).mockResolvedValue(undefined);
const result = await getBotSchedule('bot-1');
expect(result).toBeNull();
});
it('should return parsed schedule config', async () => {
const { db } = await import('@/db');
vi.mocked(db.query.bots.findFirst).mockResolvedValue({
scheduleConfig: JSON.stringify({
type: 'interval',
intervalMinutes: 30,
}),
} as any);
const result = await getBotSchedule('bot-1');
expect(result).toEqual({
type: 'interval',
intervalMinutes: 30,
});
});
});
describe('updateBotSchedule', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should update schedule config', async () => {
const { db } = await import('@/db');
const config: ScheduleConfig = {
type: 'interval',
intervalMinutes: 60,
};
await updateBotSchedule('bot-1', config);
expect(db.update).toHaveBeenCalled();
});
it('should throw error for invalid config', async () => {
const invalidConfig = {
type: 'invalid',
} as any;
await expect(updateBotSchedule('bot-1', invalidConfig)).rejects.toThrow('Invalid schedule configuration');
});
});
describe('removeBotSchedule', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should remove schedule config', async () => {
const { db } = await import('@/db');
await removeBotSchedule('bot-1');
expect(db.update).toHaveBeenCalled();
});
});
+944
View File
@@ -0,0 +1,944 @@
/**
* Scheduler Service
*
* Manages scheduled posting for bots. Supports interval-based, time-of-day,
* and cron-like schedules. Integrates with rate limiter and content sources.
*
* Requirements: 5.1, 5.2, 5.3, 5.4, 5.5
*/
import { db, bots, botContentSources, botContentItems } from '@/db';
import { eq, and } from 'drizzle-orm';
import { canPost } from './rateLimiter';
// ============================================
// TYPES
// ============================================
/**
* Schedule configuration for a bot.
*
* Validates: Requirements 5.1, 5.3
*/
export interface ScheduleConfig {
/** Type of schedule */
type: 'interval' | 'times' | 'cron';
/** Interval in minutes (for interval type) */
intervalMinutes?: number;
/** Times of day in HH:MM format (for times type) */
times?: string[];
/** Cron expression (for cron type) */
cronExpression?: string;
/** Timezone for time-based schedules (default: UTC) */
timezone?: string;
}
/**
* Result of schedule validation.
*/
export interface ScheduleValidationResult {
valid: boolean;
errors: string[];
}
/**
* Result of checking if a schedule is due.
*/
export interface IsDueResult {
isDue: boolean;
reason?: string;
nextDueAt?: Date;
}
/**
* Result of processing scheduled posts.
*/
export interface ProcessScheduledPostsResult {
processed: number;
skipped: number;
errors: string[];
details: {
botId: string;
status: 'posted' | 'skipped_no_content' | 'skipped_rate_limit' | 'skipped_not_due' | 'error';
message?: string;
}[];
}
// ============================================
// CONSTANTS
// ============================================
/** Minimum interval in minutes */
export const MIN_INTERVAL_MINUTES = 5;
/** Maximum interval in minutes (7 days) */
export const MAX_INTERVAL_MINUTES = 10080;
/** Maximum times per day */
export const MAX_TIMES_PER_DAY = 24;
/** Time format regex (HH:MM) */
const TIME_FORMAT_REGEX = /^([01]?[0-9]|2[0-3]):([0-5][0-9])$/;
/** Cron expression regex (simplified: minute hour day month weekday) */
const CRON_REGEX = /^(\*|[0-5]?[0-9])\s+(\*|[01]?[0-9]|2[0-3])\s+(\*|[1-9]|[12][0-9]|3[01])\s+(\*|[1-9]|1[0-2])\s+(\*|[0-6])$/;
// ============================================
// VALIDATION FUNCTIONS
// ============================================
/**
* Validate a time string in HH:MM format.
*
* @param time - The time string to validate
* @returns True if valid
*/
export function isValidTimeFormat(time: string): boolean {
if (!time || typeof time !== 'string') {
return false;
}
return TIME_FORMAT_REGEX.test(time.trim());
}
/**
* Validate a cron expression.
* Supports simplified cron format: minute hour day month weekday
*
* @param expression - The cron expression to validate
* @returns True if valid
*/
export function isValidCronExpression(expression: string): boolean {
if (!expression || typeof expression !== 'string') {
return false;
}
return CRON_REGEX.test(expression.trim());
}
/**
* Validate interval minutes.
*
* @param minutes - The interval in minutes
* @returns Validation errors (empty if valid)
*/
export function validateIntervalMinutes(minutes: unknown): string[] {
const errors: string[] = [];
if (minutes === undefined || minutes === null) {
errors.push('Interval minutes is required for interval schedules');
return errors;
}
if (typeof minutes !== 'number') {
errors.push('Interval minutes must be a number');
return errors;
}
if (!Number.isInteger(minutes)) {
errors.push('Interval minutes must be an integer');
return errors;
}
if (minutes < MIN_INTERVAL_MINUTES) {
errors.push(`Interval must be at least ${MIN_INTERVAL_MINUTES} minutes`);
}
if (minutes > MAX_INTERVAL_MINUTES) {
errors.push(`Interval must be at most ${MAX_INTERVAL_MINUTES} minutes (7 days)`);
}
return errors;
}
/**
* Validate times array.
*
* @param times - The times array
* @returns Validation errors (empty if valid)
*/
export function validateTimes(times: unknown): string[] {
const errors: string[] = [];
if (times === undefined || times === null) {
errors.push('Times array is required for times schedules');
return errors;
}
if (!Array.isArray(times)) {
errors.push('Times must be an array');
return errors;
}
if (times.length === 0) {
errors.push('Times array cannot be empty');
return errors;
}
if (times.length > MAX_TIMES_PER_DAY) {
errors.push(`Maximum ${MAX_TIMES_PER_DAY} times per day allowed`);
}
const seenTimes = new Set<string>();
for (let i = 0; i < times.length; i++) {
const time = times[i];
if (typeof time !== 'string') {
errors.push(`Time at index ${i} must be a string`);
continue;
}
const trimmed = time.trim();
if (!isValidTimeFormat(trimmed)) {
errors.push(`Time at index ${i} must be in HH:MM format (got: ${trimmed})`);
continue;
}
// Normalize to HH:MM format
const normalized = normalizeTime(trimmed);
if (seenTimes.has(normalized)) {
errors.push(`Duplicate time at index ${i}: ${normalized}`);
} else {
seenTimes.add(normalized);
}
}
return errors;
}
/**
* Validate cron expression.
*
* @param expression - The cron expression
* @returns Validation errors (empty if valid)
*/
export function validateCronExpression(expression: unknown): string[] {
const errors: string[] = [];
if (expression === undefined || expression === null) {
errors.push('Cron expression is required for cron schedules');
return errors;
}
if (typeof expression !== 'string') {
errors.push('Cron expression must be a string');
return errors;
}
const trimmed = expression.trim();
if (!isValidCronExpression(trimmed)) {
errors.push('Invalid cron expression format. Expected: minute hour day month weekday');
}
return errors;
}
/**
* Validate a complete schedule configuration.
*
* @param config - The schedule configuration to validate
* @returns Validation result with errors
*
* Validates: Requirements 5.1, 5.3
*/
export function validateScheduleConfig(config: unknown): ScheduleValidationResult {
const errors: string[] = [];
if (!config || typeof config !== 'object') {
return {
valid: false,
errors: ['Schedule configuration must be an object'],
};
}
const configObj = config as Record<string, unknown>;
// Validate type
if (!configObj.type || typeof configObj.type !== 'string') {
errors.push('Schedule type is required');
return { valid: false, errors };
}
const validTypes = ['interval', 'times', 'cron'];
if (!validTypes.includes(configObj.type)) {
errors.push(`Invalid schedule type: ${configObj.type}. Must be one of: ${validTypes.join(', ')}`);
return { valid: false, errors };
}
// Type-specific validation
switch (configObj.type) {
case 'interval':
errors.push(...validateIntervalMinutes(configObj.intervalMinutes));
break;
case 'times':
errors.push(...validateTimes(configObj.times));
break;
case 'cron':
errors.push(...validateCronExpression(configObj.cronExpression));
break;
}
// Validate timezone if provided
if (configObj.timezone !== undefined && configObj.timezone !== null) {
if (typeof configObj.timezone !== 'string') {
errors.push('Timezone must be a string');
} else if (!isValidTimezone(configObj.timezone)) {
errors.push(`Invalid timezone: ${configObj.timezone}`);
}
}
return {
valid: errors.length === 0,
errors,
};
}
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Normalize a time string to HH:MM format.
*
* @param time - The time string (H:MM or HH:MM)
* @returns Normalized time string
*/
export function normalizeTime(time: string): string {
const match = time.trim().match(TIME_FORMAT_REGEX);
if (!match) {
return time;
}
const hours = match[1].padStart(2, '0');
const minutes = match[2];
return `${hours}:${minutes}`;
}
/**
* Check if a timezone string is valid.
*
* @param timezone - The timezone string
* @returns True if valid
*/
export function isValidTimezone(timezone: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
return false;
}
}
/**
* Get the current time in a specific timezone.
*
* @param timezone - The timezone (default: UTC)
* @returns Object with hours and minutes
*/
export function getCurrentTimeInTimezone(timezone: string = 'UTC'): { hours: number; minutes: number } {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
minute: 'numeric',
hour12: false,
});
const parts = formatter.formatToParts(now);
const hours = parseInt(parts.find(p => p.type === 'hour')?.value || '0', 10);
const minutes = parseInt(parts.find(p => p.type === 'minute')?.value || '0', 10);
return { hours, minutes };
}
/**
* Parse a time string to hours and minutes.
*
* @param time - The time string in HH:MM format
* @returns Object with hours and minutes
*/
export function parseTime(time: string): { hours: number; minutes: number } {
const normalized = normalizeTime(time);
const [hours, minutes] = normalized.split(':').map(Number);
return { hours, minutes };
}
/**
* Parse a cron expression into its components.
*
* @param expression - The cron expression
* @returns Parsed cron components
*/
export function parseCronExpression(expression: string): {
minute: string;
hour: string;
dayOfMonth: string;
month: string;
dayOfWeek: string;
} {
const parts = expression.trim().split(/\s+/);
return {
minute: parts[0] || '*',
hour: parts[1] || '*',
dayOfMonth: parts[2] || '*',
month: parts[3] || '*',
dayOfWeek: parts[4] || '*',
};
}
/**
* Check if a cron field matches a value.
*
* @param field - The cron field (number or '*')
* @param value - The value to check
* @returns True if matches
*/
function cronFieldMatches(field: string, value: number): boolean {
if (field === '*') {
return true;
}
return parseInt(field, 10) === value;
}
// ============================================
// SCHEDULE STORAGE
// ============================================
/**
* Parse a schedule configuration from JSON string.
*
* @param json - The JSON string
* @returns Parsed schedule config or null
*/
export function parseScheduleConfig(json: string | null): ScheduleConfig | null {
if (!json) {
return null;
}
try {
const parsed = JSON.parse(json);
const validation = validateScheduleConfig(parsed);
if (!validation.valid) {
return null;
}
return parsed as ScheduleConfig;
} catch {
return null;
}
}
/**
* Serialize a schedule configuration to JSON string.
*
* @param config - The schedule configuration
* @returns JSON string
*/
export function serializeScheduleConfig(config: ScheduleConfig): string {
return JSON.stringify(config);
}
// ============================================
// IS DUE CHECK
// ============================================
/**
* Check if an interval schedule is due.
*
* @param intervalMinutes - The interval in minutes
* @param lastPostAt - The last post timestamp
* @returns IsDueResult
*/
export function isIntervalDue(
intervalMinutes: number,
lastPostAt: Date | null
): IsDueResult {
// If never posted, it's due
if (!lastPostAt) {
return { isDue: true, reason: 'No previous post' };
}
const intervalMs = intervalMinutes * 60 * 1000;
const nextDueAt = new Date(lastPostAt.getTime() + intervalMs);
const now = new Date();
if (now >= nextDueAt) {
return { isDue: true, reason: 'Interval elapsed', nextDueAt };
}
return {
isDue: false,
reason: `Next post due at ${nextDueAt.toISOString()}`,
nextDueAt,
};
}
/**
* Check if a time-of-day schedule is due.
*
* @param times - Array of times in HH:MM format
* @param timezone - The timezone (default: UTC)
* @param lastPostAt - The last post timestamp
* @returns IsDueResult
*/
export function isTimesDue(
times: string[],
timezone: string = 'UTC',
lastPostAt: Date | null
): IsDueResult {
const currentTime = getCurrentTimeInTimezone(timezone);
const currentMinutes = currentTime.hours * 60 + currentTime.minutes;
// Sort times and find the next due time
const sortedTimes = times
.map(t => parseTime(t))
.map(t => ({ ...t, totalMinutes: t.hours * 60 + t.minutes }))
.sort((a, b) => a.totalMinutes - b.totalMinutes);
// Check if we're within a 5-minute window of any scheduled time
for (const time of sortedTimes) {
const diff = currentMinutes - time.totalMinutes;
// Within 5 minutes after the scheduled time
if (diff >= 0 && diff < 5) {
// Check if we already posted for this time slot today
if (lastPostAt) {
const lastPostTime = getCurrentTimeInTimezone(timezone);
const lastPostDate = new Date(lastPostAt);
const now = new Date();
// If last post was today and within this time window, skip
if (
lastPostDate.toDateString() === now.toDateString() &&
Math.abs(lastPostDate.getTime() - now.getTime()) < 5 * 60 * 1000
) {
continue;
}
}
return {
isDue: true,
reason: `Scheduled time ${normalizeTime(`${time.hours}:${time.minutes}`)} reached`,
};
}
}
// Find next scheduled time
const nextTime = sortedTimes.find(t => t.totalMinutes > currentMinutes) || sortedTimes[0];
const nextDueAt = new Date();
nextDueAt.setUTCHours(nextTime.hours, nextTime.minutes, 0, 0);
if (nextTime.totalMinutes <= currentMinutes) {
// Next time is tomorrow
nextDueAt.setDate(nextDueAt.getDate() + 1);
}
return {
isDue: false,
reason: `Next scheduled time: ${normalizeTime(`${nextTime.hours}:${nextTime.minutes}`)}`,
nextDueAt,
};
}
/**
* Check if a cron schedule is due.
*
* @param cronExpression - The cron expression
* @param timezone - The timezone (default: UTC)
* @param lastPostAt - The last post timestamp
* @returns IsDueResult
*/
export function isCronDue(
cronExpression: string,
timezone: string = 'UTC',
lastPostAt: Date | null
): IsDueResult {
const cron = parseCronExpression(cronExpression);
const now = new Date();
// Get current time components in the specified timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
weekday: 'short',
hour12: false,
});
const parts = formatter.formatToParts(now);
const currentMinute = parseInt(parts.find(p => p.type === 'minute')?.value || '0', 10);
const currentHour = parseInt(parts.find(p => p.type === 'hour')?.value || '0', 10);
const currentDay = parseInt(parts.find(p => p.type === 'day')?.value || '1', 10);
const currentMonth = parseInt(parts.find(p => p.type === 'month')?.value || '1', 10);
// Map weekday name to number (0 = Sunday)
const weekdayMap: Record<string, number> = {
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6,
};
const weekdayName = parts.find(p => p.type === 'weekday')?.value || 'Sun';
const currentWeekday = weekdayMap[weekdayName] ?? 0;
// Check if all cron fields match
const matches =
cronFieldMatches(cron.minute, currentMinute) &&
cronFieldMatches(cron.hour, currentHour) &&
cronFieldMatches(cron.dayOfMonth, currentDay) &&
cronFieldMatches(cron.month, currentMonth) &&
cronFieldMatches(cron.dayOfWeek, currentWeekday);
if (matches) {
// Check if we already posted this minute
if (lastPostAt) {
const lastPostMinute = lastPostAt.getUTCMinutes();
const lastPostHour = lastPostAt.getUTCHours();
const timeDiff = now.getTime() - lastPostAt.getTime();
// If posted within the last minute, skip
if (timeDiff < 60 * 1000) {
return {
isDue: false,
reason: 'Already posted this minute',
};
}
}
return {
isDue: true,
reason: `Cron schedule matched: ${cronExpression}`,
};
}
return {
isDue: false,
reason: `Cron schedule not matched: ${cronExpression}`,
};
}
/**
* Check if a bot's schedule is due.
*
* @param config - The schedule configuration
* @param lastPostAt - The last post timestamp
* @returns IsDueResult
*
* Validates: Requirements 5.2
*/
export function isDue(
config: ScheduleConfig,
lastPostAt: Date | null
): IsDueResult {
const timezone = config.timezone || 'UTC';
switch (config.type) {
case 'interval':
if (!config.intervalMinutes) {
return { isDue: false, reason: 'Missing interval configuration' };
}
return isIntervalDue(config.intervalMinutes, lastPostAt);
case 'times':
if (!config.times || config.times.length === 0) {
return { isDue: false, reason: 'Missing times configuration' };
}
return isTimesDue(config.times, timezone, lastPostAt);
case 'cron':
if (!config.cronExpression) {
return { isDue: false, reason: 'Missing cron expression' };
}
return isCronDue(config.cronExpression, timezone, lastPostAt);
default:
return { isDue: false, reason: `Unknown schedule type: ${(config as any).type}` };
}
}
// ============================================
// CONTENT AVAILABILITY
// ============================================
/**
* Check if a bot has unprocessed content available.
*
* @param botId - The bot ID
* @returns True if unprocessed content exists
*
* Validates: Requirements 5.5
*/
export async function hasUnprocessedContent(botId: string): Promise<boolean> {
// Get all content sources for the bot
const sources = await db.query.botContentSources.findMany({
where: and(
eq(botContentSources.botId, botId),
eq(botContentSources.isActive, true)
),
columns: { id: true },
});
if (sources.length === 0) {
return false;
}
// Check if any source has unprocessed content
for (const source of sources) {
const unprocessedItem = await db.query.botContentItems.findFirst({
where: and(
eq(botContentItems.sourceId, source.id),
eq(botContentItems.isProcessed, false)
),
columns: { id: true },
});
if (unprocessedItem) {
return true;
}
}
return false;
}
/**
* Get the next unprocessed content item for a bot.
*
* @param botId - The bot ID
* @returns The next content item or null
*/
export async function getNextUnprocessedContent(botId: string): Promise<{
id: string;
sourceId: string;
title: string;
content: string | null;
url: string;
publishedAt: Date;
} | null> {
// Get all content sources for the bot
const sources = await db.query.botContentSources.findMany({
where: and(
eq(botContentSources.botId, botId),
eq(botContentSources.isActive, true)
),
columns: { id: true },
});
if (sources.length === 0) {
return null;
}
// Find the oldest unprocessed item across all sources
let oldestItem: typeof botContentItems.$inferSelect | null = null;
for (const source of sources) {
const item = await db.query.botContentItems.findFirst({
where: and(
eq(botContentItems.sourceId, source.id),
eq(botContentItems.isProcessed, false)
),
orderBy: (items, { asc }) => [asc(items.publishedAt)],
});
if (item && (!oldestItem || item.publishedAt < oldestItem.publishedAt)) {
oldestItem = item;
}
}
if (!oldestItem) {
return null;
}
return {
id: oldestItem.id,
sourceId: oldestItem.sourceId,
title: oldestItem.title,
content: oldestItem.content,
url: oldestItem.url,
publishedAt: oldestItem.publishedAt,
};
}
// ============================================
// PROCESS SCHEDULED POSTS
// ============================================
/**
* Process scheduled posts for all active bots.
* Checks each bot's schedule, rate limits, and content availability.
*
* @returns Processing result with statistics
*
* Validates: Requirements 5.2, 5.4, 5.5
*/
export async function processScheduledPosts(): Promise<ProcessScheduledPostsResult> {
const result: ProcessScheduledPostsResult = {
processed: 0,
skipped: 0,
errors: [],
details: [],
};
// Get all active bots with schedules
const activeBots = await db.query.bots.findMany({
where: and(
eq(bots.isActive, true),
eq(bots.isSuspended, false)
),
});
for (const bot of activeBots) {
try {
// Parse schedule config
const scheduleConfig = parseScheduleConfig(bot.scheduleConfig);
if (!scheduleConfig) {
result.details.push({
botId: bot.id,
status: 'skipped_not_due',
message: 'No valid schedule configuration',
});
result.skipped++;
continue;
}
// Check if schedule is due
const dueResult = isDue(scheduleConfig, bot.lastPostAt);
if (!dueResult.isDue) {
result.details.push({
botId: bot.id,
status: 'skipped_not_due',
message: dueResult.reason,
});
result.skipped++;
continue;
}
// Check rate limits
const rateLimitResult = await canPost(bot.id);
if (!rateLimitResult.allowed) {
result.details.push({
botId: bot.id,
status: 'skipped_rate_limit',
message: rateLimitResult.reason,
});
result.skipped++;
continue;
}
// Check for unprocessed content (Requirement 5.5)
const hasContent = await hasUnprocessedContent(bot.id);
if (!hasContent) {
result.details.push({
botId: bot.id,
status: 'skipped_no_content',
message: 'No unprocessed content available',
});
result.skipped++;
continue;
}
// Get the next content item
const contentItem = await getNextUnprocessedContent(bot.id);
if (!contentItem) {
result.details.push({
botId: bot.id,
status: 'skipped_no_content',
message: 'Failed to retrieve content item',
});
result.skipped++;
continue;
}
// At this point, we would trigger post generation
// This will be implemented in the posting module (Task 15)
// For now, we just mark it as ready to post
result.details.push({
botId: bot.id,
status: 'posted',
message: `Ready to post content: ${contentItem.title}`,
});
result.processed++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Bot ${bot.id}: ${errorMessage}`);
result.details.push({
botId: bot.id,
status: 'error',
message: errorMessage,
});
}
}
return result;
}
/**
* Get the schedule configuration for a bot.
*
* @param botId - The bot ID
* @returns The schedule configuration or null
*/
export async function getBotSchedule(botId: string): Promise<ScheduleConfig | null> {
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { scheduleConfig: true },
});
if (!bot) {
return null;
}
return parseScheduleConfig(bot.scheduleConfig);
}
/**
* Update the schedule configuration for a bot.
*
* @param botId - The bot ID
* @param config - The new schedule configuration
* @throws Error if configuration is invalid
*/
export async function updateBotSchedule(
botId: string,
config: ScheduleConfig
): Promise<void> {
const validation = validateScheduleConfig(config);
if (!validation.valid) {
throw new Error(`Invalid schedule configuration: ${validation.errors.join(', ')}`);
}
await db
.update(bots)
.set({
scheduleConfig: serializeScheduleConfig(config),
updatedAt: new Date(),
})
.where(eq(bots.id, botId));
}
/**
* Remove the schedule configuration for a bot.
*
* @param botId - The bot ID
*/
export async function removeBotSchedule(botId: string): Promise<void> {
await db
.update(bots)
.set({
scheduleConfig: null,
updatedAt: new Date(),
})
.where(eq(bots.id, botId));
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Bot Suspension Service
*
* Handles bot suspension and reinstatement.
* Suspended bots cannot perform any actions.
*
* Requirements: 10.6
*/
import { db, bots } from '@/db';
import { eq } from 'drizzle-orm';
// ============================================
// SUSPENSION FUNCTIONS
// ============================================
/**
* Suspend a bot.
*
* @param botId - The ID of the bot to suspend
* @param reason - Reason for suspension
* @returns Updated bot
*
* Validates: Requirements 10.6
*/
export async function suspendBot(botId: string, reason: string) {
const [bot] = await db.update(bots)
.set({
isSuspended: true,
suspensionReason: reason,
suspendedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(bots.id, botId))
.returning();
return bot;
}
/**
* Reinstate a suspended bot.
*
* @param botId - The ID of the bot to reinstate
* @returns Updated bot
*
* Validates: Requirements 10.6
*/
export async function reinstateBot(botId: string) {
const [bot] = await db.update(bots)
.set({
isSuspended: false,
suspensionReason: null,
suspendedAt: null,
updatedAt: new Date(),
})
.where(eq(bots.id, botId))
.returning();
return bot;
}
/**
* Check if a bot is suspended.
*
* @param botId - The ID of the bot
* @returns True if suspended
*
* Validates: Requirements 10.6
*/
export async function isBotSuspended(botId: string): Promise<boolean> {
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { isSuspended: true },
});
return bot?.isSuspended || false;
}
/**
* Throw error if bot is suspended.
*
* @param botId - The ID of the bot
* @throws Error if bot is suspended
*
* Validates: Requirements 10.6
*/
export async function ensureBotNotSuspended(botId: string): Promise<void> {
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
columns: { isSuspended: true, suspensionReason: true },
});
if (bot?.isSuspended) {
throw new Error(`Bot is suspended: ${bot.suspensionReason || 'No reason provided'}`);
}
}
+13
View File
@@ -13,6 +13,13 @@ export interface User {
movedTo?: string | null;
isRemote?: boolean;
profileUrl?: string | null;
isBot?: boolean;
botOwner?: {
id: string;
handle: string;
displayName?: string | null;
avatarUrl?: string | null;
} | null;
}
export interface MediaItem {
@@ -48,4 +55,10 @@ export interface Post {
} | null;
isLiked?: boolean;
isReposted?: boolean;
bot?: {
id: string;
name: string;
handle: string;
ownerId: string;
} | null;
}