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
+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();
});
});