- Accept agent_id as alias for assigned_to in /api/controlplane/tasks - Store task_completed output tail (reduces transcript bloat) - Use project-local tmp/ for test temp dirs --- Build: pass (just typecheck) Tests: pass — 1536 passed (92 files)
534 lines
16 KiB
TypeScript
534 lines
16 KiB
TypeScript
/**
|
|
* src/ipc.test.ts — processTaskIpc unit tests.
|
|
*
|
|
* Tests authorization and scheduling logic in processTaskIpc().
|
|
* startIpcWatcher() is not tested here (requires FS watcher + real dirs).
|
|
*
|
|
* Run with: npx vitest run src/ipc.test.ts
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('./config.js', () => ({
|
|
DATA_DIR: require('path').resolve(process.cwd(), 'tmp', 'ipc-test'),
|
|
IPC_POLL_INTERVAL: 100,
|
|
MAIN_GROUP_FOLDER: 'main',
|
|
TIMEZONE: 'UTC',
|
|
}));
|
|
|
|
const dbMocks = vi.hoisted(() => ({
|
|
createTask: vi.fn<unknown[], Promise<void>>().mockResolvedValue(undefined),
|
|
getTaskById: vi.fn<[string], Promise<{ group_folder: string; status: string } | null>>().mockResolvedValue(null),
|
|
updateTask: vi.fn<unknown[], Promise<void>>().mockResolvedValue(undefined),
|
|
deleteTask: vi.fn<[string], Promise<void>>().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock('./db.js', () => dbMocks);
|
|
|
|
const groupFolderMock = vi.hoisted(() => ({
|
|
isValidGroupFolder: vi.fn<[string], boolean>(() => true),
|
|
}));
|
|
|
|
vi.mock('./group-folder.js', () => groupFolderMock);
|
|
|
|
vi.mock('./logger.js', () => ({
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('./agent-runner.js', () => ({}));
|
|
|
|
import { processTaskIpc, type IpcDeps } from './ipc.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeDeps(overrides: Partial<IpcDeps> = {}): IpcDeps {
|
|
return {
|
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
registeredGroups: vi.fn().mockReturnValue({}),
|
|
registerGroup: vi.fn().mockResolvedValue(undefined),
|
|
syncGroupMetadata: vi.fn().mockResolvedValue(undefined),
|
|
getAvailableGroups: vi.fn().mockResolvedValue([]),
|
|
writeGroupsSnapshot: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRegisteredGroups(entries: Record<string, { folder: string; name?: string }>) {
|
|
return Object.fromEntries(
|
|
Object.entries(entries).map(([jid, g]) => [
|
|
jid,
|
|
{ name: g.name ?? 'Test', folder: g.folder, trigger: '!cmd', added_at: '' },
|
|
]),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unknown / default type
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('processTaskIpc — unknown type', () => {
|
|
it('silently handles unknown task type', async () => {
|
|
const deps = makeDeps();
|
|
await expect(
|
|
processTaskIpc({ type: 'totally_unknown' }, 'main', true, deps),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// schedule_task
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('processTaskIpc — schedule_task', () => {
|
|
beforeEach(() => {
|
|
dbMocks.createTask.mockReset().mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('creates a cron task when fields are valid', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'say hi',
|
|
schedule_type: 'cron',
|
|
schedule_value: '0 9 * * *',
|
|
targetJid: '123@g.us',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).toHaveBeenCalledOnce();
|
|
const arg = dbMocks.createTask.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(arg.group_folder).toBe('group-a');
|
|
expect(arg.schedule_type).toBe('cron');
|
|
expect(arg.next_run).toBeTruthy();
|
|
});
|
|
|
|
it('creates an interval task', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'do work',
|
|
schedule_type: 'interval',
|
|
schedule_value: '60000',
|
|
targetJid: '123@g.us',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).toHaveBeenCalledOnce();
|
|
const arg = dbMocks.createTask.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(arg.schedule_type).toBe('interval');
|
|
expect(typeof arg.next_run).toBe('string');
|
|
});
|
|
|
|
it('creates a once task with a valid ISO timestamp', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'run once',
|
|
schedule_type: 'once',
|
|
schedule_value: '2099-01-01T00:00:00.000Z',
|
|
targetJid: '123@g.us',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).toHaveBeenCalledOnce();
|
|
const arg = dbMocks.createTask.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(arg.next_run).toBe('2099-01-01T00:00:00.000Z');
|
|
});
|
|
|
|
it('defaults context_mode to "isolated" for invalid values', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'test',
|
|
schedule_type: 'interval',
|
|
schedule_value: '5000',
|
|
targetJid: '123@g.us',
|
|
context_mode: 'weird_mode',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
const arg = dbMocks.createTask.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(arg.context_mode).toBe('isolated');
|
|
});
|
|
|
|
it('preserves context_mode "group"', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'test',
|
|
schedule_type: 'interval',
|
|
schedule_value: '5000',
|
|
targetJid: '123@g.us',
|
|
context_mode: 'group',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
const arg = dbMocks.createTask.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(arg.context_mode).toBe('group');
|
|
});
|
|
|
|
it('does not create task when targetJid is not registered', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue({}),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'test',
|
|
schedule_type: 'interval',
|
|
schedule_value: '5000',
|
|
targetJid: 'unknown@g.us',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks non-main group from scheduling for another group', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'other-group' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'test',
|
|
schedule_type: 'interval',
|
|
schedule_value: '5000',
|
|
targetJid: '123@g.us',
|
|
},
|
|
'my-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows non-main group to schedule for itself', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'my-group' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'self-schedule',
|
|
schedule_type: 'interval',
|
|
schedule_value: '5000',
|
|
targetJid: '123@g.us',
|
|
},
|
|
'my-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects invalid cron expression', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'test',
|
|
schedule_type: 'cron',
|
|
schedule_value: 'not-a-cron',
|
|
targetJid: '123@g.us',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects invalid interval (NaN)', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'test',
|
|
schedule_type: 'interval',
|
|
schedule_value: 'bad',
|
|
targetJid: '123@g.us',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects invalid once timestamp', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
});
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'test',
|
|
schedule_type: 'once',
|
|
schedule_value: 'not-a-date',
|
|
targetJid: '123@g.us',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(dbMocks.createTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing when required fields are missing', async () => {
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'schedule_task' }, 'main', true, deps);
|
|
expect(dbMocks.createTask).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// pause_task / resume_task / cancel_task
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('processTaskIpc — pause_task', () => {
|
|
beforeEach(() => {
|
|
dbMocks.getTaskById.mockReset();
|
|
dbMocks.updateTask.mockReset().mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('pauses an owned task', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue({ group_folder: 'my-group', status: 'active' });
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'pause_task', taskId: 'task-1' }, 'my-group', false, deps);
|
|
expect(dbMocks.updateTask).toHaveBeenCalledWith('task-1', { status: 'paused' });
|
|
});
|
|
|
|
it('main group can pause any task', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue({ group_folder: 'other-group', status: 'active' });
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'pause_task', taskId: 'task-2' }, 'main', true, deps);
|
|
expect(dbMocks.updateTask).toHaveBeenCalledWith('task-2', { status: 'paused' });
|
|
});
|
|
|
|
it('non-main group cannot pause task owned by another group', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue({ group_folder: 'other-group', status: 'active' });
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'pause_task', taskId: 'task-3' }, 'my-group', false, deps);
|
|
expect(dbMocks.updateTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing when taskId is missing', async () => {
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'pause_task' }, 'main', true, deps);
|
|
expect(dbMocks.updateTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing when task not found', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue(null);
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'pause_task', taskId: 'missing' }, 'main', true, deps);
|
|
expect(dbMocks.updateTask).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('processTaskIpc — resume_task', () => {
|
|
beforeEach(() => {
|
|
dbMocks.getTaskById.mockReset();
|
|
dbMocks.updateTask.mockReset().mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('resumes an owned task', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue({ group_folder: 'my-group', status: 'paused' });
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'resume_task', taskId: 'task-5' }, 'my-group', false, deps);
|
|
expect(dbMocks.updateTask).toHaveBeenCalledWith('task-5', { status: 'active' });
|
|
});
|
|
|
|
it('non-main group cannot resume task owned by another group', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue({ group_folder: 'other-group', status: 'paused' });
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'resume_task', taskId: 'task-6' }, 'my-group', false, deps);
|
|
expect(dbMocks.updateTask).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('processTaskIpc — cancel_task', () => {
|
|
beforeEach(() => {
|
|
dbMocks.getTaskById.mockReset();
|
|
dbMocks.deleteTask.mockReset().mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('cancels an owned task', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue({ group_folder: 'group-a', status: 'active' });
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'cancel_task', taskId: 'task-7' }, 'group-a', false, deps);
|
|
expect(dbMocks.deleteTask).toHaveBeenCalledWith('task-7');
|
|
});
|
|
|
|
it('non-main group cannot cancel task owned by another group', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue({ group_folder: 'other-group', status: 'active' });
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'cancel_task', taskId: 'task-8' }, 'my-group', false, deps);
|
|
expect(dbMocks.deleteTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('main group can cancel any task', async () => {
|
|
dbMocks.getTaskById.mockResolvedValue({ group_folder: 'some-group', status: 'active' });
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'cancel_task', taskId: 'task-9' }, 'main', true, deps);
|
|
expect(dbMocks.deleteTask).toHaveBeenCalledWith('task-9');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// refresh_groups
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('processTaskIpc — refresh_groups', () => {
|
|
it('calls syncGroupMetadata and writeGroupsSnapshot when isMain', async () => {
|
|
const deps = makeDeps({
|
|
registeredGroups: vi.fn().mockReturnValue(
|
|
makeRegisteredGroups({ '123@g.us': { folder: 'group-a' } }),
|
|
),
|
|
getAvailableGroups: vi.fn().mockResolvedValue([{ jid: '123@g.us', name: 'A' }]),
|
|
});
|
|
await processTaskIpc({ type: 'refresh_groups' }, 'main', true, deps);
|
|
expect(deps.syncGroupMetadata).toHaveBeenCalledWith(true);
|
|
expect(deps.writeGroupsSnapshot).toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks non-main group from requesting refresh', async () => {
|
|
const deps = makeDeps();
|
|
await processTaskIpc({ type: 'refresh_groups' }, 'other-group', false, deps);
|
|
expect(deps.syncGroupMetadata).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// register_group
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('processTaskIpc — register_group', () => {
|
|
beforeEach(() => {
|
|
groupFolderMock.isValidGroupFolder.mockReset().mockReturnValue(true);
|
|
});
|
|
|
|
it('registers a group when main sends valid data', async () => {
|
|
const deps = makeDeps();
|
|
await processTaskIpc(
|
|
{
|
|
type: 'register_group',
|
|
jid: '999@g.us',
|
|
name: 'New Group',
|
|
folder: 'new-group',
|
|
trigger: '!ng',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(deps.registerGroup).toHaveBeenCalledWith('999@g.us', expect.objectContaining({
|
|
name: 'New Group',
|
|
folder: 'new-group',
|
|
trigger: '!ng',
|
|
}));
|
|
});
|
|
|
|
it('blocks non-main group from registering', async () => {
|
|
const deps = makeDeps();
|
|
await processTaskIpc(
|
|
{
|
|
type: 'register_group',
|
|
jid: '999@g.us',
|
|
name: 'New Group',
|
|
folder: 'new-group',
|
|
trigger: '!ng',
|
|
},
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(deps.registerGroup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not register when folder is invalid', async () => {
|
|
groupFolderMock.isValidGroupFolder.mockReturnValue(false);
|
|
const deps = makeDeps();
|
|
await processTaskIpc(
|
|
{
|
|
type: 'register_group',
|
|
jid: '999@g.us',
|
|
name: 'Bad Group',
|
|
folder: '../../../etc',
|
|
trigger: '!bad',
|
|
},
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(deps.registerGroup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not register when required fields are missing', async () => {
|
|
const deps = makeDeps();
|
|
await processTaskIpc(
|
|
{ type: 'register_group', jid: '999@g.us' },
|
|
'main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(deps.registerGroup).not.toHaveBeenCalled();
|
|
});
|
|
});
|