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