diff options
Diffstat (limited to 'packages/trpc')
| -rw-r--r-- | packages/trpc/models/lists.ts | 35 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.test.ts | 361 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.ts | 7 |
3 files changed, 392 insertions, 11 deletions
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index c0e17bfc..48da5ed1 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -215,6 +215,41 @@ export abstract class List implements PrivacyAware { } } + async getChildren(): Promise<(ManualList | SmartList)[]> { + const lists = await List.getAll(this.ctx); + const listById = new Map(lists.map((l) => [l.list.id, l])); + + const adjecencyList = new Map<string, string[]>(); + + // Initialize all lists with empty arrays first + lists.forEach((l) => { + adjecencyList.set(l.list.id, []); + }); + + // Then populate the parent-child relationships + lists.forEach((l) => { + if (l.list.parentId) { + const currentChildren = adjecencyList.get(l.list.parentId) ?? []; + currentChildren.push(l.list.id); + adjecencyList.set(l.list.parentId, currentChildren); + } + }); + + const resultIds: string[] = []; + const queue: string[] = [this.list.id]; + + while (queue.length > 0) { + const id = queue.pop()!; + const children = adjecencyList.get(id) ?? []; + children.forEach((childId) => { + queue.push(childId); + resultIds.push(childId); + }); + } + + return resultIds.map((id) => listById.get(id)!); + } + async update( input: z.infer<typeof zEditBookmarkListSchemaWithValidation>, ): Promise<void> { diff --git a/packages/trpc/routers/lists.test.ts b/packages/trpc/routers/lists.test.ts index 9863fb38..8797b35e 100644 --- a/packages/trpc/routers/lists.test.ts +++ b/packages/trpc/routers/lists.test.ts @@ -10,19 +10,18 @@ import { zNewBookmarkListSchema } from "@karakeep/shared/types/lists"; import type { APICallerType, CustomTestContext } from "../testUtils"; import { defaultBeforeEach } from "../testUtils"; +async function createTestBookmark(api: APICallerType) { + const newBookmarkInput: z.infer<typeof zNewBookmarkRequestSchema> = { + type: BookmarkTypes.TEXT, + text: "Test bookmark text", + }; + const createdBookmark = await api.bookmarks.createBookmark(newBookmarkInput); + return createdBookmark.id; +} + beforeEach<CustomTestContext>(defaultBeforeEach(true)); describe("Lists Routes", () => { - async function createTestBookmark(api: APICallerType) { - const newBookmarkInput: z.infer<typeof zNewBookmarkRequestSchema> = { - type: BookmarkTypes.TEXT, - text: "Test bookmark text", - }; - const createdBookmark = - await api.bookmarks.createBookmark(newBookmarkInput); - return createdBookmark.id; - } - test<CustomTestContext>("create list", async ({ apiCallers }) => { const api = apiCallers[0].lists; const newListInput: z.infer<typeof zNewBookmarkListSchema> = { @@ -253,3 +252,345 @@ describe("Lists Routes", () => { expect(stats.stats.get(createdList.id)).toBeGreaterThan(0); }); }); + +describe("recursive delete", () => { + test<CustomTestContext>("non-recursive delete (deleteChildren=false)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].lists; + + // Create parent list + const parentInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Parent List", + type: "manual", + icon: "📂", + }; + const parentList = await api.create(parentInput); + + // Create child list + const childInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Child List", + parentId: parentList.id, + type: "manual", + icon: "📄", + }; + const childList = await api.create(childInput); + + // Test both default behavior and explicit false + // Default (should be false) + await api.delete({ listId: parentList.id }); + + let lists = await api.list(); + expect(lists.lists.find((l) => l.id === parentList.id)).toBeUndefined(); + let remainingChild = lists.lists.find((l) => l.id === childList.id); + expect(remainingChild).toBeDefined(); + expect(remainingChild?.parentId).toBeNull(); + + // Create another parent-child pair to test explicit false + const parent2 = await api.create({ + name: "Parent List 2", + type: "manual", + icon: "📂", + }); + const child2 = await api.create({ + name: "Child List 2", + parentId: parent2.id, + type: "manual", + icon: "📄", + }); + + // Explicit deleteChildren=false + await api.delete({ listId: parent2.id, deleteChildren: false }); + + lists = await api.list(); + expect(lists.lists.find((l) => l.id === parent2.id)).toBeUndefined(); + remainingChild = lists.lists.find((l) => l.id === child2.id); + expect(remainingChild).toBeDefined(); + expect(remainingChild?.parentId).toBeNull(); + }); + + test<CustomTestContext>("recursive delete with multiple children", async ({ + apiCallers, + }) => { + const api = apiCallers[0].lists; + + // Create parent list + const parentInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Parent List", + type: "manual", + icon: "📂", + }; + const parentList = await api.create(parentInput); + + // Create multiple child lists + const child1Input: z.infer<typeof zNewBookmarkListSchema> = { + name: "Child List 1", + parentId: parentList.id, + type: "manual", + icon: "📄", + }; + const child1 = await api.create(child1Input); + + const child2Input: z.infer<typeof zNewBookmarkListSchema> = { + name: "Child List 2", + parentId: parentList.id, + type: "manual", + icon: "📄", + }; + const child2 = await api.create(child2Input); + + const child3Input: z.infer<typeof zNewBookmarkListSchema> = { + name: "Child List 3", + parentId: parentList.id, + type: "smart", + query: "is:fav", + icon: "⭐", + }; + const child3 = await api.create(child3Input); + + // Delete parent with deleteChildren=true + await api.delete({ listId: parentList.id, deleteChildren: true }); + + // Verify all lists are deleted + const lists = await api.list(); + expect(lists.lists.find((l) => l.id === parentList.id)).toBeUndefined(); + expect(lists.lists.find((l) => l.id === child1.id)).toBeUndefined(); + expect(lists.lists.find((l) => l.id === child2.id)).toBeUndefined(); + expect(lists.lists.find((l) => l.id === child3.id)).toBeUndefined(); + }); + + test<CustomTestContext>("recursive delete preserves bookmarks in deleted lists", async ({ + apiCallers, + }) => { + const api = apiCallers[0].lists; + + // Create a bookmark first + const bookmarkId = await createTestBookmark(apiCallers[0]); + + // Create parent list + const parentInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Parent List", + type: "manual", + icon: "📂", + }; + const parentList = await api.create(parentInput); + + // Create child list with bookmark + const childInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Child List", + parentId: parentList.id, + type: "manual", + icon: "📄", + }; + const childList = await api.create(childInput); + + // Add bookmark to child list + await api.addToList({ listId: childList.id, bookmarkId }); + + // Verify bookmark is in the list + const listsBeforeDelete = await api.getListsOfBookmark({ bookmarkId }); + expect( + listsBeforeDelete.lists.find((l) => l.id === childList.id), + ).toBeDefined(); + + // Delete parent with deleteChildren=true + await api.delete({ listId: parentList.id, deleteChildren: true }); + + // Verify lists are deleted + const allLists = await api.list(); + expect(allLists.lists.find((l) => l.id === parentList.id)).toBeUndefined(); + expect(allLists.lists.find((l) => l.id === childList.id)).toBeUndefined(); + + // Verify bookmark still exists but is not in any list + const listsAfterDelete = await api.getListsOfBookmark({ bookmarkId }); + expect(listsAfterDelete.lists).toHaveLength(0); + + // Verify the bookmark itself still exists by trying to access it + const bookmark = await apiCallers[0].bookmarks.getBookmark({ + bookmarkId, + }); + expect(bookmark).toBeDefined(); + expect(bookmark.id).toBe(bookmarkId); + }); + + test<CustomTestContext>("recursive delete with complex hierarchy", async ({ + apiCallers, + }) => { + const api = apiCallers[0].lists; + + // Create a complex tree structure: + // root + // / | \ + // A B C + // /| | |\ + // D E F G H + // | + // I + + const root = await api.create({ + name: "Root", + type: "manual", + icon: "🌳", + }); + + const listA = await api.create({ + name: "List A", + parentId: root.id, + type: "manual", + icon: "📂", + }); + + const listB = await api.create({ + name: "List B", + parentId: root.id, + type: "smart", + query: "is:fav", + icon: "📂", + }); + + const listC = await api.create({ + name: "List C", + parentId: root.id, + type: "manual", + icon: "📂", + }); + + const listD = await api.create({ + name: "List D", + parentId: listA.id, + type: "manual", + icon: "📄", + }); + + const listE = await api.create({ + name: "List E", + parentId: listA.id, + type: "smart", + query: "is:archived", + icon: "📄", + }); + + const listF = await api.create({ + name: "List F", + parentId: listB.id, + type: "manual", + icon: "📄", + }); + + const listG = await api.create({ + name: "List G", + parentId: listC.id, + type: "manual", + icon: "📄", + }); + + const listH = await api.create({ + name: "List H", + parentId: listC.id, + type: "smart", + query: "is:fav", + icon: "📄", + }); + + const listI = await api.create({ + name: "List I", + parentId: listF.id, + type: "manual", + icon: "📄", + }); + + const allCreatedIds = [ + root.id, + listA.id, + listB.id, + listC.id, + listD.id, + listE.id, + listF.id, + listG.id, + listH.id, + listI.id, + ]; + + // Delete root with deleteChildren=true + await api.delete({ listId: root.id, deleteChildren: true }); + + // Verify entire tree is deleted + const remainingLists = await api.list(); + allCreatedIds.forEach((id) => { + expect(remainingLists.lists.find((l) => l.id === id)).toBeUndefined(); + }); + }); + + test<CustomTestContext>("recursive delete edge cases", async ({ + apiCallers, + }) => { + const api = apiCallers[0].lists; + + // Test 1: Delete list with no children (should work fine) + const standaloneList = await api.create({ + name: "Standalone List", + type: "manual", + icon: "📄", + }); + + await api.delete({ listId: standaloneList.id, deleteChildren: true }); + let lists = await api.list(); + expect(lists.lists.find((l) => l.id === standaloneList.id)).toBeUndefined(); + + // Test 2: Delete child directly (no recursion needed) + const parent = await api.create({ + name: "Parent", + type: "manual", + icon: "📂", + }); + + const child = await api.create({ + name: "Child", + parentId: parent.id, + type: "manual", + icon: "📄", + }); + + await api.delete({ listId: child.id, deleteChildren: true }); + lists = await api.list(); + expect(lists.lists.find((l) => l.id === parent.id)).toBeDefined(); + expect(lists.lists.find((l) => l.id === child.id)).toBeUndefined(); + }); + + test<CustomTestContext>("partial recursive delete on middle node", async ({ + apiCallers, + }) => { + const api = apiCallers[0].lists; + + // Create hierarchy: grandparent -> parent -> child + const grandparent = await api.create({ + name: "Grandparent", + type: "manual", + icon: "📂", + }); + + const parent = await api.create({ + name: "Parent", + parentId: grandparent.id, + type: "manual", + icon: "📂", + }); + + const child = await api.create({ + name: "Child", + parentId: parent.id, + type: "manual", + icon: "📄", + }); + + // Delete middle node (parent) with deleteChildren=true + await api.delete({ listId: parent.id, deleteChildren: true }); + + // Verify parent and child are deleted, but grandparent remains + const lists = await api.list(); + expect(lists.lists.find((l) => l.id === grandparent.id)).toBeDefined(); + expect(lists.lists.find((l) => l.id === parent.id)).toBeUndefined(); + expect(lists.lists.find((l) => l.id === child.id)).toBeUndefined(); + }); +}); diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index 92392448..7118c608 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -57,10 +57,15 @@ export const listsAppRouter = router({ .input( z.object({ listId: z.string(), + deleteChildren: z.boolean().optional().default(false), }), ) .use(ensureListOwnership) - .mutation(async ({ ctx }) => { + .mutation(async ({ ctx, input }) => { + if (input.deleteChildren) { + const children = await ctx.list.getChildren(); + await Promise.all(children.map((l) => l.delete())); + } await ctx.list.delete(); }), addToList: authedProcedure |
