Files
mosaic/packages/queue/tests/task-repository.test.ts

333 lines
8.4 KiB
TypeScript

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<string, string>,
private readonly sets: Map<string, Set<string>>,
) {}
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<readonly (readonly [Error | null, unknown])[] | null> {
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<string>();
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<string, string>();
private readonly sets = new Map<string, Set<string>>();
public get(key: string): Promise<string | null> {
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<string[]> {
return Promise.resolve([...(this.sets.get(key) ?? new Set<string>())]);
}
public sadd(key: string, member: string): Promise<number> {
const values = this.sets.get(key) ?? new Set<string>();
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<string | null> {
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);
});
});