import { describe, expect, it } from 'vitest'; import { RedisTaskRepository, TaskAlreadyExistsError, TaskTransitionError, type RedisTaskClient, type RedisTaskTransaction, } from '../src/task-repository.js'; class NoopRedisTransaction implements RedisTaskTransaction { private readonly operations: ( | { readonly type: 'set'; readonly key: string; readonly value: string; readonly mode?: 'NX' | 'XX'; } | { readonly type: 'sadd'; readonly key: string; readonly member: string; } )[] = []; public constructor( private readonly kv: Map, private readonly sets: Map>, ) {} public set(key: string, value: string, mode?: 'NX' | 'XX'): RedisTaskTransaction { this.operations.push({ type: 'set', key, value, mode, }); return this; } public sadd(key: string, member: string): RedisTaskTransaction { this.operations.push({ type: 'sadd', key, member, }); return this; } public exec(): Promise { const results: (readonly [Error | null, unknown])[] = []; for (const operation of this.operations) { if (operation.type === 'set') { const exists = this.kv.has(operation.key); if (operation.mode === 'NX' && exists) { results.push([null, null]); continue; } if (operation.mode === 'XX' && !exists) { results.push([null, null]); continue; } this.kv.set(operation.key, operation.value); results.push([null, 'OK']); continue; } const values = this.sets.get(operation.key) ?? new Set(); const beforeSize = values.size; values.add(operation.member); this.sets.set(operation.key, values); results.push([null, values.size === beforeSize ? 0 : 1]); } return Promise.resolve(results); } } class InMemoryRedisClient implements RedisTaskClient { private readonly kv = new Map(); private readonly sets = new Map>(); public get(key: string): Promise { return Promise.resolve(this.kv.get(key) ?? null); } public mget(...keys: string[]): Promise<(string | null)[]> { return Promise.resolve(keys.map((key) => this.kv.get(key) ?? null)); } public set( key: string, value: string, mode?: 'NX' | 'XX', ): Promise<'OK' | null> { const exists = this.kv.has(key); if (mode === 'NX' && exists) { return Promise.resolve(null); } if (mode === 'XX' && !exists) { return Promise.resolve(null); } this.kv.set(key, value); return Promise.resolve('OK'); } public smembers(key: string): Promise { return Promise.resolve([...(this.sets.get(key) ?? new Set())]); } public sadd(key: string, member: string): Promise { const values = this.sets.get(key) ?? new Set(); const beforeSize = values.size; values.add(member); this.sets.set(key, values); return Promise.resolve(values.size === beforeSize ? 0 : 1); } public watch(): Promise<'OK'> { return Promise.resolve('OK'); } public unwatch(): Promise<'OK'> { return Promise.resolve('OK'); } public multi(): RedisTaskTransaction { return new NoopRedisTransaction(this.kv, this.sets); } } class MgetTrackingRedisClient extends InMemoryRedisClient { public getCalls = 0; public mgetCalls = 0; public lastMgetKeys: string[] = []; public override get(key: string): Promise { this.getCalls += 1; return super.get(key); } public override mget(...keys: string[]): Promise<(string | null)[]> { this.mgetCalls += 1; this.lastMgetKeys = [...keys]; return super.mget(...keys); } } describe('RedisTaskRepository CRUD', () => { it('creates and fetches a task with defaults', async () => { const repository = new RedisTaskRepository({ client: new InMemoryRedisClient(), now: () => 1_700_000_000_000, }); const created = await repository.create({ project: 'queue', mission: 'phase1', taskId: 'MQ-003', title: 'Implement task CRUD', }); const fetched = await repository.get('MQ-003'); expect(created.id).toBe('MQ-003'); expect(created.status).toBe('pending'); expect(created.priority).toBe('medium'); expect(created.lane).toBe('any'); expect(created.dependencies).toEqual([]); expect(created.createdAt).toBe(1_700_000_000_000); expect(fetched).toEqual(created); }); it('throws when creating a duplicate task id', async () => { const repository = new RedisTaskRepository({ client: new InMemoryRedisClient(), }); await repository.create({ project: 'queue', mission: 'phase1', taskId: 'MQ-003', title: 'First task', }); await expect( repository.create({ project: 'queue', mission: 'phase1', taskId: 'MQ-003', title: 'Duplicate', }), ).rejects.toBeInstanceOf(TaskAlreadyExistsError); }); it('lists tasks and filters by project, mission, and status', async () => { const repository = new RedisTaskRepository({ client: new InMemoryRedisClient(), }); await repository.create({ project: 'queue', mission: 'phase1', taskId: 'MQ-003A', title: 'Pending task', }); await repository.create({ project: 'queue', mission: 'phase2', taskId: 'MQ-003B', title: 'Claimed task', }); await repository.claim('MQ-003B', { agentId: 'agent-a', ttlSeconds: 60, }); const byProject = await repository.list({ project: 'queue', }); const byMission = await repository.list({ mission: 'phase2', }); const byStatus = await repository.list({ status: 'claimed', }); expect(byProject).toHaveLength(2); expect(byMission.map((task) => task.taskId)).toEqual(['MQ-003B']); expect(byStatus.map((task) => task.taskId)).toEqual(['MQ-003B']); }); it('lists 3+ tasks with a single mget call', async () => { const client = new MgetTrackingRedisClient(); const repository = new RedisTaskRepository({ client, }); await repository.create({ project: 'queue', mission: 'phase-list', taskId: 'MQ-MGET-001', title: 'Task one', }); await repository.create({ project: 'queue', mission: 'phase-list', taskId: 'MQ-MGET-002', title: 'Task two', }); await repository.create({ project: 'queue', mission: 'phase-list', taskId: 'MQ-MGET-003', title: 'Task three', }); const tasks = await repository.list(); expect(tasks).toHaveLength(3); expect(client.mgetCalls).toBe(1); expect(client.getCalls).toBe(0); expect(client.lastMgetKeys).toHaveLength(3); }); it('updates mutable fields and preserves immutable fields', async () => { const repository = new RedisTaskRepository({ client: new InMemoryRedisClient(), now: () => 1_700_000_000_001, }); await repository.create({ project: 'queue', mission: 'phase1', taskId: 'MQ-003', title: 'Original title', description: 'Original description', }); const updated = await repository.update('MQ-003', { title: 'Updated title', description: 'Updated description', priority: 'high', lane: 'coding', dependencies: ['MQ-002'], metadata: { source: 'unit-test', }, }); expect(updated.title).toBe('Updated title'); expect(updated.description).toBe('Updated description'); expect(updated.priority).toBe('high'); expect(updated.lane).toBe('coding'); expect(updated.dependencies).toEqual(['MQ-002']); expect(updated.metadata).toEqual({ source: 'unit-test' }); expect(updated.project).toBe('queue'); expect(updated.taskId).toBe('MQ-003'); expect(updated.updatedAt).toBe(1_700_000_000_001); }); it('rejects status transitions through update()', async () => { const repository = new RedisTaskRepository({ client: new InMemoryRedisClient(), }); await repository.create({ project: 'queue', mission: 'phase1', taskId: 'MQ-003-TRANSITION', title: 'Transition guard', }); await expect( repository.update('MQ-003-TRANSITION', { status: 'completed', }), ).rejects.toBeInstanceOf(TaskTransitionError); }); });