agent-ecosystem/test/main/services/team/TeamTaskReader.test.ts

187 lines
6.2 KiB
TypeScript

import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import type { TeamTask } from '../../../../src/shared/types/team';
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function makeTask(id: string): TeamTask & { teamName: string } {
return {
id,
subject: id,
owner: 'alice',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
updatedAt: '2026-05-02T12:00:00.000Z',
teamName: 'atlas-hq',
};
}
describe('TeamTaskReader', () => {
let tmpDir: string | null = null;
afterEach(async () => {
vi.restoreAllMocks();
TeamTaskReader.invalidateAllTasksCache();
setClaudeBasePathOverride(null);
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
async function setupTasksRoot(): Promise<string> {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'team-task-reader-'));
setClaudeBasePathOverride(tmpDir);
await fsp.mkdir(path.join(tmpDir, 'tasks'), { recursive: true });
return tmpDir;
}
async function writeTaskFile(teamName: string, task: Record<string, unknown>): Promise<string> {
const tasksDir = path.join(tmpDir!, 'tasks', teamName);
await fsp.mkdir(tasksDir, { recursive: true });
const taskPath = path.join(tasksDir, `${String(task.id)}.json`);
await fsp.writeFile(taskPath, JSON.stringify(task, null, 2), 'utf8');
return taskPath;
}
it('does not reuse or cache a stale in-flight getAllTasks scan after invalidation', async () => {
const firstRead = createDeferred<(TeamTask & { teamName: string })[]>();
const secondRead = createDeferred<(TeamTask & { teamName: string })[]>();
const readAllTasksUncached = vi
.spyOn(
TeamTaskReader.prototype as unknown as {
readAllTasksUncached: () => Promise<(TeamTask & { teamName: string })[]>;
},
'readAllTasksUncached'
)
.mockImplementationOnce(() => firstRead.promise)
.mockImplementationOnce(() => secondRead.promise);
const reader = new TeamTaskReader();
const staleRequest = reader.getAllTasks();
await Promise.resolve();
expect(readAllTasksUncached).toHaveBeenCalledTimes(1);
TeamTaskReader.invalidateAllTasksCache();
const freshRequest = reader.getAllTasks();
await Promise.resolve();
expect(readAllTasksUncached).toHaveBeenCalledTimes(2);
secondRead.resolve([makeTask('fresh-task')]);
await expect(freshRequest).resolves.toEqual([makeTask('fresh-task')]);
firstRead.resolve([makeTask('stale-task')]);
await staleRequest;
await expect(reader.getAllTasks()).resolves.toEqual([makeTask('fresh-task')]);
expect(readAllTasksUncached).toHaveBeenCalledTimes(2);
});
it('keeps cached getAllTasks data isolated from caller mutations', async () => {
const readAllTasksUncached = vi
.spyOn(
TeamTaskReader.prototype as unknown as {
readAllTasksUncached: () => Promise<(TeamTask & { teamName: string })[]>;
},
'readAllTasksUncached'
)
.mockResolvedValueOnce([makeTask('cached-task')]);
const reader = new TeamTaskReader();
const firstRead = await reader.getAllTasks();
firstRead[0]!.subject = 'mutated caller copy';
await expect(reader.getAllTasks()).resolves.toEqual([makeTask('cached-task')]);
expect(readAllTasksUncached).toHaveBeenCalledTimes(1);
});
it('reuses parsed task files until their file signature changes', async () => {
await setupTasksRoot();
await writeTaskFile('atlas-hq', {
id: '1',
subject: 'Cached task',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
});
const readFileSpy = vi.spyOn(fs.promises, 'readFile');
const reader = new TeamTaskReader();
const firstRead = await reader.getTasks('atlas-hq');
expect(firstRead).toMatchObject([{ id: '1', subject: 'Cached task' }]);
firstRead[0]!.subject = 'Mutated caller copy';
await expect(reader.getTasks('atlas-hq')).resolves.toMatchObject([
{ id: '1', subject: 'Cached task' },
]);
expect(readFileSpy).toHaveBeenCalledTimes(1);
await writeTaskFile('atlas-hq', {
id: '1',
subject: 'Changed cached task',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
});
await expect(reader.getTasks('atlas-hq')).resolves.toMatchObject([
{ id: '1', subject: 'Changed cached task' },
]);
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
it('reuses read-only team task projection snapshots until a file signature changes', async () => {
await setupTasksRoot();
const taskPath = await writeTaskFile('atlas-hq', {
id: '1',
subject: 'Projection cached task',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
});
const readFileSpy = vi.spyOn(fs.promises, 'readFile');
const reader = new TeamTaskReader();
const firstRead = await reader.getTasksProjectionSnapshot('atlas-hq');
const secondRead = await reader.getTasksProjectionSnapshot('atlas-hq');
expect(secondRead).toBe(firstRead);
expect(secondRead).toMatchObject([{ id: '1', subject: 'Projection cached task' }]);
expect(readFileSpy).toHaveBeenCalledTimes(1);
await fsp.writeFile(
taskPath,
JSON.stringify(
{
id: '1',
subject: 'Projection changed task',
status: 'pending',
createdAt: '2026-05-02T12:00:00.000Z',
},
null,
2
),
'utf8'
);
const changedTime = new Date(Date.now() + 2_000);
await fsp.utimes(taskPath, changedTime, changedTime);
const thirdRead = await reader.getTasksProjectionSnapshot('atlas-hq');
expect(thirdRead).not.toBe(firstRead);
expect(thirdRead).toMatchObject([{ id: '1', subject: 'Projection changed task' }]);
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
});