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