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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user