This commit is contained in:
340
packages/worker/tests/handlers/handlers.test.ts
Normal file
340
packages/worker/tests/handlers/handlers.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { Db } from '../../../server/src/db/connection.js';
|
||||
import type { JobData } from '../../src/job-processor.js';
|
||||
|
||||
/** Build a minimal Job<JobData> mock — only `.data` is exercised by handlers. */
|
||||
function makeJob(data: JobData): Job<JobData> {
|
||||
return { data } as unknown as Job<JobData>;
|
||||
}
|
||||
|
||||
// ── Mock @waggle/agent before importing handlers ────────────────────────
|
||||
vi.mock('@waggle/agent', () => ({
|
||||
runAgentLoop: vi.fn(async () => ({
|
||||
content: 'Task completed successfully',
|
||||
toolsUsed: ['bash', 'read_file'],
|
||||
usage: { inputTokens: 200, outputTokens: 100 },
|
||||
})),
|
||||
createSystemTools: vi.fn(() => [
|
||||
{ name: 'bash', description: 'Run shell commands', parameters: { type: 'object', properties: {} }, execute: async () => 'ok' },
|
||||
{ name: 'read_file', description: 'Read a file', parameters: { type: 'object', properties: {} }, execute: async () => 'ok' },
|
||||
]),
|
||||
}));
|
||||
|
||||
// ── Mock drizzle-orm ────────────────────────────────────────────────────
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((col: unknown, val: unknown) => ({ column: col, value: val })),
|
||||
}));
|
||||
|
||||
// ── Mock server DB schema ───────────────────────────────────────────────
|
||||
vi.mock('../../../server/src/db/schema.js', () => ({
|
||||
tasks: {
|
||||
id: 'tasks.id',
|
||||
teamId: 'tasks.teamId',
|
||||
status: 'tasks.status',
|
||||
assignedTo: 'tasks.assignedTo',
|
||||
updatedAt: 'tasks.updatedAt',
|
||||
title: 'tasks.title',
|
||||
description: 'tasks.description',
|
||||
priority: 'tasks.priority',
|
||||
},
|
||||
agentGroups: { id: 'agent_groups.id' },
|
||||
agentGroupMembers: {
|
||||
groupId: 'agent_group_members.groupId',
|
||||
agentId: 'agent_group_members.agentId',
|
||||
executionOrder: 'agent_group_members.executionOrder',
|
||||
},
|
||||
agents: { id: 'agents.id' },
|
||||
}));
|
||||
|
||||
import { taskHandler } from '../../src/handlers/task-handler.js';
|
||||
import { groupHandler } from '../../src/handlers/group-handler.js';
|
||||
import { runAgentLoop } from '@waggle/agent';
|
||||
|
||||
// ── Helper: mock DB ─────────────────────────────────────────────────────
|
||||
|
||||
function createMockDb(overrides?: {
|
||||
selectResult?: unknown[];
|
||||
selectResultSecond?: unknown[];
|
||||
}): Db {
|
||||
const selectResult = overrides?.selectResult ?? [];
|
||||
const hasSecondResult = overrides?.selectResultSecond !== undefined;
|
||||
const selectResultSecond = overrides?.selectResultSecond ?? [];
|
||||
let selectCallCount = 0;
|
||||
|
||||
const mockChain = (results: unknown[]) => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(results),
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(results),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
select: vi.fn(() => {
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return mockChain(selectResult);
|
||||
return mockChain(hasSecondResult ? selectResultSecond : selectResult);
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Task Handler Tests (11F-7)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Task Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('executes a task job and returns results', async () => {
|
||||
const mockTask = {
|
||||
id: 'task-1',
|
||||
title: 'Fix login bug',
|
||||
description: 'Users cannot log in with SSO',
|
||||
teamId: 'team-1',
|
||||
priority: 'high',
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockTask] });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j1',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: { taskId: 'task-1' },
|
||||
});
|
||||
|
||||
const result = await taskHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.taskId).toBe('task-1');
|
||||
expect(result.taskTitle).toBe('Fix login bug');
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.result).toBe('Task completed successfully');
|
||||
expect(result.toolsUsed).toEqual(['bash', 'read_file']);
|
||||
expect(result.tokensUsed).toBe(300); // 200 + 100
|
||||
expect(result.userId).toBe('user-1');
|
||||
|
||||
// Verify runAgentLoop was called
|
||||
expect(runAgentLoop).toHaveBeenCalledOnce();
|
||||
|
||||
// Verify DB was updated (in-progress then completed)
|
||||
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws error and resets task to open on agent failure', async () => {
|
||||
const mockTask = {
|
||||
id: 'task-2',
|
||||
title: 'Deploy service',
|
||||
description: null,
|
||||
teamId: 'team-1',
|
||||
priority: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockTask] });
|
||||
vi.mocked(runAgentLoop).mockRejectedValueOnce(new Error('LLM API timeout'));
|
||||
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j2',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: { taskId: 'task-2' },
|
||||
});
|
||||
|
||||
await expect(taskHandler(mockJob, mockDb)).rejects.toThrow('LLM API timeout');
|
||||
|
||||
// Verify DB was updated: first to in_progress, then back to open on error
|
||||
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws if taskId is missing from input', async () => {
|
||||
const mockDb = createMockDb();
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j3',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: {}, // no taskId
|
||||
});
|
||||
|
||||
await expect(taskHandler(mockJob, mockDb)).rejects.toThrow('taskHandler requires input.taskId');
|
||||
});
|
||||
|
||||
it('throws if task is not found in DB', async () => {
|
||||
const mockDb = createMockDb({ selectResult: [] }); // no task found
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j4',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: { taskId: 'nonexistent-task' },
|
||||
});
|
||||
|
||||
await expect(taskHandler(mockJob, mockDb)).rejects.toThrow('Task not found: nonexistent-task');
|
||||
});
|
||||
|
||||
it('throws if task belongs to a different team', async () => {
|
||||
const mockTask = {
|
||||
id: 'task-5',
|
||||
title: 'Other team task',
|
||||
description: null,
|
||||
teamId: 'team-other', // different team
|
||||
priority: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockTask] });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j5',
|
||||
teamId: 'team-1', // requesting team doesn't match
|
||||
userId: 'user-1',
|
||||
jobType: 'task',
|
||||
input: { taskId: 'task-5' },
|
||||
});
|
||||
|
||||
await expect(taskHandler(mockJob, mockDb)).rejects.toThrow(
|
||||
'Task task-5 does not belong to team team-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Group Handler Tests (11F-7)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Group Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws if groupId is missing from input', async () => {
|
||||
const mockDb = createMockDb();
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j1',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: {}, // no groupId
|
||||
});
|
||||
|
||||
await expect(groupHandler(mockJob, mockDb)).rejects.toThrow('groupHandler requires input.groupId');
|
||||
});
|
||||
|
||||
it('throws if group is not found in DB', async () => {
|
||||
const mockDb = createMockDb({ selectResult: [] });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j2',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'nonexistent-group' },
|
||||
});
|
||||
|
||||
await expect(groupHandler(mockJob, mockDb)).rejects.toThrow('Agent group not found: nonexistent-group');
|
||||
});
|
||||
|
||||
it('throws if group has no members', async () => {
|
||||
const mockGroup = { id: 'group-1', strategy: 'parallel', name: 'Test Group' };
|
||||
// First select returns the group, second returns empty members
|
||||
const mockDb = createMockDb({ selectResult: [mockGroup], selectResultSecond: [] });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j3',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'group-1' },
|
||||
});
|
||||
|
||||
await expect(groupHandler(mockJob, mockDb)).rejects.toThrow('Agent group group-1 has no members');
|
||||
});
|
||||
|
||||
it('dispatches parallel strategy correctly', async () => {
|
||||
const mockGroup = { id: 'group-1', strategy: 'parallel', name: 'Parallel Group' };
|
||||
const mockMembers = [
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 0, groupId: 'group-1', agentId: 'a1' },
|
||||
agent: { id: 'a1', name: 'Agent Alpha', model: 'claude-sonnet', systemPrompt: 'You are Alpha', tools: [] },
|
||||
},
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 1, groupId: 'group-1', agentId: 'a2' },
|
||||
agent: { id: 'a2', name: 'Agent Beta', model: 'claude-haiku', systemPrompt: 'You are Beta', tools: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockGroup], selectResultSecond: mockMembers });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j4',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'group-1', taskInput: { task: 'analyze data' } },
|
||||
});
|
||||
|
||||
const result = await groupHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.strategy).toBe('parallel');
|
||||
expect(result.agentCount).toBe(2);
|
||||
// runAgentLoop called once per member
|
||||
expect(runAgentLoop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('dispatches sequential strategy correctly', async () => {
|
||||
const mockGroup = { id: 'group-2', strategy: 'sequential', name: 'Sequential Group' };
|
||||
const mockMembers = [
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 0, groupId: 'group-2', agentId: 'a1' },
|
||||
agent: { id: 'a1', name: 'Researcher', model: 'claude-sonnet', systemPrompt: 'Research first', tools: [] },
|
||||
},
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 1, groupId: 'group-2', agentId: 'a2' },
|
||||
agent: { id: 'a2', name: 'Writer', model: 'claude-haiku', systemPrompt: 'Write based on research', tools: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockGroup], selectResultSecond: mockMembers });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j5',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'group-2', taskInput: { task: 'research and draft' } },
|
||||
});
|
||||
|
||||
const result = await groupHandler(mockJob, mockDb);
|
||||
|
||||
expect(result.strategy).toBe('sequential');
|
||||
expect(result.agentCount).toBe(2);
|
||||
expect(runAgentLoop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws for unknown execution strategy', async () => {
|
||||
const mockGroup = { id: 'group-3', strategy: 'unknown_strategy', name: 'Bad Group' };
|
||||
const mockMembers = [
|
||||
{
|
||||
member: { roleInGroup: 'worker', executionOrder: 0, groupId: 'group-3', agentId: 'a1' },
|
||||
agent: { id: 'a1', name: 'Agent', model: 'claude-sonnet', systemPrompt: null, tools: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createMockDb({ selectResult: [mockGroup], selectResultSecond: mockMembers });
|
||||
const mockJob = makeJob({
|
||||
jobId: 'j6',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
jobType: 'group',
|
||||
input: { groupId: 'group-3', taskInput: {} },
|
||||
});
|
||||
|
||||
await expect(groupHandler(mockJob, mockDb)).rejects.toThrow('Unknown execution strategy: unknown_strategy');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user