import { beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => { const files = new Map(); let overrideVerifyRead: string | null = null; let readCount = 0; // Normalize path separators so tests pass on Windows (backslash → forward slash) const norm = (p: string): string => p.replace(/\\/g, '/'); const readFile = vi.fn(async (filePath: string) => { readCount += 1; if (overrideVerifyRead && readCount >= 2) { return overrideVerifyRead; } const data = files.get(norm(filePath)); if (data === undefined) { const error = new Error('ENOENT') as NodeJS.ErrnoException; error.code = 'ENOENT'; throw error; } return data; }); const atomicWrite = vi.fn(async (filePath: string, data: string) => { files.set(norm(filePath), data); }); return { files, readFile, atomicWrite, setVerifyOverride: (value: string | null) => { overrideVerifyRead = value; }, resetReadCount: () => { readCount = 0; }, }; }); vi.mock('fs', () => ({ promises: { readFile: hoisted.readFile, mkdir: vi.fn(async () => undefined), access: vi.fn(async () => { const error = new Error('ENOENT') as NodeJS.ErrnoException; error.code = 'ENOENT'; throw error; }), }, constants: { F_OK: 0 }, })); vi.mock('../../../../src/main/utils/pathDecoder', () => ({ getTasksBasePath: () => '/mock/tasks', })); vi.mock('../../../../src/main/services/team/atomicWrite', () => ({ atomicWriteAsync: hoisted.atomicWrite, })); import { TeamTaskWriter } from '../../../../src/main/services/team/TeamTaskWriter'; describe('TeamTaskWriter', () => { const writer = new TeamTaskWriter(); const taskPath = '/mock/tasks/my-team/12.json'; beforeEach(() => { hoisted.files.clear(); hoisted.readFile.mockClear(); hoisted.atomicWrite.mockClear(); hoisted.setVerifyOverride(null); hoisted.resetReadCount(); }); it('createTask writes CLI-compatible format with description, blocks, blockedBy', async () => { await writer.createTask('my-team', { id: '5', subject: 'Test task', owner: 'bob', status: 'pending', }); const writtenPath = '/mock/tasks/my-team/5.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}') as Record; expect(persisted.id).toBe('5'); expect(persisted.subject).toBe('Test task'); expect(persisted.owner).toBe('bob'); expect(persisted.status).toBe('pending'); // CLI requires these fields for Zod schema validation expect(persisted.description).toBe(''); expect(persisted.blocks).toEqual([]); expect(persisted.blockedBy).toEqual([]); }); it('createTask preserves provided description, blocks, blockedBy', async () => { await writer.createTask('my-team', { id: '6', subject: 'Task with details', description: 'Some description', status: 'pending', blocks: ['7'], blockedBy: ['3'], }); const writtenPath = '/mock/tasks/my-team/6.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}') as Record; expect(persisted.description).toBe('Some description'); expect(persisted.blocks).toEqual(['7']); expect(persisted.blockedBy).toEqual(['3']); }); it('updates status and preserves other fields', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', owner: 'alice', status: 'pending', }) ); await writer.updateStatus('my-team', '12', 'in_progress'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}') as Record; expect(persisted).toMatchObject({ id: '12', subject: 'task', owner: 'alice', status: 'in_progress', }); }); it('opens a new work interval when the previous completedAt is malformed', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', owner: 'alice', status: 'pending', workIntervals: [{ startedAt: '2026-05-02T10:00:00.000Z', completedAt: '' }], }) ); await writer.updateStatus('my-team', '12', 'in_progress'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}') as { workIntervals?: { startedAt?: string; completedAt?: string }[]; }; expect(persisted.workIntervals).toHaveLength(2); expect(persisted.workIntervals?.[0]?.completedAt).toBe(''); expect(persisted.workIntervals?.[1]?.completedAt).toBeUndefined(); }); it('throws when verify detects conflicting status', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', status: 'pending', }) ); hoisted.setVerifyOverride( JSON.stringify({ id: '12', subject: 'task', status: 'pending', }) ); await expect(writer.updateStatus('my-team', '12', 'in_progress')).rejects.toThrow( 'Task status update verification failed: 12' ); }); describe('historyEvents', () => { it('createTask records initial task_created event', async () => { await writer.createTask('my-team', { id: '10', subject: 'New task', status: 'pending', createdBy: 'alice', }); const writtenPath = '/mock/tasks/my-team/10.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}'); expect(persisted.historyEvents).toHaveLength(1); expect(persisted.historyEvents[0]).toMatchObject({ type: 'task_created', status: 'pending', actor: 'alice', }); expect(typeof persisted.historyEvents[0].id).toBe('string'); expect(typeof persisted.historyEvents[0].timestamp).toBe('string'); }); it('createTask with in_progress records initial task_created event', async () => { await writer.createTask('my-team', { id: '11', subject: 'Start immediately', status: 'in_progress', createdBy: 'bob', }); const writtenPath = '/mock/tasks/my-team/11.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}'); expect(persisted.historyEvents).toHaveLength(1); expect(persisted.historyEvents[0]).toMatchObject({ type: 'task_created', status: 'in_progress', actor: 'bob', }); }); it('createTask records in_progress workIntervals as status time, not runtime activity', async () => { await writer.createTask('my-team', { id: '14', subject: 'Already assigned', status: 'in_progress', createdAt: '2026-05-14T10:00:00.000Z', }); const writtenPath = '/mock/tasks/my-team/14.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}'); expect(persisted.workIntervals).toEqual([{ startedAt: '2026-05-14T10:00:00.000Z' }]); }); it('createTask without createdBy omits actor', async () => { await writer.createTask('my-team', { id: '13', subject: 'No author', status: 'pending', }); const writtenPath = '/mock/tasks/my-team/13.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}'); expect(persisted.historyEvents).toHaveLength(1); expect(persisted.historyEvents[0].type).toBe('task_created'); expect(persisted.historyEvents[0].actor).toBeUndefined(); }); it('updateStatus appends status_changed event', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', status: 'pending', historyEvents: [ { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z', actor: 'user', }, ], }) ); await writer.updateStatus('my-team', '12', 'in_progress', 'alice'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); expect(persisted.historyEvents).toHaveLength(2); expect(persisted.historyEvents[1]).toMatchObject({ type: 'status_changed', from: 'pending', to: 'in_progress', actor: 'alice', }); }); it('updateStatus works on task without historyEvents', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'legacy task', status: 'pending', }) ); await writer.updateStatus('my-team', '12', 'in_progress'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); expect(persisted.historyEvents).toHaveLength(1); expect(persisted.historyEvents[0]).toMatchObject({ type: 'status_changed', from: 'pending', to: 'in_progress', }); expect(persisted.historyEvents[0].actor).toBeUndefined(); }); it('softDelete appends status_changed to deleted', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', status: 'in_progress', historyEvents: [ { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z', }, { type: 'status_changed', id: 'ev2', from: 'pending', to: 'in_progress', timestamp: '2024-01-01T00:01:00.000Z', }, ], }) ); await writer.softDelete('my-team', '12', 'user'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); expect(persisted.historyEvents).toHaveLength(3); expect(persisted.historyEvents[2]).toMatchObject({ type: 'status_changed', from: 'in_progress', to: 'deleted', actor: 'user', }); }); it('restoreTask appends status_changed to pending', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', status: 'deleted', deletedAt: '2024-01-01T00:02:00.000Z', historyEvents: [ { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z', }, { type: 'status_changed', id: 'ev2', from: 'pending', to: 'deleted', timestamp: '2024-01-01T00:02:00.000Z', }, ], }) ); await writer.restoreTask('my-team', '12', 'user'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); expect(persisted.status).toBe('pending'); expect(persisted.historyEvents).toHaveLength(3); expect(persisted.historyEvents[2]).toMatchObject({ type: 'status_changed', from: 'deleted', to: 'pending', actor: 'user', }); }); it('restoreTask defaults actor to user when not provided', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', status: 'deleted', deletedAt: '2024-01-01T00:02:00.000Z', }) ); await writer.restoreTask('my-team', '12'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); expect(persisted.historyEvents).toHaveLength(1); expect(persisted.historyEvents[0]).toMatchObject({ type: 'status_changed', from: 'deleted', to: 'pending', actor: 'user', }); }); it('updateOwner appends owner_changed event', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', owner: 'alice', status: 'pending', historyEvents: [ { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z', }, ], }) ); await writer.updateOwner('my-team', '12', 'bob'); await writer.updateOwner('my-team', '12', 'bob'); await writer.updateOwner('my-team', '12', null); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); const ownerEvents = persisted.historyEvents.filter( (event: Record) => event.type === 'owner_changed' ); expect(ownerEvents).toHaveLength(2); expect(ownerEvents[0]).toMatchObject({ type: 'owner_changed', from: 'alice', to: 'bob', actor: 'user', }); expect(ownerEvents[1]).toMatchObject({ type: 'owner_changed', from: 'bob', actor: 'user', }); expect(ownerEvents[1].to).toBeUndefined(); }); }); });