clawdie-ai/src/ipc.test.ts
Mevy Assistant 649d7a5351 Fix controlplane task API + test temp dirs
- 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)
2026-04-19 05:29:17 +00:00

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();
});
});