Files
Codewalkers/apps/server/db/repositories/drizzle/cascade.test.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
2026-03-03 11:22:53 +01:00

302 lines
11 KiB
TypeScript

/**
* Cascade Delete Tests
*
* Tests that cascade deletes work correctly through the repository layer.
* Verifies the SQLite foreign key cascade behavior configured in schema.ts.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { nanoid } from 'nanoid';
import { DrizzleInitiativeRepository } from './initiative.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePageRepository } from './page.js';
import { DrizzleProjectRepository } from './project.js';
import { DrizzleChangeSetRepository } from './change-set.js';
import { DrizzleAgentRepository } from './agent.js';
import { DrizzleConversationRepository } from './conversation.js';
import { createTestDatabase } from './test-helpers.js';
import { changeSets } from '../../schema.js';
import type { DrizzleDatabase } from '../../index.js';
describe('Cascade Deletes', () => {
let db: DrizzleDatabase;
let initiativeRepo: DrizzleInitiativeRepository;
let phaseRepo: DrizzlePhaseRepository;
let taskRepo: DrizzleTaskRepository;
let pageRepo: DrizzlePageRepository;
let projectRepo: DrizzleProjectRepository;
let changeSetRepo: DrizzleChangeSetRepository;
let agentRepo: DrizzleAgentRepository;
let conversationRepo: DrizzleConversationRepository;
beforeEach(() => {
db = createTestDatabase();
initiativeRepo = new DrizzleInitiativeRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
taskRepo = new DrizzleTaskRepository(db);
pageRepo = new DrizzlePageRepository(db);
projectRepo = new DrizzleProjectRepository(db);
changeSetRepo = new DrizzleChangeSetRepository(db);
agentRepo = new DrizzleAgentRepository(db);
conversationRepo = new DrizzleConversationRepository(db);
});
/**
* Helper to create a full hierarchy for testing.
* Uses parent tasks (detail category) to group child tasks.
*/
async function createFullHierarchy() {
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
const phase1 = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Phase 2',
});
// Create parent (detail) tasks that group child tasks
const parentTask1 = await taskRepo.create({
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Parent Task 1-1',
category: 'detail',
order: 1,
});
const parentTask2 = await taskRepo.create({
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Parent Task 1-2',
category: 'detail',
order: 2,
});
const parentTask3 = await taskRepo.create({
phaseId: phase2.id,
initiativeId: initiative.id,
name: 'Parent Task 2-1',
category: 'detail',
order: 1,
});
// Create child tasks under parent tasks
const task1 = await taskRepo.create({
parentTaskId: parentTask1.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-1-1',
order: 1,
});
const task2 = await taskRepo.create({
parentTaskId: parentTask1.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-1-2',
order: 2,
});
const task3 = await taskRepo.create({
parentTaskId: parentTask2.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-2-1',
order: 1,
});
const task4 = await taskRepo.create({
parentTaskId: parentTask3.id,
phaseId: phase2.id,
initiativeId: initiative.id,
name: 'Task 2-1-1',
order: 1,
});
// Create a page for the initiative
const page = await pageRepo.create({
initiativeId: initiative.id,
parentPageId: null,
title: 'Root Page',
content: null,
sortOrder: 0,
});
// Create a project and link it via junction table
const project = await projectRepo.create({
name: 'test-project',
url: 'https://github.com/test/test-project.git',
});
await projectRepo.setInitiativeProjects(initiative.id, [project.id]);
// Create two agents (need two for conversations, and one for changeSet FK)
const agent1 = await agentRepo.create({
name: 'agent-1',
worktreeId: 'wt-1',
initiativeId: initiative.id,
});
const agent2 = await agentRepo.create({
name: 'agent-2',
worktreeId: 'wt-2',
initiativeId: initiative.id,
});
// Insert change set directly (createWithEntries uses async tx, incompatible with better-sqlite3 sync driver)
const changeSetId = nanoid();
await db.insert(changeSets).values({
id: changeSetId,
agentId: agent1.id,
agentName: agent1.name,
initiativeId: initiative.id,
mode: 'plan',
status: 'applied',
createdAt: new Date(),
});
const changeSet = (await changeSetRepo.findById(changeSetId))!;
// Create a conversation between agents with initiative context
const conversation = await conversationRepo.create({
fromAgentId: agent1.id,
toAgentId: agent2.id,
initiativeId: initiative.id,
question: 'Test question',
});
return {
initiative,
phases: { phase1, phase2 },
parentTasks: { parentTask1, parentTask2, parentTask3 },
tasks: { task1, task2, task3, task4 },
page,
project,
changeSet,
agents: { agent1, agent2 },
conversation,
};
}
describe('delete initiative', () => {
it('should cascade delete all phases, tasks, pages, junction rows, and change sets', async () => {
const { initiative, phases, parentTasks, tasks, page, project, changeSet } =
await createFullHierarchy();
// Verify everything exists
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
expect(await phaseRepo.findById(phases.phase1.id)).not.toBeNull();
expect(await phaseRepo.findById(phases.phase2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
expect(await pageRepo.findById(page.id)).not.toBeNull();
expect(await changeSetRepo.findById(changeSet.id)).not.toBeNull();
const linkedProjects = await projectRepo.findProjectsByInitiativeId(initiative.id);
expect(linkedProjects).toHaveLength(1);
// Delete initiative
await initiativeRepo.delete(initiative.id);
// Verify cascade deletes — all gone
expect(await initiativeRepo.findById(initiative.id)).toBeNull();
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
expect(await phaseRepo.findById(phases.phase2.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).toBeNull();
expect(await pageRepo.findById(page.id)).toBeNull();
expect(await changeSetRepo.findById(changeSet.id)).toBeNull();
// Junction row gone but project itself survives
const remainingLinks = await projectRepo.findProjectsByInitiativeId(initiative.id);
expect(remainingLinks).toHaveLength(0);
expect(await projectRepo.findById(project.id)).not.toBeNull();
});
it('should set null on agents and conversations (not cascade)', async () => {
const { initiative, agents, conversation } = await createFullHierarchy();
// Verify agents are linked
const a1Before = await agentRepo.findById(agents.agent1.id);
expect(a1Before!.initiativeId).toBe(initiative.id);
// Delete initiative
await initiativeRepo.delete(initiative.id);
// Agents survive with initiativeId set to null
const a1After = await agentRepo.findById(agents.agent1.id);
expect(a1After).not.toBeNull();
expect(a1After!.initiativeId).toBeNull();
const a2After = await agentRepo.findById(agents.agent2.id);
expect(a2After).not.toBeNull();
expect(a2After!.initiativeId).toBeNull();
// Conversation survives with initiativeId set to null
const convAfter = await conversationRepo.findById(conversation.id);
expect(convAfter).not.toBeNull();
expect(convAfter!.initiativeId).toBeNull();
});
});
describe('delete phase', () => {
it('should cascade delete tasks under that phase only', async () => {
const { initiative, phases, parentTasks, tasks } = await createFullHierarchy();
// Delete phase 1
await phaseRepo.delete(phases.phase1.id);
// Initiative still exists
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
// Phase 1 and its tasks are gone
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
// Phase 2 and its tasks still exist
expect(await phaseRepo.findById(phases.phase2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
});
});
describe('delete parent task', () => {
it('should cascade delete child tasks under that parent only', async () => {
const { phases, parentTasks, tasks } = await createFullHierarchy();
// Delete parent task 1
await taskRepo.delete(parentTasks.parentTask1.id);
// Phase still exists
expect(await phaseRepo.findById(phases.phase1.id)).not.toBeNull();
// Parent task 1 and its children are gone
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
// Other parent tasks and their children still exist
expect(await taskRepo.findById(parentTasks.parentTask2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
});
});
});