test(02-02): add repository adapter tests

- createTestDatabase helper with in-memory SQLite schema
- initiative.test.ts: CRUD, defaults, not-found errors
- phase.test.ts: CRUD, FK constraint, findByInitiativeId ordering
- plan.test.ts: CRUD, FK constraint, findByPhaseId ordering
- task.test.ts: CRUD, FK constraint, findByPlanId ordering
- Fixed adapters to apply defaults and fetch inserted records
- All 42 tests passing
This commit is contained in:
Lukas May
2026-01-30 14:53:15 +01:00
parent 14e0f6f0f5
commit 830aa4b03f
9 changed files with 798 additions and 24 deletions

View File

@@ -0,0 +1,124 @@
/**
* DrizzleInitiativeRepository Tests
*
* Tests for the Initiative repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleInitiativeRepository', () => {
let db: DrizzleDatabase;
let repo: DrizzleInitiativeRepository;
beforeEach(() => {
db = createTestDatabase();
repo = new DrizzleInitiativeRepository(db);
});
describe('create', () => {
it('should create an initiative with generated id and timestamps', async () => {
const initiative = await repo.create({
name: 'Test Initiative',
description: 'A test initiative',
});
expect(initiative.id).toBeDefined();
expect(initiative.id.length).toBeGreaterThan(0);
expect(initiative.name).toBe('Test Initiative');
expect(initiative.description).toBe('A test initiative');
expect(initiative.status).toBe('active');
expect(initiative.createdAt).toBeInstanceOf(Date);
expect(initiative.updatedAt).toBeInstanceOf(Date);
});
it('should use provided status', async () => {
const initiative = await repo.create({
name: 'Completed Initiative',
status: 'completed',
});
expect(initiative.status).toBe('completed');
});
});
describe('findById', () => {
it('should return null for non-existent initiative', async () => {
const result = await repo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing initiative', async () => {
const created = await repo.create({
name: 'Find Me',
});
const found = await repo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('Find Me');
});
});
describe('findAll', () => {
it('should return empty array initially', async () => {
const all = await repo.findAll();
expect(all).toEqual([]);
});
it('should return all initiatives', async () => {
await repo.create({ name: 'Initiative 1' });
await repo.create({ name: 'Initiative 2' });
await repo.create({ name: 'Initiative 3' });
const all = await repo.findAll();
expect(all.length).toBe(3);
});
});
describe('update', () => {
it('should update fields and updatedAt', async () => {
const created = await repo.create({
name: 'Original Name',
status: 'active',
});
// Small delay to ensure updatedAt differs
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await repo.update(created.id, {
name: 'Updated Name',
status: 'completed',
});
expect(updated.name).toBe('Updated Name');
expect(updated.status).toBe('completed');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
});
it('should throw for non-existent initiative', async () => {
await expect(
repo.update('non-existent-id', { name: 'New Name' })
).rejects.toThrow('Initiative not found');
});
});
describe('delete', () => {
it('should delete an existing initiative', async () => {
const created = await repo.create({ name: 'To Delete' });
await repo.delete(created.id);
const found = await repo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent initiative', async () => {
await expect(repo.delete('non-existent-id')).rejects.toThrow(
'Initiative not found'
);
});
});
});

View File

@@ -24,17 +24,20 @@ export class DrizzleInitiativeRepository implements InitiativeRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateInitiativeData): Promise<Initiative> {
const id = nanoid();
const now = new Date();
const initiative = {
id: nanoid(),
await this.db.insert(initiatives).values({
id,
...data,
status: data.status ?? 'active',
createdAt: now,
updatedAt: now,
};
});
await this.db.insert(initiatives).values(initiative);
return initiative as Initiative;
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
}
async findById(id: string): Promise<Initiative | null> {

View File

@@ -0,0 +1,171 @@
/**
* DrizzlePhaseRepository Tests
*
* Tests for the Phase repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzlePhaseRepository', () => {
let db: DrizzleDatabase;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testInitiativeId: string;
beforeEach(async () => {
db = createTestDatabase();
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
// Create a test initiative for FK constraint
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
testInitiativeId = initiative.id;
});
describe('create', () => {
it('should create a phase with generated id and timestamps', async () => {
const phase = await phaseRepo.create({
initiativeId: testInitiativeId,
number: 1,
name: 'Test Phase',
description: 'A test phase',
});
expect(phase.id).toBeDefined();
expect(phase.id.length).toBeGreaterThan(0);
expect(phase.initiativeId).toBe(testInitiativeId);
expect(phase.number).toBe(1);
expect(phase.name).toBe('Test Phase');
expect(phase.status).toBe('pending');
expect(phase.createdAt).toBeInstanceOf(Date);
expect(phase.updatedAt).toBeInstanceOf(Date);
});
it('should throw for invalid initiativeId (FK constraint)', async () => {
await expect(
phaseRepo.create({
initiativeId: 'invalid-initiative-id',
number: 1,
name: 'Invalid Phase',
})
).rejects.toThrow();
});
});
describe('findById', () => {
it('should return null for non-existent phase', async () => {
const result = await phaseRepo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing phase', async () => {
const created = await phaseRepo.create({
initiativeId: testInitiativeId,
number: 1,
name: 'Find Me',
});
const found = await phaseRepo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('Find Me');
});
});
describe('findByInitiativeId', () => {
it('should return empty array for initiative with no phases', async () => {
const phases = await phaseRepo.findByInitiativeId(testInitiativeId);
expect(phases).toEqual([]);
});
it('should return only matching phases ordered by number', async () => {
// Create phases out of order
await phaseRepo.create({
initiativeId: testInitiativeId,
number: 3,
name: 'Phase 3',
});
await phaseRepo.create({
initiativeId: testInitiativeId,
number: 1,
name: 'Phase 1',
});
await phaseRepo.create({
initiativeId: testInitiativeId,
number: 2,
name: 'Phase 2',
});
// Create another initiative with phases
const otherInitiative = await initiativeRepo.create({
name: 'Other Initiative',
});
await phaseRepo.create({
initiativeId: otherInitiative.id,
number: 1,
name: 'Other Phase',
});
const phases = await phaseRepo.findByInitiativeId(testInitiativeId);
expect(phases.length).toBe(3);
expect(phases[0].name).toBe('Phase 1');
expect(phases[1].name).toBe('Phase 2');
expect(phases[2].name).toBe('Phase 3');
});
});
describe('update', () => {
it('should update fields and updatedAt', async () => {
const created = await phaseRepo.create({
initiativeId: testInitiativeId,
number: 1,
name: 'Original Name',
status: 'pending',
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await phaseRepo.update(created.id, {
name: 'Updated Name',
status: 'in_progress',
});
expect(updated.name).toBe('Updated Name');
expect(updated.status).toBe('in_progress');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
});
it('should throw for non-existent phase', async () => {
await expect(
phaseRepo.update('non-existent-id', { name: 'New Name' })
).rejects.toThrow('Phase not found');
});
});
describe('delete', () => {
it('should delete an existing phase', async () => {
const created = await phaseRepo.create({
initiativeId: testInitiativeId,
number: 1,
name: 'To Delete',
});
await phaseRepo.delete(created.id);
const found = await phaseRepo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent phase', async () => {
await expect(phaseRepo.delete('non-existent-id')).rejects.toThrow(
'Phase not found'
);
});
});
});

View File

@@ -24,17 +24,20 @@ export class DrizzlePhaseRepository implements PhaseRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreatePhaseData): Promise<Phase> {
const id = nanoid();
const now = new Date();
const phase = {
id: nanoid(),
await this.db.insert(phases).values({
id,
...data,
status: data.status ?? 'pending',
createdAt: now,
updatedAt: now,
};
});
await this.db.insert(phases).values(phase);
return phase as Phase;
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
}
async findById(id: string): Promise<Phase | null> {

View File

@@ -0,0 +1,169 @@
/**
* DrizzlePlanRepository Tests
*
* Tests for the Plan repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzlePlanRepository } from './plan.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzlePlanRepository', () => {
let db: DrizzleDatabase;
let planRepo: DrizzlePlanRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testPhaseId: string;
beforeEach(async () => {
db = createTestDatabase();
planRepo = new DrizzlePlanRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
// Create test initiative and phase for FK constraint
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
number: 1,
name: 'Test Phase',
});
testPhaseId = phase.id;
});
describe('create', () => {
it('should create a plan with generated id and timestamps', async () => {
const plan = await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'Test Plan',
description: 'A test plan',
});
expect(plan.id).toBeDefined();
expect(plan.id.length).toBeGreaterThan(0);
expect(plan.phaseId).toBe(testPhaseId);
expect(plan.number).toBe(1);
expect(plan.name).toBe('Test Plan');
expect(plan.status).toBe('pending');
expect(plan.createdAt).toBeInstanceOf(Date);
expect(plan.updatedAt).toBeInstanceOf(Date);
});
it('should throw for invalid phaseId (FK constraint)', async () => {
await expect(
planRepo.create({
phaseId: 'invalid-phase-id',
number: 1,
name: 'Invalid Plan',
})
).rejects.toThrow();
});
});
describe('findById', () => {
it('should return null for non-existent plan', async () => {
const result = await planRepo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing plan', async () => {
const created = await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'Find Me',
});
const found = await planRepo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('Find Me');
});
});
describe('findByPhaseId', () => {
it('should return empty array for phase with no plans', async () => {
const plans = await planRepo.findByPhaseId(testPhaseId);
expect(plans).toEqual([]);
});
it('should return only matching plans ordered by number', async () => {
// Create plans out of order
await planRepo.create({
phaseId: testPhaseId,
number: 3,
name: 'Plan 3',
});
await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'Plan 1',
});
await planRepo.create({
phaseId: testPhaseId,
number: 2,
name: 'Plan 2',
});
const plans = await planRepo.findByPhaseId(testPhaseId);
expect(plans.length).toBe(3);
expect(plans[0].name).toBe('Plan 1');
expect(plans[1].name).toBe('Plan 2');
expect(plans[2].name).toBe('Plan 3');
});
});
describe('update', () => {
it('should update fields and updatedAt', async () => {
const created = await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'Original Name',
status: 'pending',
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await planRepo.update(created.id, {
name: 'Updated Name',
status: 'in_progress',
});
expect(updated.name).toBe('Updated Name');
expect(updated.status).toBe('in_progress');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
});
it('should throw for non-existent plan', async () => {
await expect(
planRepo.update('non-existent-id', { name: 'New Name' })
).rejects.toThrow('Plan not found');
});
});
describe('delete', () => {
it('should delete an existing plan', async () => {
const created = await planRepo.create({
phaseId: testPhaseId,
number: 1,
name: 'To Delete',
});
await planRepo.delete(created.id);
const found = await planRepo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent plan', async () => {
await expect(planRepo.delete('non-existent-id')).rejects.toThrow(
'Plan not found'
);
});
});
});

View File

@@ -24,17 +24,20 @@ export class DrizzlePlanRepository implements PlanRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreatePlanData): Promise<Plan> {
const id = nanoid();
const now = new Date();
const plan = {
id: nanoid(),
await this.db.insert(plans).values({
id,
...data,
status: data.status ?? 'pending',
createdAt: now,
updatedAt: now,
};
});
await this.db.insert(plans).values(plan);
return plan as Plan;
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
}
async findById(id: string): Promise<Plan | null> {

View File

@@ -0,0 +1,206 @@
/**
* DrizzleTaskRepository Tests
*
* Tests for the Task repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePlanRepository } from './plan.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleTaskRepository', () => {
let db: DrizzleDatabase;
let taskRepo: DrizzleTaskRepository;
let planRepo: DrizzlePlanRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testPlanId: string;
beforeEach(async () => {
db = createTestDatabase();
taskRepo = new DrizzleTaskRepository(db);
planRepo = new DrizzlePlanRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
// Create full hierarchy for FK constraint
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
number: 1,
name: 'Test Phase',
});
const plan = await planRepo.create({
phaseId: phase.id,
number: 1,
name: 'Test Plan',
});
testPlanId = plan.id;
});
describe('create', () => {
it('should create a task with generated id and timestamps', async () => {
const task = await taskRepo.create({
planId: testPlanId,
name: 'Test Task',
description: 'A test task',
order: 1,
});
expect(task.id).toBeDefined();
expect(task.id.length).toBeGreaterThan(0);
expect(task.planId).toBe(testPlanId);
expect(task.name).toBe('Test Task');
expect(task.type).toBe('auto');
expect(task.priority).toBe('medium');
expect(task.status).toBe('pending');
expect(task.order).toBe(1);
expect(task.createdAt).toBeInstanceOf(Date);
expect(task.updatedAt).toBeInstanceOf(Date);
});
it('should throw for invalid planId (FK constraint)', async () => {
await expect(
taskRepo.create({
planId: 'invalid-plan-id',
name: 'Invalid Task',
order: 1,
})
).rejects.toThrow();
});
it('should accept custom type and priority', async () => {
const task = await taskRepo.create({
planId: testPlanId,
name: 'Checkpoint Task',
type: 'checkpoint:human-verify',
priority: 'high',
order: 1,
});
expect(task.type).toBe('checkpoint:human-verify');
expect(task.priority).toBe('high');
});
});
describe('findById', () => {
it('should return null for non-existent task', async () => {
const result = await taskRepo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing task', async () => {
const created = await taskRepo.create({
planId: testPlanId,
name: 'Find Me',
order: 1,
});
const found = await taskRepo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('Find Me');
});
});
describe('findByPlanId', () => {
it('should return empty array for plan with no tasks', async () => {
const tasks = await taskRepo.findByPlanId(testPlanId);
expect(tasks).toEqual([]);
});
it('should return only matching tasks ordered by order field', async () => {
// Create tasks out of order
await taskRepo.create({
planId: testPlanId,
name: 'Task 3',
order: 3,
});
await taskRepo.create({
planId: testPlanId,
name: 'Task 1',
order: 1,
});
await taskRepo.create({
planId: testPlanId,
name: 'Task 2',
order: 2,
});
const tasks = await taskRepo.findByPlanId(testPlanId);
expect(tasks.length).toBe(3);
expect(tasks[0].name).toBe('Task 1');
expect(tasks[1].name).toBe('Task 2');
expect(tasks[2].name).toBe('Task 3');
});
});
describe('update', () => {
it('should update status correctly', async () => {
const created = await taskRepo.create({
planId: testPlanId,
name: 'Status Test',
status: 'pending',
order: 1,
});
const updated = await taskRepo.update(created.id, {
status: 'in_progress',
});
expect(updated.status).toBe('in_progress');
});
it('should update fields and updatedAt', async () => {
const created = await taskRepo.create({
planId: testPlanId,
name: 'Original Name',
order: 1,
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await taskRepo.update(created.id, {
name: 'Updated Name',
priority: 'low',
});
expect(updated.name).toBe('Updated Name');
expect(updated.priority).toBe('low');
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
});
it('should throw for non-existent task', async () => {
await expect(
taskRepo.update('non-existent-id', { name: 'New Name' })
).rejects.toThrow('Task not found');
});
});
describe('delete', () => {
it('should delete an existing task', async () => {
const created = await taskRepo.create({
planId: testPlanId,
name: 'To Delete',
order: 1,
});
await taskRepo.delete(created.id);
const found = await taskRepo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent task', async () => {
await expect(taskRepo.delete('non-existent-id')).rejects.toThrow(
'Task not found'
);
});
});
});

View File

@@ -24,17 +24,23 @@ export class DrizzleTaskRepository implements TaskRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateTaskData): Promise<Task> {
const id = nanoid();
const now = new Date();
const task = {
id: nanoid(),
await this.db.insert(tasks).values({
id,
...data,
type: data.type ?? 'auto',
priority: data.priority ?? 'medium',
status: data.status ?? 'pending',
order: data.order ?? 0,
createdAt: now,
updatedAt: now,
};
});
await this.db.insert(tasks).values(task);
return task as Task;
// Fetch to get the complete record with all defaults applied
const created = await this.findById(id);
return created!;
}
async findById(id: string): Promise<Task | null> {

View File

@@ -0,0 +1,89 @@
/**
* Test helpers for repository tests.
*
* Provides utilities for setting up in-memory test databases
* with schema applied.
*/
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import type { DrizzleDatabase } from '../../index.js';
import * as schema from '../../schema.js';
/**
* SQL statements to create the database schema.
* These mirror the schema defined in schema.ts.
*/
const CREATE_TABLES_SQL = `
-- Initiatives table
CREATE TABLE IF NOT EXISTS initiatives (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Phases table
CREATE TABLE IF NOT EXISTS phases (
id TEXT PRIMARY KEY NOT NULL,
initiative_id TEXT NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
number INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Plans table
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY NOT NULL,
phase_id TEXT NOT NULL REFERENCES phases(id) ON DELETE CASCADE,
number INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Tasks table
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'auto',
priority TEXT NOT NULL DEFAULT 'medium',
status TEXT NOT NULL DEFAULT 'pending',
"order" INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Task dependencies table
CREATE TABLE IF NOT EXISTS task_dependencies (
id TEXT PRIMARY KEY NOT NULL,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
depends_on_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
);
`;
/**
* Create an in-memory test database with schema applied.
* Returns a fresh Drizzle instance for each call.
*/
export function createTestDatabase(): DrizzleDatabase {
const sqlite = new Database(':memory:');
// Enable foreign keys
sqlite.pragma('foreign_keys = ON');
// Create all tables
sqlite.exec(CREATE_TABLES_SQL);
return drizzle(sqlite, { schema });
}