Review follow-up: - runtime.test.js: add syncProject wiring tests with a real temp-dir project and real project-config runtime — asserts reconcileLoopTasks is driven with the discovered loops when the project path is known (task created, nextRunAt computed) and that plain listing is used when the path cannot be resolved (reconcile not called). - service.js: DELETE on a loop-owned task is rejected with a 400 only while its loop file still exists on disk; once the file is gone the orphan task can be deleted directly instead of waiting for the next reconcile. Tests use real temp files for both branches. - DOCUMENTATION.md: delete semantics updated accordingly. - PR description refreshed for the final HEAD (test counts, reconciliation contract, evidence wording).
100 lines
3.9 KiB
JavaScript
100 lines
3.9 KiB
JavaScript
import { describe, expect, it, vi } from 'vitest';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
|
import { createScheduledTaskService } from './service.js';
|
|
|
|
const createService = (overrides = {}) => {
|
|
const projectConfigRuntime = {
|
|
listScheduledTasks: vi.fn(async () => []),
|
|
deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })),
|
|
...(overrides.projectConfigRuntime || {}),
|
|
};
|
|
const scheduledTasksRuntime = {
|
|
syncProject: vi.fn(async () => []),
|
|
...(overrides.scheduledTasksRuntime || {}),
|
|
};
|
|
const service = createScheduledTaskService({
|
|
readSettingsFromDiskMigrated: async () => ({
|
|
projects: [{ id: 'project-test', path: '/repo' }],
|
|
}),
|
|
sanitizeProjects: (projects) => projects,
|
|
projectConfigRuntime,
|
|
scheduledTasksRuntime,
|
|
});
|
|
return { service, projectConfigRuntime, scheduledTasksRuntime };
|
|
};
|
|
|
|
const loopTask = {
|
|
id: 'loop:project:daily-digest',
|
|
name: 'daily-digest',
|
|
enabled: true,
|
|
loopFile: '/repo/.agents/loops/daily-digest.md',
|
|
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
|
|
execution: { prompt: 'digest', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
};
|
|
|
|
describe('scheduled-task service remove', () => {
|
|
it('rejects deleting a loop-sourced task while its loop file still exists', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-'));
|
|
try {
|
|
const loopFilePath = path.join(tempRoot, 'daily.md');
|
|
await writeFile(loopFilePath, '---\nname: daily-digest\n---\nRun.\n', 'utf8');
|
|
|
|
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
|
|
projectConfigRuntime: {
|
|
listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]),
|
|
},
|
|
});
|
|
|
|
await expect(service.remove('project-test', loopTask.id)).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: expect.stringContaining('delete the file to remove the task'),
|
|
});
|
|
expect(projectConfigRuntime.deleteScheduledTask).not.toHaveBeenCalled();
|
|
expect(scheduledTasksRuntime.syncProject).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('allows deleting a loop-sourced task once its loop file is gone', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-'));
|
|
try {
|
|
// The loop file was removed from disk; the orphan task is allowed to be
|
|
// deleted directly instead of waiting for the next reconcile.
|
|
const loopFilePath = path.join(tempRoot, 'gone.md');
|
|
|
|
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
|
|
projectConfigRuntime: {
|
|
listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]),
|
|
},
|
|
});
|
|
|
|
const tasks = await service.remove('project-test', loopTask.id);
|
|
|
|
expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', loopTask.id);
|
|
expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled();
|
|
expect(Array.isArray(tasks)).toBe(true);
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('deletes JSON-configured tasks normally', async () => {
|
|
const jsonTask = { ...loopTask, id: 'json-task', loopFile: undefined };
|
|
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
|
|
projectConfigRuntime: {
|
|
listScheduledTasks: vi.fn(async () => [jsonTask]),
|
|
deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })),
|
|
},
|
|
});
|
|
|
|
const tasks = await service.remove('project-test', jsonTask.id);
|
|
|
|
expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', jsonTask.id);
|
|
expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled();
|
|
expect(Array.isArray(tasks)).toBe(true);
|
|
});
|
|
});
|