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
302 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|