215 lines
7.6 KiB
TypeScript
215 lines
7.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { CronScheduler } from './cron.js';
|
|
import type { CronJobConfig } from '../config/schema.js';
|
|
import type { InboundMessage } from '../channels/types.js';
|
|
|
|
function asCronChannelRegistry(value: unknown): ConstructorParameters<typeof CronScheduler>[1] {
|
|
return value as ConstructorParameters<typeof CronScheduler>[1];
|
|
}
|
|
|
|
function makeCronJob(overrides?: Partial<CronJobConfig>): CronJobConfig {
|
|
return {
|
|
name: 'test-job',
|
|
schedule: '0 9 * * *',
|
|
message: 'Hello from cron',
|
|
output: { channel: 'telegram', peer: '123' },
|
|
enabled: true,
|
|
once_per_local_day: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('CronScheduler', () => {
|
|
let scheduler: CronScheduler;
|
|
let mockChannelRegistry: { get: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(() => {
|
|
mockChannelRegistry = {
|
|
get: vi.fn(),
|
|
};
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (scheduler) {
|
|
await scheduler.disconnect();
|
|
}
|
|
});
|
|
|
|
it('implements ChannelAdapter interface', () => {
|
|
scheduler = new CronScheduler([], asCronChannelRegistry(mockChannelRegistry));
|
|
expect(scheduler.name).toBe('cron');
|
|
expect(scheduler.status).toBe('disconnected');
|
|
});
|
|
|
|
it('status changes to connected after connect()', async () => {
|
|
scheduler = new CronScheduler([], asCronChannelRegistry(mockChannelRegistry));
|
|
await scheduler.connect();
|
|
expect(scheduler.status).toBe('connected');
|
|
});
|
|
|
|
it('status changes to disconnected after disconnect()', async () => {
|
|
scheduler = new CronScheduler([], asCronChannelRegistry(mockChannelRegistry));
|
|
await scheduler.connect();
|
|
await scheduler.disconnect();
|
|
expect(scheduler.status).toBe('disconnected');
|
|
});
|
|
|
|
it('skips disabled jobs', async () => {
|
|
const jobs = [makeCronJob({ enabled: false })];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
|
|
const messages: InboundMessage[] = [];
|
|
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
|
|
|
|
await scheduler.connect();
|
|
// Disabled job should not fire
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it('fires a message when triggerJob is called', async () => {
|
|
const jobs = [makeCronJob()];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
|
|
const messages: InboundMessage[] = [];
|
|
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
|
|
await scheduler.connect();
|
|
|
|
// Manually trigger (simulates cron firing)
|
|
scheduler.triggerJob('test-job');
|
|
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].channel).toBe('cron');
|
|
expect(messages[0].senderId).toBe('test-job');
|
|
expect(messages[0].text).toBe('Hello from cron');
|
|
});
|
|
|
|
it('uses isolated sender IDs when delivery mode is isolated_job', async () => {
|
|
const jobs = [makeCronJob()];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry), 'isolated_job');
|
|
|
|
const messages: InboundMessage[] = [];
|
|
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
|
|
await scheduler.connect();
|
|
scheduler.triggerJob('test-job');
|
|
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].senderId).toMatch(/^test-job:run-/);
|
|
expect(messages[0].metadata?.replyPeerId).toBe('test-job');
|
|
expect(messages[0].metadata?.deliveryMode).toBe('isolated_job');
|
|
});
|
|
|
|
it('forwards response to output channel on send()', async () => {
|
|
const mockOutputAdapter = {
|
|
send: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
mockChannelRegistry.get.mockReturnValue(mockOutputAdapter);
|
|
|
|
const jobs = [makeCronJob()];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
await scheduler.connect();
|
|
|
|
await scheduler.send('test-job', { text: 'Agent response' });
|
|
|
|
expect(mockChannelRegistry.get).toHaveBeenCalledWith('telegram');
|
|
expect(mockOutputAdapter.send).toHaveBeenCalledWith('123', { text: 'Agent response' });
|
|
});
|
|
|
|
it('logs warning when output channel not found', async () => {
|
|
mockChannelRegistry.get.mockReturnValue(undefined);
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
const jobs = [makeCronJob()];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
await scheduler.connect();
|
|
|
|
await scheduler.send('test-job', { text: 'Agent response' });
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Output channel'));
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('logs warning when job name not found in send()', async () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
const jobs = [makeCronJob()];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
await scheduler.connect();
|
|
|
|
await scheduler.send('nonexistent-job', { text: 'response' });
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No cron job'));
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('triggerJob includes model_tier in metadata when configured', () => {
|
|
const jobs = [makeCronJob({ model_tier: 'fast' })];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
|
|
const messages: InboundMessage[] = [];
|
|
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
|
|
|
|
scheduler.triggerJob('test-job');
|
|
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].metadata?.modelTier).toBe('fast');
|
|
});
|
|
|
|
it('triggerJob metadata.modelTier is undefined when not configured', () => {
|
|
const jobs = [makeCronJob()];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
|
|
const messages: InboundMessage[] = [];
|
|
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
|
|
|
|
scheduler.triggerJob('test-job');
|
|
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].metadata?.modelTier).toBeUndefined();
|
|
});
|
|
|
|
it('lists registered job names', () => {
|
|
const jobs = [
|
|
makeCronJob({ name: 'job-a' }),
|
|
makeCronJob({ name: 'job-b', enabled: false }),
|
|
];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
|
|
const names = scheduler.getJobNames();
|
|
expect(names).toEqual(['job-a', 'job-b']);
|
|
});
|
|
|
|
it('dedupes once_per_local_day job within same local day', () => {
|
|
const jobs = [makeCronJob({ once_per_local_day: true, timezone: 'America/New_York' })];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
|
|
const messages: InboundMessage[] = [];
|
|
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
|
|
|
|
const nowSpy = vi.spyOn(Date, 'now');
|
|
nowSpy.mockReturnValueOnce(new Date('2026-02-16T13:00:00.000Z').getTime()); // 08:00 local
|
|
scheduler.triggerJob('test-job');
|
|
nowSpy.mockReturnValueOnce(new Date('2026-02-16T18:00:00.000Z').getTime()); // still same local day
|
|
scheduler.triggerJob('test-job');
|
|
nowSpy.mockRestore();
|
|
|
|
expect(messages).toHaveLength(1);
|
|
});
|
|
|
|
it('allows once_per_local_day job on next local day', () => {
|
|
const jobs = [makeCronJob({ once_per_local_day: true, timezone: 'America/New_York' })];
|
|
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
|
|
|
|
const messages: InboundMessage[] = [];
|
|
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
|
|
|
|
const nowSpy = vi.spyOn(Date, 'now');
|
|
nowSpy.mockReturnValueOnce(new Date('2026-02-16T13:00:00.000Z').getTime()); // 08:00 local
|
|
scheduler.triggerJob('test-job');
|
|
nowSpy.mockReturnValueOnce(new Date('2026-02-17T13:00:00.000Z').getTime()); // next local day
|
|
scheduler.triggerJob('test-job');
|
|
nowSpy.mockRestore();
|
|
|
|
expect(messages).toHaveLength(2);
|
|
});
|
|
});
|