This commit is contained in:
478
packages/agent/tests/cron-tools.test.ts
Normal file
478
packages/agent/tests/cron-tools.test.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createCronTools } from '../src/cron-tools.js';
|
||||
import type { ToolDefinition } from '../src/tools.js';
|
||||
|
||||
describe('Cron Tools', () => {
|
||||
let tools: ToolDefinition[];
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function getTool(name: string): ToolDefinition {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
if (!tool) throw new Error(`Tool "${name}" not found`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tools = createCronTools();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ── Tool registration ─────────────────────────────────────────────────
|
||||
|
||||
it('creates 4 cron tools', () => {
|
||||
expect(tools).toHaveLength(4);
|
||||
const names = tools.map(t => t.name);
|
||||
expect(names).toContain('create_schedule');
|
||||
expect(names).toContain('list_schedules');
|
||||
expect(names).toContain('delete_schedule');
|
||||
expect(names).toContain('trigger_schedule');
|
||||
});
|
||||
|
||||
// ── create_schedule ───────────────────────────────────────────────────
|
||||
|
||||
describe('create_schedule', () => {
|
||||
it('validates cron expression — rejects invalid', async () => {
|
||||
const tool = getTool('create_schedule');
|
||||
const result = await tool.execute({
|
||||
name: 'Test Schedule',
|
||||
cron_expression: 'not a cron',
|
||||
});
|
||||
|
||||
expect(result).toContain('Error');
|
||||
expect(result).toContain('Invalid cron expression');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates cron expression — accepts standard 5-field', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 1,
|
||||
name: 'Daily cleanup',
|
||||
cronExpr: '0 3 * * *',
|
||||
jobType: 'agent_task',
|
||||
enabled: true,
|
||||
nextRunAt: '2026-03-19T03:00:00.000Z',
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('create_schedule');
|
||||
const result = await tool.execute({
|
||||
name: 'Daily cleanup',
|
||||
cron_expression: '0 3 * * *',
|
||||
job_type: 'agent_task',
|
||||
workspace_id: 'ws-1',
|
||||
});
|
||||
|
||||
expect(result).toContain('Schedule created successfully');
|
||||
expect(result).toContain('Daily cleanup');
|
||||
expect(result).toContain('0 3 * * *');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/cron'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"cronExpr":"0 3 * * *"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('validates cron expression — accepts @daily shorthand', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 2,
|
||||
name: 'Daily task',
|
||||
cronExpr: '@daily',
|
||||
jobType: 'memory_consolidation',
|
||||
enabled: true,
|
||||
nextRunAt: '2026-03-19T00:00:00.000Z',
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('create_schedule');
|
||||
const result = await tool.execute({
|
||||
name: 'Daily task',
|
||||
cron_expression: '@daily',
|
||||
});
|
||||
|
||||
expect(result).toContain('Schedule created successfully');
|
||||
});
|
||||
|
||||
it('validates cron expression — rejects too few fields', async () => {
|
||||
const tool = getTool('create_schedule');
|
||||
const result = await tool.execute({
|
||||
name: 'Bad',
|
||||
cron_expression: '0 3 *',
|
||||
});
|
||||
|
||||
expect(result).toContain('Invalid cron expression');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates job_data JSON', async () => {
|
||||
const tool = getTool('create_schedule');
|
||||
const result = await tool.execute({
|
||||
name: 'Bad JSON',
|
||||
cron_expression: '0 3 * * *',
|
||||
job_data: '{not valid json}',
|
||||
});
|
||||
|
||||
expect(result).toContain('Invalid JSON');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles API error on create', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
error: 'agent_task jobs require a workspace ID',
|
||||
}), { status: 400 }));
|
||||
|
||||
const tool = getTool('create_schedule');
|
||||
const result = await tool.execute({
|
||||
name: 'Task without workspace',
|
||||
cron_expression: '0 3 * * *',
|
||||
job_type: 'agent_task',
|
||||
});
|
||||
|
||||
expect(result).toContain('Failed to create schedule');
|
||||
expect(result).toContain('workspace ID');
|
||||
});
|
||||
});
|
||||
|
||||
// ── create_schedule ai_task mode (#17) ────────────────────────────────
|
||||
|
||||
describe('create_schedule ai_task (#17)', () => {
|
||||
const createdResponse = (over?: Record<string, unknown>) => new Response(JSON.stringify({
|
||||
id: 7, name: 'Morning digest', cronExpr: '0 8 * * *', jobType: 'agent_task',
|
||||
enabled: true, nextRunAt: '2026-03-19T08:00:00.000Z', ...over,
|
||||
}), { status: 200 });
|
||||
|
||||
function toolsWithOrigin(origin: { session: string; workspace: string | null; channel?: { platform: string; chatId: string } } | null) {
|
||||
return createCronTools({ getTurnOrigin: () => origin });
|
||||
}
|
||||
|
||||
it('composes ai_task jobConfig with prompt + deliverTo from the origin snapshot', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(createdResponse());
|
||||
const tool = toolsWithOrigin({
|
||||
session: 'channel-telegram-42', workspace: 'ws-9',
|
||||
channel: { platform: 'telegram', chatId: '-10042' },
|
||||
}).find(t => t.name === 'create_schedule')!;
|
||||
|
||||
const result = await tool.execute({
|
||||
name: 'Morning digest', cron_expression: '0 8 * * *',
|
||||
prompt: 'Summarize yesterday', once: true,
|
||||
});
|
||||
|
||||
expect(result).toContain('Schedule created successfully');
|
||||
const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.jobConfig).toMatchObject({
|
||||
prompt: 'Summarize yesterday', mode: 'ai_task', once: true,
|
||||
deliverTo: { platform: 'telegram', chatId: '-10042' },
|
||||
});
|
||||
// workspace defaults from the origin when not given explicitly
|
||||
expect(body.workspaceId).toBe('ws-9');
|
||||
});
|
||||
|
||||
it("deliver:'notification' omits deliverTo even with a channel origin", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(createdResponse());
|
||||
const tool = toolsWithOrigin({
|
||||
session: 'channel-slack-C1', workspace: 'ws-1',
|
||||
channel: { platform: 'slack', chatId: 'C1' },
|
||||
}).find(t => t.name === 'create_schedule')!;
|
||||
|
||||
await tool.execute({
|
||||
name: 'Digest', cron_expression: '0 8 * * *',
|
||||
prompt: 'Summarize', deliver: 'notification',
|
||||
});
|
||||
|
||||
const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.jobConfig.deliverTo).toBeUndefined();
|
||||
expect(body.jobConfig.mode).toBe('ai_task');
|
||||
});
|
||||
|
||||
it('no origin → no deliverTo, workspace stays undefined', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(createdResponse());
|
||||
const tool = toolsWithOrigin(null).find(t => t.name === 'create_schedule')!;
|
||||
|
||||
await tool.execute({ name: 'Digest', cron_expression: '0 8 * * *', prompt: 'Summarize' });
|
||||
|
||||
const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.jobConfig.deliverTo).toBeUndefined();
|
||||
expect(body.workspaceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects prompt on non-agent_task schedules', async () => {
|
||||
const tool = toolsWithOrigin(null).find(t => t.name === 'create_schedule')!;
|
||||
const result = await tool.execute({
|
||||
name: 'Bad', cron_expression: '0 8 * * *',
|
||||
job_type: 'memory_consolidation', prompt: 'Summarize',
|
||||
});
|
||||
expect(result).toContain('only valid for agent_task');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('min-interval guard: rejects every-minute, */4, range, and step-on-range exprs; accepts */5, lists, @hourly', async () => {
|
||||
const tool = toolsWithOrigin(null).find(t => t.name === 'create_schedule')!;
|
||||
|
||||
for (const expr of [
|
||||
'* * * * *', '*/4 * * * *', '* * * * * *',
|
||||
'1,2,3,4,5,6,7,8,9,10,11,12,13 * * * *',
|
||||
'1-59 * * * *', // range bypass (verifier class)
|
||||
'0-59/2 * * * *', // step-on-range bypass
|
||||
]) {
|
||||
const result = await tool.execute({ name: 'Fast', cron_expression: expr, prompt: 'x' });
|
||||
expect(result, expr).toContain('may not fire more often than every 5 minutes');
|
||||
}
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
for (const expr of ['*/5 * * * *', '@hourly', '0 8 * * *', '0,30 * * * *']) {
|
||||
fetchSpy.mockResolvedValueOnce(createdResponse({ cronExpr: expr }));
|
||||
const result = await tool.execute({ name: 'OK', cron_expression: expr, prompt: 'x' });
|
||||
expect(result, expr).toContain('Schedule created successfully');
|
||||
}
|
||||
});
|
||||
|
||||
it('SEC: job_data cannot smuggle mode/deliverTo/once past the trusted param path', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(createdResponse());
|
||||
const tool = toolsWithOrigin(null).find(t => t.name === 'create_schedule')!;
|
||||
|
||||
// No `prompt` param — a raw job_data trying to fabricate an ai_task
|
||||
// with an attacker-controlled delivery target must be stripped.
|
||||
await tool.execute({
|
||||
name: 'Sneaky', cron_expression: '0 8 * * *', workspace_id: 'ws-1',
|
||||
job_data: '{"prompt":"x","mode":"ai_task","once":true,"deliverTo":{"platform":"telegram","chatId":"attacker"}}',
|
||||
});
|
||||
|
||||
const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.jobConfig.mode).toBeUndefined();
|
||||
expect(body.jobConfig.deliverTo).toBeUndefined();
|
||||
expect(body.jobConfig.once).toBeUndefined();
|
||||
expect(body.jobConfig.prompt).toBe('x'); // legacy prompt field untouched
|
||||
});
|
||||
|
||||
it('legacy create without prompt is byte-identical (no mode injected)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(createdResponse());
|
||||
const tool = toolsWithOrigin({
|
||||
session: 's', workspace: 'ws-1',
|
||||
channel: { platform: 'telegram', chatId: '1' },
|
||||
}).find(t => t.name === 'create_schedule')!;
|
||||
|
||||
await tool.execute({
|
||||
name: 'Legacy', cron_expression: '0 3 * * *',
|
||||
job_type: 'agent_task', workspace_id: 'ws-2',
|
||||
job_data: '{"prompt":"old style"}',
|
||||
});
|
||||
|
||||
const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.jobConfig).toEqual({ prompt: 'old style' });
|
||||
expect(body.workspaceId).toBe('ws-2');
|
||||
});
|
||||
});
|
||||
|
||||
// ── list_schedules ────────────────────────────────────────────────────
|
||||
|
||||
describe('list_schedules', () => {
|
||||
it('formats response correctly', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [
|
||||
{ id: 1, name: 'Memory consolidation', cronExpr: '0 3 * * *', jobType: 'memory_consolidation', enabled: true, lastRunAt: null, nextRunAt: '2026-03-19T03:00:00.000Z' },
|
||||
{ id: 2, name: 'Health check', cronExpr: '0 8 * * 1', jobType: 'workspace_health', enabled: true, lastRunAt: null, nextRunAt: '2026-03-24T08:00:00.000Z' },
|
||||
],
|
||||
count: 2,
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('list_schedules');
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result).toContain('Cron Schedules (2)');
|
||||
expect(result).toContain('Memory consolidation');
|
||||
expect(result).toContain('Health check');
|
||||
expect(result).toContain('0 3 * * *');
|
||||
expect(result).toContain('0 8 * * 1');
|
||||
expect(result).toContain('memory_consolidation');
|
||||
expect(result).toContain('workspace_health');
|
||||
expect(result).toContain('yes');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/cron'),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty list', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [],
|
||||
count: 0,
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('list_schedules');
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result).toContain('No cron schedules configured');
|
||||
});
|
||||
|
||||
it('handles disabled schedules', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [
|
||||
{ id: 1, name: 'Disabled task', cronExpr: '0 0 * * *', jobType: 'agent_task', enabled: false, lastRunAt: null, nextRunAt: null },
|
||||
],
|
||||
count: 1,
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('list_schedules');
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result).toContain('Disabled task');
|
||||
expect(result).toContain('no');
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete_schedule ───────────────────────────────────────────────────
|
||||
|
||||
describe('delete_schedule', () => {
|
||||
it('calls correct endpoint after finding by name', async () => {
|
||||
// Mock list response
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [
|
||||
{ id: 5, name: 'Memory consolidation' },
|
||||
{ id: 6, name: 'Health check' },
|
||||
],
|
||||
count: 2,
|
||||
}), { status: 200 }));
|
||||
|
||||
// Mock delete response
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
ok: true, id: 5,
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('delete_schedule');
|
||||
const result = await tool.execute({ name: 'Memory consolidation' });
|
||||
|
||||
expect(result).toContain('deleted successfully');
|
||||
expect(result).toContain('Memory consolidation');
|
||||
expect(result).toContain('ID: 5');
|
||||
|
||||
// Verify the DELETE was to /api/cron/5
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
const deleteCall = fetchSpy.mock.calls[1];
|
||||
expect(deleteCall[0]).toContain('/api/cron/5');
|
||||
expect(deleteCall[1]).toEqual(expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('handles not-found schedule', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [{ id: 1, name: 'Other schedule' }],
|
||||
count: 1,
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('delete_schedule');
|
||||
const result = await tool.execute({ name: 'Nonexistent schedule' });
|
||||
|
||||
expect(result).toContain('not found');
|
||||
expect(result).toContain('Nonexistent schedule');
|
||||
// Should only call list, not delete
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('case-insensitive name matching', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [{ id: 3, name: 'Memory Consolidation' }],
|
||||
count: 1,
|
||||
}), { status: 200 }));
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
ok: true, id: 3,
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('delete_schedule');
|
||||
const result = await tool.execute({ name: 'memory consolidation' });
|
||||
|
||||
expect(result).toContain('deleted successfully');
|
||||
});
|
||||
});
|
||||
|
||||
// ── trigger_schedule ──────────────────────────────────────────────────
|
||||
|
||||
describe('trigger_schedule', () => {
|
||||
it('calls correct endpoint after finding by name', async () => {
|
||||
// Mock list response
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [
|
||||
{ id: 7, name: 'Daily cleanup' },
|
||||
],
|
||||
count: 1,
|
||||
}), { status: 200 }));
|
||||
|
||||
// Mock trigger response
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
triggered: true,
|
||||
id: 7,
|
||||
nextRunAt: '2026-03-19T03:00:00.000Z',
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('trigger_schedule');
|
||||
const result = await tool.execute({ name: 'Daily cleanup' });
|
||||
|
||||
expect(result).toContain('triggered successfully');
|
||||
expect(result).toContain('Daily cleanup');
|
||||
expect(result).toContain('Next run');
|
||||
|
||||
// Verify the POST was to /api/cron/7/trigger
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
const triggerCall = fetchSpy.mock.calls[1];
|
||||
expect(triggerCall[0]).toContain('/api/cron/7/trigger');
|
||||
expect(triggerCall[1]).toEqual(expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('handles not-found schedule', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [],
|
||||
count: 0,
|
||||
}), { status: 200 }));
|
||||
|
||||
const tool = getTool('trigger_schedule');
|
||||
const result = await tool.execute({ name: 'ghost-schedule' });
|
||||
|
||||
expect(result).toContain('not found');
|
||||
expect(result).toContain('ghost-schedule');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles trigger API failure', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
schedules: [{ id: 8, name: 'Failing task' }],
|
||||
count: 1,
|
||||
}), { status: 200 }));
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
error: 'Trigger failed',
|
||||
}), { status: 500 }));
|
||||
|
||||
const tool = getTool('trigger_schedule');
|
||||
const result = await tool.execute({ name: 'Failing task' });
|
||||
|
||||
expect(result).toContain('Failed to trigger');
|
||||
expect(result).toContain('Trigger failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Network errors ────────────────────────────────────────────────────
|
||||
|
||||
it('handles network errors gracefully in list_schedules', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
|
||||
const tool = getTool('list_schedules');
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result).toContain('Error listing schedules');
|
||||
expect(result).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('handles network errors gracefully in create_schedule', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
|
||||
const tool = getTool('create_schedule');
|
||||
const result = await tool.execute({
|
||||
name: 'Test',
|
||||
cron_expression: '0 0 * * *',
|
||||
});
|
||||
|
||||
expect(result).toContain('Error creating schedule');
|
||||
expect(result).toContain('ECONNREFUSED');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user