333 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
});
|