Files
Codewalkers/apps/server/db/repositories/drizzle/errand.test.ts
Lukas May 940b0f8ed2 feat: Add errands persistence layer — repository port, Drizzle adapter, migration, and tests
- 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>
2026-03-06 12:35:06 +01:00

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();
});
});
});