import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import type { Job } from 'bullmq'; import { createWorker } from '../src/index.js'; import { JobService } from '../../server/src/services/job-service.js'; import { createDb } from '../../server/src/db/connection.js'; import { users, teams, teamMembers, agentJobs } from '../../server/src/db/schema.js'; import { eq, sql } from 'drizzle-orm'; import type { JobData } from '../src/job-processor.js'; const REDIS_URL = 'redis://localhost:6381'; const DATABASE_URL = 'postgres://waggle:waggle_dev@localhost:5434/waggle'; const QUEUE_NAME = `waggle-jobs-test-${Date.now()}`; /** Poll for job status until it reaches target or timeout. */ async function waitForJobStatus( jobService: JobService, jobId: string, target: string, timeoutMs = 10_000, intervalMs = 200, ): Promise>> { const start = Date.now(); while (Date.now() - start < timeoutMs) { const job = await jobService.getJob(jobId); if (job?.status === target || job?.status === 'failed') return job; await new Promise(r => setTimeout(r, intervalMs)); } // Return last state even on timeout return jobService.getJob(jobId); } describe('BullMQ Worker', () => { let db: ReturnType; let jobService: JobService; let workerInstance: ReturnType; let testUserId: string; let testTeamId: string; beforeAll(async () => { db = createDb(DATABASE_URL); jobService = new JobService(db, REDIS_URL, QUEUE_NAME); workerInstance = createWorker(REDIS_URL, DATABASE_URL, QUEUE_NAME); // Override handlers with fast mocks (real handlers call LiteLLM which isn't running in tests) workerInstance.processor.register('chat', async (job) => ({ response: `Mock response for: ${String(job.data.input.message ?? '')}`, model: 'mock', })); workerInstance.processor.register('task', async (job) => ({ result: `Mock task result`, input: job.data.input, })); // Create test data const [user] = await db.insert(users).values({ clerkId: 'worker_test_user_' + Date.now(), displayName: 'Worker Test', email: `worker_${Date.now()}@test.com`, }).returning(); testUserId = user.id; const [team] = await db.insert(teams).values({ name: 'Worker Team', slug: 'worker-test-' + Date.now(), ownerId: testUserId, }).returning(); testTeamId = team.id; await db.insert(teamMembers).values({ teamId: testTeamId, userId: testUserId, role: 'owner', }); }, 15_000); afterAll(async () => { try { await workerInstance.worker.close(); } catch { /* ignore */ } try { await jobService.close(); } catch { /* ignore */ } await db.execute(sql`DELETE FROM agent_jobs WHERE team_id = ${testTeamId}`); await db.execute(sql`DELETE FROM team_members WHERE user_id = ${testUserId}`); await db.execute(sql`DELETE FROM teams WHERE id = ${testTeamId}`); await db.execute(sql`DELETE FROM users WHERE id = ${testUserId}`); }, 15_000); it('queues job and worker picks it up', async () => { const job = await jobService.createJob(testTeamId, testUserId, 'chat', { message: 'hello' }); expect(job.status).toBe('queued'); const updated = await waitForJobStatus(jobService, job.id, 'completed'); expect(updated?.status).toBe('completed'); expect(updated?.output).toBeDefined(); }, 15_000); it('tracks job status transitions', async () => { const job = await jobService.createJob(testTeamId, testUserId, 'chat', { message: 'status test' }); const completed = await waitForJobStatus(jobService, job.id, 'completed'); expect(completed?.startedAt).toBeDefined(); expect(completed?.completedAt).toBeDefined(); expect(completed?.status).toBe('completed'); }, 15_000); it('marks failed jobs', async () => { // Register a failing handler workerInstance.processor.register('fail_test', async () => { throw new Error('Intentional failure'); }); const job = await jobService.createJob(testTeamId, testUserId, 'fail_test', {}); const failed = await waitForJobStatus(jobService, job.id, 'failed'); expect(failed?.status).toBe('failed'); expect((failed?.output as Record)?.error).toContain('Intentional failure'); }, 15_000); it('executes cron wrapper jobs through the registered target handler', async () => { workerInstance.processor.register('chat', async (job) => ({ response: String(job.data.input.message), })); const result = await workerInstance.processor.process({ data: { jobId: 'cron-wrapper-1', teamId: testTeamId, userId: testUserId, jobType: 'cron', input: { jobType: 'chat', jobConfig: { message: 'scheduled brief' }, }, }, } as unknown as Job, db); expect(result).toEqual({ response: 'scheduled brief', cron: { delegatedJobType: 'chat' }, }); }, 15_000); });