- Add errand-repository.ts port with ErrandRepository, ErrandWithAlias, ErrandStatus types - Add DrizzleErrandRepository adapter with create, findById (left-joins agents for alias), findAll (optional projectId/status filters, desc by createdAt), update, delete - Wire exports into repositories/index.ts and repositories/drizzle/index.ts - Add migration 0035_faulty_human_fly.sql (CREATE TABLE errands) and drizzle snapshot - Add 13 tests covering CRUD, filtering, ordering, agentAlias join, cascade/set-null FK behaviour - Update docs/database.md to document the errands table and ErrandRepository Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
/**
|
|
* DrizzleErrandRepository Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { DrizzleErrandRepository } from './errand.js';
|
|
import { createTestDatabase } from './test-helpers.js';
|
|
import type { DrizzleDatabase } from '../../index.js';
|
|
import { projects, agents, errands } from '../../schema.js';
|
|
import { nanoid } from 'nanoid';
|
|
import { eq } from 'drizzle-orm';
|
|
|
|
describe('DrizzleErrandRepository', () => {
|
|
let db: DrizzleDatabase;
|
|
let repo: DrizzleErrandRepository;
|
|
|
|
beforeEach(() => {
|
|
db = createTestDatabase();
|
|
repo = new DrizzleErrandRepository(db);
|
|
});
|
|
|
|
// Helper: create a project record
|
|
async function createProject(name = 'Test Project', suffix = '') {
|
|
const id = nanoid();
|
|
const now = new Date();
|
|
const [project] = await db.insert(projects).values({
|
|
id,
|
|
name: name + suffix + id,
|
|
url: `https://github.com/test/${id}`,
|
|
defaultBranch: 'main',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}).returning();
|
|
return project;
|
|
}
|
|
|
|
// Helper: create an agent record
|
|
async function createAgent(name?: string) {
|
|
const id = nanoid();
|
|
const now = new Date();
|
|
const agentName = name ?? `agent-${id}`;
|
|
const [agent] = await db.insert(agents).values({
|
|
id,
|
|
name: agentName,
|
|
worktreeId: `agent-workdirs/${agentName}`,
|
|
provider: 'claude',
|
|
status: 'idle',
|
|
mode: 'execute',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}).returning();
|
|
return agent;
|
|
}
|
|
|
|
// Helper: create an errand
|
|
async function createErrand(overrides: Partial<{
|
|
id: string;
|
|
description: string;
|
|
branch: string;
|
|
baseBranch: string;
|
|
agentId: string | null;
|
|
projectId: string | null;
|
|
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
|
|
createdAt: Date;
|
|
}> = {}) {
|
|
const project = await createProject();
|
|
const id = overrides.id ?? nanoid();
|
|
return repo.create({
|
|
id,
|
|
description: overrides.description ?? 'Test errand',
|
|
branch: overrides.branch ?? 'feature/test',
|
|
baseBranch: overrides.baseBranch ?? 'main',
|
|
agentId: overrides.agentId !== undefined ? overrides.agentId : null,
|
|
projectId: overrides.projectId !== undefined ? overrides.projectId : project.id,
|
|
status: overrides.status ?? 'active',
|
|
});
|
|
}
|
|
|
|
describe('create + findById', () => {
|
|
it('should create errand and find by id with all fields', async () => {
|
|
const project = await createProject();
|
|
const id = nanoid();
|
|
|
|
await repo.create({
|
|
id,
|
|
description: 'Fix the bug',
|
|
branch: 'fix/bug-123',
|
|
baseBranch: 'main',
|
|
agentId: null,
|
|
projectId: project.id,
|
|
status: 'active',
|
|
});
|
|
|
|
const found = await repo.findById(id);
|
|
expect(found).toBeDefined();
|
|
expect(found!.id).toBe(id);
|
|
expect(found!.description).toBe('Fix the bug');
|
|
expect(found!.branch).toBe('fix/bug-123');
|
|
expect(found!.baseBranch).toBe('main');
|
|
expect(found!.status).toBe('active');
|
|
expect(found!.projectId).toBe(project.id);
|
|
expect(found!.agentId).toBeNull();
|
|
expect(found!.agentAlias).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('findAll', () => {
|
|
it('should return all errands ordered by createdAt desc', async () => {
|
|
const project = await createProject();
|
|
const t1 = new Date('2024-01-01T00:00:00Z');
|
|
const t2 = new Date('2024-01-02T00:00:00Z');
|
|
const t3 = new Date('2024-01-03T00:00:00Z');
|
|
|
|
const id1 = nanoid();
|
|
const id2 = nanoid();
|
|
const id3 = nanoid();
|
|
|
|
await db.insert(errands).values([
|
|
{ id: id1, description: 'Errand 1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t1, updatedAt: t1 },
|
|
{ id: id2, description: 'Errand 2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t2, updatedAt: t2 },
|
|
{ id: id3, description: 'Errand 3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t3, updatedAt: t3 },
|
|
]);
|
|
|
|
const result = await repo.findAll();
|
|
expect(result.length).toBeGreaterThanOrEqual(3);
|
|
// Find our three in the results
|
|
const ids = result.map((e) => e.id);
|
|
expect(ids.indexOf(id3)).toBeLessThan(ids.indexOf(id2));
|
|
expect(ids.indexOf(id2)).toBeLessThan(ids.indexOf(id1));
|
|
});
|
|
|
|
it('should filter by projectId', async () => {
|
|
const projectA = await createProject('A');
|
|
const projectB = await createProject('B');
|
|
const now = new Date();
|
|
|
|
const idA1 = nanoid();
|
|
const idA2 = nanoid();
|
|
const idB1 = nanoid();
|
|
|
|
await db.insert(errands).values([
|
|
{ id: idA1, description: 'A1', branch: 'b-a1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
|
{ id: idA2, description: 'A2', branch: 'b-a2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
|
{ id: idB1, description: 'B1', branch: 'b-b1', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now },
|
|
]);
|
|
|
|
const result = await repo.findAll({ projectId: projectA.id });
|
|
expect(result).toHaveLength(2);
|
|
expect(result.map((e) => e.id).sort()).toEqual([idA1, idA2].sort());
|
|
});
|
|
|
|
it('should filter by status', async () => {
|
|
const project = await createProject();
|
|
const now = new Date();
|
|
|
|
const id1 = nanoid();
|
|
const id2 = nanoid();
|
|
const id3 = nanoid();
|
|
|
|
await db.insert(errands).values([
|
|
{ id: id1, description: 'E1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: now, updatedAt: now },
|
|
{ id: id2, description: 'E2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
|
{ id: id3, description: 'E3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'merged', createdAt: now, updatedAt: now },
|
|
]);
|
|
|
|
const result = await repo.findAll({ status: 'pending_review' });
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe(id2);
|
|
});
|
|
|
|
it('should filter by both projectId and status', async () => {
|
|
const projectA = await createProject('PA');
|
|
const projectB = await createProject('PB');
|
|
const now = new Date();
|
|
|
|
const idMatch = nanoid();
|
|
const idOtherStatus = nanoid();
|
|
const idOtherProject = nanoid();
|
|
const idNeither = nanoid();
|
|
|
|
await db.insert(errands).values([
|
|
{ id: idMatch, description: 'Match', branch: 'b1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
|
{ id: idOtherStatus, description: 'Wrong status', branch: 'b2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
|
|
{ id: idOtherProject, description: 'Wrong project', branch: 'b3', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'pending_review', createdAt: now, updatedAt: now },
|
|
{ id: idNeither, description: 'Neither', branch: 'b4', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now },
|
|
]);
|
|
|
|
const result = await repo.findAll({ projectId: projectA.id, status: 'pending_review' });
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe(idMatch);
|
|
});
|
|
});
|
|
|
|
describe('findById', () => {
|
|
it('should return agentAlias when agentId is set', async () => {
|
|
const agent = await createAgent('known-agent');
|
|
const project = await createProject();
|
|
const id = nanoid();
|
|
const now = new Date();
|
|
|
|
await db.insert(errands).values({
|
|
id,
|
|
description: 'With agent',
|
|
branch: 'feature/x',
|
|
baseBranch: 'main',
|
|
agentId: agent.id,
|
|
projectId: project.id,
|
|
status: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
const found = await repo.findById(id);
|
|
expect(found).toBeDefined();
|
|
expect(found!.agentAlias).toBe(agent.name);
|
|
});
|
|
|
|
it('should return agentAlias as null when agentId is null', async () => {
|
|
const project = await createProject();
|
|
const id = nanoid();
|
|
const now = new Date();
|
|
|
|
await db.insert(errands).values({
|
|
id,
|
|
description: 'No agent',
|
|
branch: 'feature/y',
|
|
baseBranch: 'main',
|
|
agentId: null,
|
|
projectId: project.id,
|
|
status: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
const found = await repo.findById(id);
|
|
expect(found).toBeDefined();
|
|
expect(found!.agentAlias).toBeNull();
|
|
});
|
|
|
|
it('should return undefined for unknown id', async () => {
|
|
const found = await repo.findById('nonexistent');
|
|
expect(found).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should update status and advance updatedAt', async () => {
|
|
const project = await createProject();
|
|
const id = nanoid();
|
|
const past = new Date('2024-01-01T00:00:00Z');
|
|
|
|
await db.insert(errands).values({
|
|
id,
|
|
description: 'Errand',
|
|
branch: 'feature/update',
|
|
baseBranch: 'main',
|
|
agentId: null,
|
|
projectId: project.id,
|
|
status: 'active',
|
|
createdAt: past,
|
|
updatedAt: past,
|
|
});
|
|
|
|
const updated = await repo.update(id, { status: 'pending_review' });
|
|
expect(updated.status).toBe('pending_review');
|
|
expect(updated.updatedAt.getTime()).toBeGreaterThan(past.getTime());
|
|
});
|
|
|
|
it('should throw on unknown id', async () => {
|
|
await expect(
|
|
repo.update('nonexistent', { status: 'merged' })
|
|
).rejects.toThrow('Errand not found');
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should delete errand and findById returns undefined', async () => {
|
|
const errand = await createErrand();
|
|
await repo.delete(errand.id);
|
|
const found = await repo.findById(errand.id);
|
|
expect(found).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('cascade and set null', () => {
|
|
it('should cascade delete errands when project is deleted', async () => {
|
|
const project = await createProject();
|
|
const id = nanoid();
|
|
const now = new Date();
|
|
|
|
await db.insert(errands).values({
|
|
id,
|
|
description: 'Cascade test',
|
|
branch: 'feature/cascade',
|
|
baseBranch: 'main',
|
|
agentId: null,
|
|
projectId: project.id,
|
|
status: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
// Delete project — should cascade delete errands
|
|
await db.delete(projects).where(eq(projects.id, project.id));
|
|
|
|
const found = await repo.findById(id);
|
|
expect(found).toBeUndefined();
|
|
});
|
|
|
|
it('should set agentId to null when agent is deleted', async () => {
|
|
const agent = await createAgent();
|
|
const project = await createProject();
|
|
const id = nanoid();
|
|
const now = new Date();
|
|
|
|
await db.insert(errands).values({
|
|
id,
|
|
description: 'Agent null test',
|
|
branch: 'feature/agent-null',
|
|
baseBranch: 'main',
|
|
agentId: agent.id,
|
|
projectId: project.id,
|
|
status: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
// Delete agent — should set null
|
|
await db.delete(agents).where(eq(agents.id, agent.id));
|
|
|
|
const [errand] = await db.select().from(errands).where(eq(errands.id, id));
|
|
expect(errand).toBeDefined();
|
|
expect(errand.agentId).toBeNull();
|
|
});
|
|
});
|
|
});
|