Skip to content

Commit 54d59ec

Browse files
OUT-4029 | Add tests for workspace-scoped task deletion
- Unit: softDeleteAllSubtasks and deleteAllAssigneeTasks delete by id/assignee scoped to workspaceId and never touch Labels rows - Integration: two workspaces sharing a label string; deleting one workspace's tree soft-deletes only its own subtree and leaves the other untouched - seedTask: optional label so integration tests can seed colliding labels Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c76c523 commit 54d59ec

4 files changed

Lines changed: 158 additions & 1 deletion

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { buildLtree } from '@/utils/ltree'
2+
import User from '@api/core/models/User.model'
3+
import { SubtaskService } from '@api/tasks/subtasks.service'
4+
5+
import { disconnectTestDb, getTestDb, seedTask, truncateAll, uuid } from '../../../../test/integration/db'
6+
7+
// Real DB; Copilot is doubled only so the service constructs without a live SDK client.
8+
jest.mock('@/utils/CopilotAPI', () => ({
9+
CopilotAPI: jest.fn().mockImplementation(() => ({})),
10+
}))
11+
12+
const makeUser = (workspaceId: string) => new User('token', { internalUserId: uuid(), workspaceId } as never)
13+
14+
// seedTask doesn't set the ltree path; mirror the app's addPathToTask here.
15+
const setPath = async (id: string, ...pathIds: string[]) => {
16+
const path = buildLtree(...pathIds)
17+
await getTestDb().$executeRaw`UPDATE "Tasks" SET path = ${path}::ltree WHERE id::text = ${id}`
18+
}
19+
20+
describe('SubtaskService.softDeleteAllSubtasks (integration)', () => {
21+
beforeEach(truncateAll)
22+
afterAll(disconnectTestDb)
23+
24+
// Reproduces OUT-4029: colliding label strings across workspaces must not cause cross-workspace deletes.
25+
it('deletes only the acting workspace subtree when a descendant shares a label string with another workspace', async () => {
26+
const workspaceA = 'ws-a'
27+
const workspaceB = 'ws-b'
28+
const sharedLabel = 'THE10-001'
29+
30+
// Root is soft-deleted before softDeleteAllSubtasks runs (as in the real call sites), so
31+
// the query returns only live descendants — the collision is on childA.
32+
const rootA = await seedTask({ workspaceId: workspaceA, label: 'THE10-000', deletedAt: new Date() })
33+
const childA = await seedTask({ workspaceId: workspaceA, parentId: rootA, label: sharedLabel })
34+
await setPath(rootA, rootA)
35+
await setPath(childA, rootA, childA)
36+
37+
// Different workspace, different id, but the SAME label string as descendant childA.
38+
const taskB = await seedTask({ workspaceId: workspaceB, label: sharedLabel })
39+
await setPath(taskB, taskB)
40+
41+
await new SubtaskService(makeUser(workspaceA)).softDeleteAllSubtasks(rootA)
42+
43+
const db = getTestDb()
44+
const [ca, b] = await Promise.all([
45+
db.task.findUnique({ where: { id: childA }, select: { deletedAt: true } }),
46+
db.task.findUnique({ where: { id: taskB }, select: { deletedAt: true } }),
47+
])
48+
49+
expect(ca?.deletedAt).not.toBeNull()
50+
// The colliding-label task in the other workspace must survive.
51+
expect(b?.deletedAt).toBeNull()
52+
})
53+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const mockQueryRawUnsafe = jest.fn()
2+
const mockTaskDeleteMany = jest.fn()
3+
const mockLabelDeleteMany = jest.fn()
4+
5+
jest.mock('@/lib/db', () => ({
6+
__esModule: true,
7+
default: {
8+
getInstance: () => ({
9+
$queryRawUnsafe: mockQueryRawUnsafe,
10+
task: { deleteMany: mockTaskDeleteMany },
11+
label: { deleteMany: mockLabelDeleteMany },
12+
}),
13+
},
14+
}))
15+
16+
jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: jest.fn() }))
17+
18+
import { SubtaskService } from '@api/tasks/subtasks.service'
19+
import User from '@api/core/models/User.model'
20+
import { UserRole } from '@api/core/types/user'
21+
22+
const user = {
23+
workspaceId: 'ws-1',
24+
role: UserRole.IU,
25+
internalUserId: 'iu-1',
26+
token: 'token',
27+
} as unknown as User
28+
29+
describe('SubtaskService#softDeleteAllSubtasks', () => {
30+
beforeEach(() => jest.clearAllMocks())
31+
32+
it('deletes descendant tasks by id scoped to the workspace', async () => {
33+
mockQueryRawUnsafe.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }])
34+
35+
await new SubtaskService(user).softDeleteAllSubtasks('parent-id')
36+
37+
expect(mockTaskDeleteMany).toHaveBeenCalledWith({
38+
where: { id: { in: ['task-a', 'task-b'] }, workspaceId: 'ws-1' },
39+
})
40+
})
41+
42+
it('never deletes Labels registry rows', async () => {
43+
mockQueryRawUnsafe.mockResolvedValue([{ id: 'task-a' }])
44+
45+
await new SubtaskService(user).softDeleteAllSubtasks('parent-id')
46+
47+
expect(mockLabelDeleteMany).not.toHaveBeenCalled()
48+
})
49+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const mockTaskFindMany = jest.fn()
2+
const mockTaskDeleteMany = jest.fn()
3+
const mockLabelDeleteMany = jest.fn()
4+
5+
jest.mock('@/lib/db', () => ({
6+
__esModule: true,
7+
default: {
8+
getInstance: () => ({
9+
task: { findMany: mockTaskFindMany, deleteMany: mockTaskDeleteMany },
10+
label: { deleteMany: mockLabelDeleteMany },
11+
}),
12+
},
13+
}))
14+
15+
jest.mock('@/utils/CopilotAPI', () => ({
16+
__esModule: true,
17+
CopilotAPI: jest.fn().mockImplementation(() => ({})),
18+
}))
19+
20+
import User from '@api/core/models/User.model'
21+
import { TasksService } from '@api/tasks/tasks.service'
22+
import { AssigneeType } from '@prisma/client'
23+
24+
const makeUser = () =>
25+
new User('token', {
26+
internalUserId: 'iu-1',
27+
workspaceId: 'ws-1',
28+
} as never)
29+
30+
describe('TasksService.deleteAllAssigneeTasks', () => {
31+
beforeEach(() => {
32+
mockTaskFindMany.mockReset()
33+
mockTaskDeleteMany.mockReset()
34+
mockLabelDeleteMany.mockReset()
35+
})
36+
37+
it('deletes the assignee tasks scoped to the workspace', async () => {
38+
mockTaskFindMany.mockResolvedValue([{ id: 'task-a', label: 'THE10-001' }])
39+
40+
await new TasksService(makeUser()).deleteAllAssigneeTasks('assignee-1', AssigneeType.client)
41+
42+
expect(mockTaskDeleteMany).toHaveBeenCalledWith({
43+
where: { assigneeId: 'assignee-1', assigneeType: AssigneeType.client, workspaceId: 'ws-1' },
44+
})
45+
})
46+
47+
it('never deletes Labels registry rows', async () => {
48+
mockTaskFindMany.mockResolvedValue([{ id: 'task-a', label: 'THE10-001' }])
49+
50+
await new TasksService(makeUser()).deleteAllAssigneeTasks('assignee-1', AssigneeType.client)
51+
52+
expect(mockLabelDeleteMany).not.toHaveBeenCalled()
53+
})
54+
})

test/integration/db.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export type SeedTaskInput = {
7575
parentId?: string | null
7676
title?: string
7777
createdById?: string
78+
label?: string
7879
}
7980

8081
// The Tasks table has an `assignee_to_user_id_mapping` CHECK that ties assigneeType to which
@@ -101,7 +102,7 @@ export const seedTask = async (input: SeedTaskInput): Promise<string> => {
101102
await getTestDb().task.create({
102103
data: {
103104
id,
104-
label: `T-${id.slice(0, 8)}`,
105+
label: input.label ?? `T-${id.slice(0, 8)}`,
105106
title: input.title ?? 'Reminder task',
106107
workspaceId: input.workspaceId,
107108
createdById: input.createdById ?? uuid(),

0 commit comments

Comments
 (0)