From b3d3602dc5af6768e5b03613a7ca22ad3b47ec8d Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 9 Feb 2026 01:03:29 +0000 Subject: fix: Support nested smart lists with cycle detection (#2470) * fix: Support nested smart lists and prevent infinite loops --------- Co-authored-by: Claude --- packages/trpc/routers/lists.test.ts | 382 ++++++++++++++++++++++++++++++++++++ 1 file changed, 382 insertions(+) (limited to 'packages/trpc/routers/lists.test.ts') diff --git a/packages/trpc/routers/lists.test.ts b/packages/trpc/routers/lists.test.ts index 8797b35e..214df32a 100644 --- a/packages/trpc/routers/lists.test.ts +++ b/packages/trpc/routers/lists.test.ts @@ -594,3 +594,385 @@ describe("recursive delete", () => { expect(lists.lists.find((l) => l.id === child.id)).toBeUndefined(); }); }); + +describe("Nested smart lists", () => { + test("smart list can reference another smart list", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + // Create a bookmark that is favourited + const bookmark1 = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Favourited bookmark", + }); + await api.bookmarks.updateBookmark({ + bookmarkId: bookmark1.id, + favourited: true, + }); + + // Create a bookmark that is not favourited + const bookmark2 = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Non-favourited bookmark", + }); + + // Create a smart list that matches favourited bookmarks + await api.lists.create({ + name: "Favourites", + type: "smart", + query: "is:fav", + icon: "⭐", + }); + + // Create a smart list that references the first smart list + const smartListB = await api.lists.create({ + name: "From Favourites", + type: "smart", + query: "list:Favourites", + icon: "📋", + }); + + // Get bookmarks from the nested smart list + const bookmarksInSmartListB = await api.bookmarks.getBookmarks({ + listId: smartListB.id, + }); + + // Should contain the favourited bookmark + expect(bookmarksInSmartListB.bookmarks.length).toBe(1); + expect(bookmarksInSmartListB.bookmarks[0].id).toBe(bookmark1.id); + + // Verify bookmark2 is not in the nested smart list + expect( + bookmarksInSmartListB.bookmarks.find((b) => b.id === bookmark2.id), + ).toBeUndefined(); + }); + + test("nested smart lists with multiple levels", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + // Create a bookmark that is archived + const bookmark = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Archived bookmark", + }); + await api.bookmarks.updateBookmark({ + bookmarkId: bookmark.id, + archived: true, + }); + + // Create smart list A: matches archived bookmarks + await api.lists.create({ + name: "Archived", + type: "smart", + query: "is:archived", + icon: "đŸ“Ļ", + }); + + // Create smart list B: references list A + await api.lists.create({ + name: "Level1", + type: "smart", + query: "list:Archived", + icon: "1ī¸âƒŖ", + }); + + // Create smart list C: references list B (3 levels deep) + const smartListC = await api.lists.create({ + name: "Level2", + type: "smart", + query: "list:Level1", + icon: "2ī¸âƒŖ", + }); + + // Get bookmarks from the deepest nested smart list + const bookmarksInSmartListC = await api.bookmarks.getBookmarks({ + listId: smartListC.id, + }); + + // Should contain the archived bookmark + expect(bookmarksInSmartListC.bookmarks.length).toBe(1); + expect(bookmarksInSmartListC.bookmarks[0].id).toBe(bookmark.id); + }); + + test("smart list with inverse reference to another smart list", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + // Create two bookmarks + const favouritedBookmark = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Favourited bookmark", + }); + await api.bookmarks.updateBookmark({ + bookmarkId: favouritedBookmark.id, + favourited: true, + }); + + const normalBookmark = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Normal bookmark", + }); + + // Create a smart list that matches favourited bookmarks + await api.lists.create({ + name: "Favourites", + type: "smart", + query: "is:fav", + icon: "⭐", + }); + + // Create a smart list with negative reference to Favourites + const notInFavourites = await api.lists.create({ + name: "Not In Favourites", + type: "smart", + query: "-list:Favourites", + icon: "❌", + }); + + // Get bookmarks from the smart list + const bookmarksNotInFav = await api.bookmarks.getBookmarks({ + listId: notInFavourites.id, + }); + + // Should contain only the non-favourited bookmark + expect(bookmarksNotInFav.bookmarks.length).toBe(1); + expect(bookmarksNotInFav.bookmarks[0].id).toBe(normalBookmark.id); + }); + + test("circular reference between smart lists returns empty", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + // Create a bookmark + const bookmark = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Test bookmark", + }); + await api.bookmarks.updateBookmark({ + bookmarkId: bookmark.id, + favourited: true, + }); + + // Create smart list A that references smart list B + const smartListA = await api.lists.create({ + name: "ListA", + type: "smart", + query: "list:ListB", + icon: "đŸ…°ī¸", + }); + + // Create smart list B that references smart list A (circular!) + await api.lists.create({ + name: "ListB", + type: "smart", + query: "list:ListA", + icon: "đŸ…ąī¸", + }); + + // Querying ListA should return empty because of the circular reference + const bookmarksInListA = await api.bookmarks.getBookmarks({ + listId: smartListA.id, + }); + + // Should be empty due to circular reference detection + expect(bookmarksInListA.bookmarks.length).toBe(0); + }); + + test("self-referencing smart list returns empty", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + // Create a bookmark + await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Test bookmark", + }); + + // Create a smart list that references itself + const selfRefList = await api.lists.create({ + name: "SelfRef", + type: "smart", + query: "list:SelfRef", + icon: "🔄", + }); + + // Querying should return empty because of self-reference + const bookmarks = await api.bookmarks.getBookmarks({ + listId: selfRefList.id, + }); + + expect(bookmarks.bookmarks.length).toBe(0); + }); + + test("three-way circular reference returns empty", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + // Create a bookmark + await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Test bookmark", + }); + + // Create three smart lists with circular references: A -> B -> C -> A + const listA = await api.lists.create({ + name: "CircularA", + type: "smart", + query: "list:CircularB", + icon: "đŸ…°ī¸", + }); + + await api.lists.create({ + name: "CircularB", + type: "smart", + query: "list:CircularC", + icon: "đŸ…ąī¸", + }); + + await api.lists.create({ + name: "CircularC", + type: "smart", + query: "list:CircularA", + icon: "ÂŠī¸", + }); + + // Querying any of them should return empty due to circular reference + const bookmarksInListA = await api.bookmarks.getBookmarks({ + listId: listA.id, + }); + + expect(bookmarksInListA.bookmarks.length).toBe(0); + }); + + test("smart list traversal above max visited lists returns empty", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + const bookmark = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Depth test bookmark", + }); + + const manualList = await api.lists.create({ + name: "DepthBaseManual", + type: "manual", + icon: "📋", + }); + await api.lists.addToList({ + listId: manualList.id, + bookmarkId: bookmark.id, + }); + + const maxVisitedLists = 30; + const overLimitChainLength = maxVisitedLists + 1; + + for (let i = overLimitChainLength; i >= 2; i--) { + await api.lists.create({ + name: `DepthL${i}`, + type: "smart", + query: + i === overLimitChainLength + ? "list:DepthBaseManual" + : `list:DepthL${i + 1}`, + icon: "D", + }); + } + + const depthRoot = await api.lists.create({ + name: "DepthL1", + type: "smart", + query: "list:DepthL2", + icon: "D", + }); + + const bookmarksInRoot = await api.bookmarks.getBookmarks({ + listId: depthRoot.id, + }); + + expect(bookmarksInRoot.bookmarks.length).toBe(0); + }); + + test("smart list references non-existent list returns empty", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + // Create a bookmark + await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Test bookmark", + }); + + // Create a smart list that references a non-existent list + const smartList = await api.lists.create({ + name: "RefNonExistent", + type: "smart", + query: "list:NonExistentList", + icon: "❓", + }); + + // Should return empty since the referenced list doesn't exist + const bookmarks = await api.bookmarks.getBookmarks({ + listId: smartList.id, + }); + + expect(bookmarks.bookmarks.length).toBe(0); + }); + + test("smart list can reference manual list", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + // Create bookmarks + const bookmark1 = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Bookmark in manual list", + }); + const bookmark2 = await api.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Bookmark not in list", + }); + + // Create a manual list and add bookmark1 + const manualList = await api.lists.create({ + name: "ManualList", + type: "manual", + icon: "📋", + }); + await api.lists.addToList({ + listId: manualList.id, + bookmarkId: bookmark1.id, + }); + + // Create a smart list that references the manual list + const smartList = await api.lists.create({ + name: "SmartRefManual", + type: "smart", + query: "list:ManualList", + icon: "🔗", + }); + + // Get bookmarks from the smart list + const bookmarksInSmartList = await api.bookmarks.getBookmarks({ + listId: smartList.id, + }); + + // Should contain only bookmark1 + expect(bookmarksInSmartList.bookmarks.length).toBe(1); + expect(bookmarksInSmartList.bookmarks[0].id).toBe(bookmark1.id); + + // Verify bookmark2 is not in the smart list + expect( + bookmarksInSmartList.bookmarks.find((b) => b.id === bookmark2.id), + ).toBeUndefined(); + }); +}); -- cgit v1.2.3-70-g09d2