diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-04-16 19:52:01 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-04-16 23:50:17 +0000 |
| commit | 1d780485d731c077009fc76d5fa0e283f6f78d85 (patch) | |
| tree | e3f95e0c23d5eedc459cd0358fe3b0d6d926b79d /packages | |
| parent | 812354deaa96c37b5a06cfd7cb98df35f8202aa9 (diff) | |
| download | karakeep-1d780485d731c077009fc76d5fa0e283f6f78d85.tar.zst | |
tests: Add tests for various trpc endpoints
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/db/drizzle.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.test.ts | 150 | ||||
| -rw-r--r-- | packages/trpc/routers/highlights.test.ts | 253 | ||||
| -rw-r--r-- | packages/trpc/routers/highlights.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.test.ts | 255 | ||||
| -rw-r--r-- | packages/trpc/routers/prompts.test.ts | 141 | ||||
| -rw-r--r-- | packages/trpc/routers/tags.test.ts | 146 | ||||
| -rw-r--r-- | packages/trpc/routers/tags.ts | 36 | ||||
| -rw-r--r-- | packages/trpc/routers/webhooks.test.ts | 128 |
9 files changed, 1108 insertions, 5 deletions
diff --git a/packages/db/drizzle.ts b/packages/db/drizzle.ts index 5c441bec..843b21cc 100644 --- a/packages/db/drizzle.ts +++ b/packages/db/drizzle.ts @@ -13,7 +13,7 @@ export type DB = typeof db; export function getInMemoryDB(runMigrations: boolean) { const mem = new Database(":memory:"); - const db = drizzle(mem, { schema, logger: true }); + const db = drizzle(mem, { schema, logger: false }); if (runMigrations) { migrate(db, { migrationsFolder: path.resolve(__dirname, "./drizzle") }); } diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index f83ec5aa..e2179542 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -1,14 +1,34 @@ +import { eq } from "drizzle-orm"; import { assert, beforeEach, describe, expect, test } from "vitest"; -import { bookmarks } from "@karakeep/db/schema"; +import { + bookmarkLinks, + bookmarks, + rssFeedImportsTable, +} from "@karakeep/db/schema"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -import type { CustomTestContext } from "../testUtils"; +import type { APICallerType, CustomTestContext } from "../testUtils"; import { defaultBeforeEach } from "../testUtils"; beforeEach<CustomTestContext>(defaultBeforeEach(true)); describe("Bookmark Routes", () => { + async function createTestTag(api: APICallerType, tagName: string) { + const result = await api.tags.create({ name: tagName }); + return result.id; + } + + async function createTestFeed( + api: APICallerType, + feedName: string, + feedUrl: string, + ) { + // Create an RSS feed and return its ID + const feed = await api.feeds.create({ name: feedName, url: feedUrl }); + return feed.id; + } + test<CustomTestContext>("create bookmark", async ({ apiCallers }) => { const api = apiCallers[0].bookmarks; const bookmark = await api.createBookmark({ @@ -126,7 +146,7 @@ describe("Bookmark Routes", () => { ); }); - test<CustomTestContext>("list bookmarks", async ({ apiCallers }) => { + test<CustomTestContext>("list bookmarks", async ({ apiCallers, db }) => { const api = apiCallers[0].bookmarks; const emptyBookmarks = await api.getBookmarks({}); expect(emptyBookmarks.bookmarks.length).toEqual(0); @@ -176,6 +196,64 @@ describe("Bookmark Routes", () => { expect(bookmarks.bookmarks.length).toEqual(1); expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id); } + + // Test tagId filter + { + const tagId = await createTestTag(apiCallers[0], "testTag"); + await api.updateTags({ + bookmarkId: bookmark1.id, + attach: [{ tagId }], + detach: [], + }); + const tagResult = await api.getBookmarks({ tagId }); + expect(tagResult.bookmarks.length).toBeGreaterThan(0); + expect( + tagResult.bookmarks.some((b) => b.id === bookmark1.id), + ).toBeTruthy(); + } + + // Test rssFeedId filter + { + const feedId = await createTestFeed( + apiCallers[0], + "Test Feed", + "https://rss-feed.com", + ); + const rssBookmark = await api.createBookmark({ + url: "https://rss-feed.com", + type: BookmarkTypes.LINK, + }); + await db.insert(rssFeedImportsTable).values([ + { + rssFeedId: feedId, + entryId: "entry-id", + bookmarkId: rssBookmark.id, + }, + ]); + const rssResult = await api.getBookmarks({ rssFeedId: feedId }); + expect(rssResult.bookmarks.length).toBeGreaterThan(0); + expect( + rssResult.bookmarks.some((b) => b.id === rssBookmark.id), + ).toBeTruthy(); + } + + // Test listId filter + { + const list = await apiCallers[0].lists.create({ + name: "Test List", + type: "manual", + icon: "😂", + }); + await apiCallers[0].lists.addToList({ + listId: list.id, + bookmarkId: bookmark1.id, + }); + const listResult = await api.getBookmarks({ listId: list.id }); + expect(listResult.bookmarks.length).toBeGreaterThan(0); + expect( + listResult.bookmarks.some((b) => b.id === bookmark1.id), + ).toBeTruthy(); + } }); test<CustomTestContext>("update tags", async ({ apiCallers }) => { @@ -402,4 +480,70 @@ describe("Bookmark Routes", () => { await validateWithLimit(10); await validateWithLimit(100); }); + + test<CustomTestContext>("getBookmark", async ({ apiCallers }) => { + const api = apiCallers[0].bookmarks; + const createdBookmark = await api.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + + // Test successful getBookmark with includeContent false + const bookmarkWithoutContent = await api.getBookmark({ + bookmarkId: createdBookmark.id, + includeContent: false, + }); + expect(bookmarkWithoutContent.id).toEqual(createdBookmark.id); + expect(bookmarkWithoutContent.content).toBeDefined(); // Content should still be present but might be partial + expect(bookmarkWithoutContent.content.type).toEqual(BookmarkTypes.LINK); + assert(bookmarkWithoutContent.content.type == BookmarkTypes.LINK); + expect(bookmarkWithoutContent.content.url).toEqual("https://example.com"); + + // Test successful getBookmark with includeContent true + const bookmarkWithContent = await api.getBookmark({ + bookmarkId: createdBookmark.id, + includeContent: true, + }); + expect(bookmarkWithContent.id).toEqual(createdBookmark.id); + expect(bookmarkWithContent.content).toBeDefined(); + expect(bookmarkWithContent.content.type).toEqual(BookmarkTypes.LINK); + assert(bookmarkWithContent.content.type == BookmarkTypes.LINK); + expect(bookmarkWithContent.content.url).toEqual("https://example.com"); + // Additional checks if content includes more details, e.g., htmlContent if available + + // Test non-existent bookmark + await expect(() => + api.getBookmark({ bookmarkId: "non-existent-id" }), + ).rejects.toThrow(/Bookmark not found/); + }); + + test<CustomTestContext>("getBrokenLinks", async ({ apiCallers, db }) => { + const api = apiCallers[0].bookmarks; + + // Create a broken link bookmark (simulate by setting crawlStatus to 'failure') + const brokenBookmark = await api.createBookmark({ + url: "https://broken-link.com", + type: BookmarkTypes.LINK, + }); + await db + .update(bookmarkLinks) + .set({ crawlStatus: "failure" }) + .where(eq(bookmarkLinks.id, brokenBookmark.id)); + + const result = await api.getBrokenLinks(); + expect(result.bookmarks.length).toBeGreaterThan(0); + expect( + result.bookmarks.some((b) => b.id === brokenBookmark.id), + ).toBeTruthy(); + expect(result.bookmarks[0].url).toEqual("https://broken-link.com"); + expect(result.bookmarks[0].isCrawlingFailure).toBeTruthy(); + + // Test with no broken links + await db + .update(bookmarkLinks) + .set({ crawlStatus: "success" }) + .where(eq(bookmarkLinks.id, brokenBookmark.id)); + const emptyResult = await api.getBrokenLinks(); + expect(emptyResult.bookmarks.length).toEqual(0); + }); }); diff --git a/packages/trpc/routers/highlights.test.ts b/packages/trpc/routers/highlights.test.ts new file mode 100644 index 00000000..44184d03 --- /dev/null +++ b/packages/trpc/routers/highlights.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach<CustomTestContext>(defaultBeforeEach(true)); + +describe("Highlight Routes", () => { + test<CustomTestContext>("create highlight", async ({ apiCallers }) => { + const api = apiCallers[0].highlights; + const bookmarksApi = apiCallers[0].bookmarks; + + // First, create a valid bookmark + const bookmark = await bookmarksApi.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + const bookmarkId = bookmark.id; + + const highlight = await api.create({ + bookmarkId, + startOffset: 10, + endOffset: 20, + color: "yellow", + text: "Test highlight text", + note: "Test note", + }); + + const res = await api.get({ highlightId: highlight.id }); + expect(res.bookmarkId).toEqual(bookmarkId); + expect(res.startOffset).toEqual(10); + expect(res.endOffset).toEqual(20); + expect(res.color).toEqual("yellow"); + expect(res.text).toEqual("Test highlight text"); + expect(res.note).toEqual("Test note"); + }); + + test<CustomTestContext>("delete highlight", async ({ apiCallers }) => { + const api = apiCallers[0].highlights; + const bookmarksApi = apiCallers[0].bookmarks; + + // First, create a valid bookmark + const bookmark = await bookmarksApi.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + const bookmarkId = bookmark.id; + + // Create the highlight first + const highlight = await api.create({ + bookmarkId, + startOffset: 10, + endOffset: 20, + color: "yellow", + text: "Test highlight text", + note: "Test note", + }); + + // It should exist + await api.get({ highlightId: highlight.id }); + + // Delete it + await api.delete({ highlightId: highlight.id }); + + // It shouldn't be there anymore + await expect(() => api.get({ highlightId: highlight.id })).rejects.toThrow( + /Highlight not found/, + ); + }); + + test<CustomTestContext>("update highlight", async ({ apiCallers }) => { + const api = apiCallers[0].highlights; + const bookmarksApi = apiCallers[0].bookmarks; + + // First, create a valid bookmark + const bookmark = await bookmarksApi.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + const bookmarkId = bookmark.id; + + // Create the highlight + const highlight = await api.create({ + bookmarkId, + startOffset: 10, + endOffset: 20, + color: "yellow", + text: "Original text", + note: "Original note", + }); + + await api.update({ + highlightId: highlight.id, + color: "blue", + }); + + const res = await api.get({ highlightId: highlight.id }); + expect(res.color).toEqual("blue"); + expect(res.text).toEqual("Original text"); // Only color is updated in the router + }); + + test<CustomTestContext>("get highlight", async ({ apiCallers }) => { + const api = apiCallers[0].highlights; + const bookmarksApi = apiCallers[0].bookmarks; + + // First, create a valid bookmark + const bookmark = await bookmarksApi.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + const bookmarkId = bookmark.id; + + // Create the highlight + const createdHighlight = await api.create({ + bookmarkId, + startOffset: 10, + endOffset: 20, + color: "yellow", + text: "Test text", + note: "Test note", + }); + + const res = await api.get({ highlightId: createdHighlight.id }); + expect(res.id).toEqual(createdHighlight.id); + expect(res.bookmarkId).toEqual(bookmarkId); + }); + + test<CustomTestContext>("get highlights for bookmark", async ({ + apiCallers, + }) => { + const api = apiCallers[0].highlights; + const bookmarksApi = apiCallers[0].bookmarks; + const bookmark = await bookmarksApi.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + const bookmarkId = bookmark.id; + + const highlight1 = await api.create({ + bookmarkId, + startOffset: 10, + endOffset: 20, + color: "yellow", + text: "Highlight 1", + note: "", + }); + + const highlight2 = await api.create({ + bookmarkId, + startOffset: 30, + endOffset: 40, + color: "blue", + text: "Highlight 2", + note: "", + }); + + const res = await api.getForBookmark({ bookmarkId }); + expect(res.highlights.length).toBeGreaterThanOrEqual(2); + expect(res.highlights.some((h) => h.id === highlight1.id)).toBeTruthy(); + expect(res.highlights.some((h) => h.id === highlight2.id)).toBeTruthy(); + }); + + test<CustomTestContext>("get all highlights with pagination", async ({ + apiCallers, + }) => { + const api = apiCallers[0].highlights; + const bookmarksApi = apiCallers[0].bookmarks; + const bookmark = await bookmarksApi.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + const bookmarkId = bookmark.id; + + // Create multiple highlights + await api.create({ + bookmarkId, + startOffset: 10, + endOffset: 20, + color: "yellow", + text: "Highlight 1", + note: "", + }); + await api.create({ + bookmarkId, + startOffset: 30, + endOffset: 40, + color: "blue", + text: "Highlight 2", + note: "", + }); + await api.create({ + bookmarkId, + startOffset: 50, + endOffset: 60, + color: "green", + text: "Highlight 3", + note: "", + }); + + const res = await api.getAll({ limit: 2 }); + expect(res.highlights.length).toEqual(2); + expect(res.nextCursor).toBeDefined(); // Should have a next cursor + }); + + test<CustomTestContext>("privacy for highlights", async ({ apiCallers }) => { + const apiUser1 = apiCallers[0].highlights; + const apiUser2 = apiCallers[1].highlights; + const bookmarksApiUser1 = apiCallers[0].bookmarks; + const bookmarksApiUser2 = apiCallers[1].bookmarks; + + const bookmarkUser1 = await bookmarksApiUser1.createBookmark({ + url: "https://user1-example.com", + type: BookmarkTypes.LINK, + }); + const bookmarkIdUser1 = bookmarkUser1.id; + + const bookmarkUser2 = await bookmarksApiUser2.createBookmark({ + url: "https://user2-example.com", + type: BookmarkTypes.LINK, + }); + const bookmarkIdUser2 = bookmarkUser2.id; + + const highlightUser1 = await apiUser1.create({ + bookmarkId: bookmarkIdUser1, + startOffset: 10, + endOffset: 20, + color: "yellow", + text: "User1 highlight", + note: "", + }); + + const highlightUser2 = await apiUser2.create({ + bookmarkId: bookmarkIdUser2, + startOffset: 10, + endOffset: 20, + color: "blue", + text: "User2 highlight", + note: "", + }); + + // User1 should not access User2's highlight + await expect(() => + apiUser1.get({ highlightId: highlightUser2.id }), + ).rejects.toThrow(/User is not allowed to access resource/); + + // User2 should not access User1's highlight + await expect(() => + apiUser2.get({ highlightId: highlightUser1.id }), + ).rejects.toThrow(/User is not allowed to access resource/); + }); +}); diff --git a/packages/trpc/routers/highlights.ts b/packages/trpc/routers/highlights.ts index 3436653e..6124e35f 100644 --- a/packages/trpc/routers/highlights.ts +++ b/packages/trpc/routers/highlights.ts @@ -34,7 +34,7 @@ const ensureHighlightOwnership = experimental_trpcMiddleware<{ if (!highlight) { throw new TRPCError({ code: "NOT_FOUND", - message: "Bookmark not found", + message: "Highlight not found", }); } if (highlight.userId != opts.ctx.user.id) { diff --git a/packages/trpc/routers/lists.test.ts b/packages/trpc/routers/lists.test.ts new file mode 100644 index 00000000..9863fb38 --- /dev/null +++ b/packages/trpc/routers/lists.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { z } from "zod"; + +import { + BookmarkTypes, + zNewBookmarkRequestSchema, +} from "@karakeep/shared/types/bookmarks"; +import { zNewBookmarkListSchema } from "@karakeep/shared/types/lists"; + +import type { APICallerType, CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +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> = { + name: "Test List", + description: "A test list", + icon: "📋", + type: "manual", + }; + + const createdList = await api.create(newListInput); + + expect(createdList).toMatchObject({ + name: newListInput.name, + description: newListInput.description, + icon: newListInput.icon, + type: newListInput.type, + }); + + const lists = await api.list(); + const listFromList = lists.lists.find((l) => l.id === createdList.id); + expect(listFromList).toBeDefined(); + expect(listFromList?.name).toEqual(newListInput.name); + }); + + test<CustomTestContext>("edit list", async ({ apiCallers }) => { + const api = apiCallers[0].lists; + + // First, create a list + const createdListInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Original List", + description: "Original description", + icon: "📋", + type: "manual", + }; + const createdList = await api.create(createdListInput); + + // Update it + const updatedListInput = { + listId: createdList.id, + name: "Updated List", + description: "Updated description", + icon: "⭐️", + }; + const updatedList = await api.edit(updatedListInput); + + expect(updatedList.name).toEqual(updatedListInput.name); + expect(updatedList.description).toEqual(updatedListInput.description); + expect(updatedList.icon).toEqual(updatedListInput.icon); + + // Verify the update + const lists = await api.list(); + const listFromList = lists.lists.find((l) => l.id === createdList.id); + expect(listFromList).toBeDefined(); + expect(listFromList?.name).toEqual(updatedListInput.name); + + // Test editing a non-existent list + await expect(() => + api.edit({ listId: "non-existent-id", name: "Fail" }), + ).rejects.toThrow(/List not found/); + }); + + test<CustomTestContext>("merge lists", async ({ apiCallers }) => { + const api = apiCallers[0].lists; + + // First, create a real bookmark + const bookmarkId = await createTestBookmark(apiCallers[0]); + + // Create two lists + const sourceListInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Source List", + type: "manual", + icon: "📚", + }; + const targetListInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Target List", + type: "manual", + icon: "📖", + }; + const sourceList = await api.create(sourceListInput); + const targetList = await api.create(targetListInput); + + // Add the real bookmark to source list + await api.addToList({ listId: sourceList.id, bookmarkId }); + + // Merge + await api.merge({ + sourceId: sourceList.id, + targetId: targetList.id, + deleteSourceAfterMerge: true, + }); + + // Verify source list is deleted and bookmark is in target + const lists = await api.list(); + expect(lists.lists.find((l) => l.id === sourceList.id)).toBeUndefined(); + const targetListsOfBookmark = await api.getListsOfBookmark({ + bookmarkId, + }); + expect( + targetListsOfBookmark.lists.find((l) => l.id === targetList.id), + ).toBeDefined(); + + // Test merging invalid lists + await expect(() => + api.merge({ + sourceId: sourceList.id, + targetId: "non-existent-id", + deleteSourceAfterMerge: true, + }), + ).rejects.toThrow(/List not found/); + }); + + test<CustomTestContext>("delete list", async ({ apiCallers }) => { + const api = apiCallers[0].lists; + + // Create a list + const createdListInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "List to Delete", + type: "manual", + icon: "📚", + }; + const createdList = await api.create(createdListInput); + + // Delete it + await api.delete({ listId: createdList.id }); + + // Verify it's deleted + const lists = await api.list(); + expect(lists.lists.find((l) => l.id === createdList.id)).toBeUndefined(); + + // Test deleting a non-existent list + await expect(() => + api.delete({ listId: "non-existent-id" }), + ).rejects.toThrow(/List not found/); + }); + + test<CustomTestContext>("add and remove from list", async ({ + apiCallers, + }) => { + const api = apiCallers[0].lists; + + // First, create a real bookmark + const bookmarkId = await createTestBookmark(apiCallers[0]); + + // Create a manual list + const listInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Manual List", + type: "manual", + icon: "📚", + }; + const createdList = await api.create(listInput); + + // Add to list + await api.addToList({ listId: createdList.id, bookmarkId }); + + // Verify addition + const listsOfBookmark = await api.getListsOfBookmark({ + bookmarkId, + }); + expect( + listsOfBookmark.lists.find((l) => l.id === createdList.id), + ).toBeDefined(); + + // Remove from list + await api.removeFromList({ listId: createdList.id, bookmarkId }); + + // Verify removal + const updatedListsOfBookmark = await api.getListsOfBookmark({ + bookmarkId, + }); + expect( + updatedListsOfBookmark.lists.find((l) => l.id === createdList.id), + ).toBeUndefined(); + + // Test on smart list (should fail) + const smartListInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Smart List", + type: "smart", + query: "#example", + icon: "📚", + }; + const smartList = await api.create(smartListInput); + await expect(() => + api.addToList({ listId: smartList.id, bookmarkId }), + ).rejects.toThrow(/Smart lists cannot be added to/); + }); + + test<CustomTestContext>("get and list lists", async ({ apiCallers }) => { + const api = apiCallers[0].lists; + + const newListInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Get Test List", + type: "manual", + icon: "📚", + }; + const createdList = await api.create(newListInput); + + const getList = await api.get({ listId: createdList.id }); + expect(getList.name).toEqual(newListInput.name); + + const lists = await api.list(); + expect(lists.lists.length).toBeGreaterThan(0); + expect(lists.lists.find((l) => l.id === createdList.id)).toBeDefined(); + }); + + test<CustomTestContext>("get lists of bookmark and stats", async ({ + apiCallers, + }) => { + const api = apiCallers[0].lists; + + // First, create a real bookmark + const bookmarkId = await createTestBookmark(apiCallers[0]); + + // Create a list and add the bookmark + const listInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Stats Test List", + type: "manual", + icon: "📚", + }; + const createdList = await api.create(listInput); + await api.addToList({ listId: createdList.id, bookmarkId }); + + const listsOfBookmark = await api.getListsOfBookmark({ + bookmarkId, + }); + expect(listsOfBookmark.lists.length).toBeGreaterThan(0); + + const stats = await api.stats(); + expect(stats.stats.get(createdList.id)).toBeGreaterThan(0); + }); +}); diff --git a/packages/trpc/routers/prompts.test.ts b/packages/trpc/routers/prompts.test.ts new file mode 100644 index 00000000..5afd627e --- /dev/null +++ b/packages/trpc/routers/prompts.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { z } from "zod"; + +import { zNewPromptSchema } from "@karakeep/shared/types/prompts"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach<CustomTestContext>(defaultBeforeEach(true)); + +describe("Prompts Routes", () => { + test<CustomTestContext>("create prompt", async ({ apiCallers }) => { + const api = apiCallers[0].prompts; + const newPromptInput: z.infer<typeof zNewPromptSchema> = { + text: "Test prompt text", + appliesTo: "summary", + }; + + const createdPrompt = await api.create({ ...newPromptInput }); + + expect(createdPrompt).toMatchObject({ + text: newPromptInput.text, + appliesTo: newPromptInput.appliesTo, + enabled: true, + }); + + const prompts = await api.list(); + const promptFromList = prompts.find((p) => p.id === createdPrompt.id); + expect(promptFromList).toBeDefined(); + expect(promptFromList?.text).toEqual(newPromptInput.text); + }); + + test<CustomTestContext>("update prompt", async ({ apiCallers }) => { + const api = apiCallers[0].prompts; + + // First, create a prompt + const createdPrompt = await api.create({ + text: "Original text", + appliesTo: "summary", + }); + + // Update it + const updatedPrompt = await api.update({ + promptId: createdPrompt.id, + text: "Updated text", + appliesTo: "summary", + enabled: false, + }); + + expect(updatedPrompt.text).toEqual("Updated text"); + expect(updatedPrompt.appliesTo).toEqual("summary"); + expect(updatedPrompt.enabled).toEqual(false); + + // Instead of api.getPrompt, use api.list() to verify + const prompts = await api.list(); + const promptFromList = prompts.find((p) => p.id === createdPrompt.id); + expect(promptFromList).toBeDefined(); + expect(promptFromList?.text).toEqual("Updated text"); + expect(promptFromList?.enabled).toEqual(false); + + // Test updating a non-existent prompt + await expect(() => + api.update({ + promptId: "non-existent-id", + text: "Should fail", + appliesTo: "summary", + enabled: true, // Assuming this matches the schema + }), + ).rejects.toThrow(/Prompt not found/); + }); + + test<CustomTestContext>("list prompts", async ({ apiCallers }) => { + const api = apiCallers[0].prompts; + + const emptyPrompts = await api.list(); + expect(emptyPrompts).toEqual([]); + + const prompt1Input: z.infer<typeof zNewPromptSchema> = { + text: "Prompt 1", + appliesTo: "summary", + }; + await api.create(prompt1Input); + + const prompt2Input: z.infer<typeof zNewPromptSchema> = { + text: "Prompt 2", + appliesTo: "summary", + }; + await api.create(prompt2Input); + + const prompts = await api.list(); + expect(prompts.length).toEqual(2); + expect(prompts.some((p) => p.text === "Prompt 1")).toBeTruthy(); + expect(prompts.some((p) => p.text === "Prompt 2")).toBeTruthy(); + }); + + test<CustomTestContext>("delete prompt", async ({ apiCallers }) => { + const api = apiCallers[0].prompts; + + // Create a prompt + const createdPromptInput: z.infer<typeof zNewPromptSchema> = { + text: "To be deleted", + appliesTo: "summary", + }; + const createdPrompt = await api.create(createdPromptInput); + + // Delete it + await api.delete({ promptId: createdPrompt.id }); + + // Instead of api.getPrompt, use api.list() to verify + const prompts = await api.list(); + expect(prompts.some((p) => p.id === createdPrompt.id)).toBeFalsy(); + }); + + test<CustomTestContext>("privacy for prompts", async ({ apiCallers }) => { + const user1PromptInput: z.infer<typeof zNewPromptSchema> = { + text: "User 1 prompt", + appliesTo: "summary", + }; + const user1Prompt = await apiCallers[0].prompts.create(user1PromptInput); + + const user2PromptInput: z.infer<typeof zNewPromptSchema> = { + text: "User 2 prompt", + appliesTo: "summary", + }; + const user2Prompt = await apiCallers[1].prompts.create(user2PromptInput); + + // User 1 should not access User 2's prompt + await expect(() => + apiCallers[0].prompts.delete({ promptId: user2Prompt.id }), + ).rejects.toThrow(/User is not allowed to access resource/); + + // List should only show the correct user's prompts + const user1Prompts = await apiCallers[0].prompts.list(); + expect(user1Prompts.length).toEqual(1); + expect(user1Prompts[0].id).toEqual(user1Prompt.id); + + const user2Prompts = await apiCallers[1].prompts.list(); + expect(user2Prompts.length).toEqual(1); + expect(user2Prompts[0].id).toEqual(user2Prompt.id); + }); +}); diff --git a/packages/trpc/routers/tags.test.ts b/packages/trpc/routers/tags.test.ts new file mode 100644 index 00000000..c5f92cb2 --- /dev/null +++ b/packages/trpc/routers/tags.test.ts @@ -0,0 +1,146 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test } from "vitest"; + +import { bookmarkTags } from "@karakeep/db/schema"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach<CustomTestContext>(defaultBeforeEach(true)); + +describe("Tags Routes", () => { + test<CustomTestContext>("get tag", async ({ apiCallers }) => { + const api = apiCallers[0].tags; + const createdTag = await api.create({ name: "testTag" }); + + const res = await api.get({ tagId: createdTag.id }); + expect(res.id).toEqual(createdTag.id); + expect(res.name).toEqual("testTag"); + expect(res.numBookmarks).toBeGreaterThanOrEqual(0); + }); + + test<CustomTestContext>("get tag - not found", async ({ apiCallers }) => { + const api = apiCallers[0].tags; + await expect(() => api.get({ tagId: "nonExistentId" })).rejects.toThrow( + /Tag not found/, + ); + }); + + test<CustomTestContext>("delete tag", async ({ apiCallers, db }) => { + const api = apiCallers[0].tags; + const createdTag = await api.create({ name: "testTag" }); + + await api.delete({ tagId: createdTag.id }); + + const res = await db.query.bookmarkTags.findFirst({ + where: eq(bookmarkTags.id, createdTag.id), + }); + expect(res).toBeUndefined(); // Tag should be deleted + }); + + test<CustomTestContext>("delete tag - unauthorized", async ({ + apiCallers, + }) => { + const user1api = apiCallers[0].tags; + const createdTag = await user1api.create({ name: "testTag" }); + + const api = apiCallers[1].tags; + await expect(() => api.delete({ tagId: createdTag.id })).rejects.toThrow( + /Tag not found/, + ); + }); + + test<CustomTestContext>("delete unused tags", async ({ apiCallers }) => { + const api = apiCallers[0].tags; + await api.create({ name: "unusedTag" }); // Create an unused tag + + const res = await api.deleteUnused(); + expect(res.deletedTags).toBeGreaterThanOrEqual(1); // At least one tag deleted + }); + + test<CustomTestContext>("update tag", async ({ apiCallers }) => { + const api = apiCallers[0].tags; + const createdTag = await api.create({ name: "oldName" }); + + const updatedTag = await api.update({ + tagId: createdTag.id, + name: "newName", + }); + expect(updatedTag.name).toEqual("newName"); + }); + + test<CustomTestContext>("update tag - conflict", async ({ apiCallers }) => { + const api = apiCallers[0].tags; + await api.create({ name: "existingName" }); + const createdTag = await api.create({ name: "anotherName" }); + + await expect(() => + api.update({ tagId: createdTag.id, name: "existingName" }), + ).rejects.toThrow(/Tag name already exists/); + }); + + test<CustomTestContext>("merge tags", async ({ apiCallers }) => { + const api = apiCallers[0].tags; + const tag1 = await api.create({ name: "tag1" }); + const tag2 = await api.create({ name: "tag2" }); + + // First, create a bookmark with tag2 + const bookmarkApi = apiCallers[0].bookmarks; + const createdBookmark = await bookmarkApi.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + await bookmarkApi.updateTags({ + bookmarkId: createdBookmark.id, + attach: [{ tagName: "tag2" }], + detach: [], + }); + + // Now perform the merge + const result = await api.merge({ + intoTagId: tag1.id, + fromTagIds: [tag2.id], + }); + expect(result.mergedIntoTagId).toEqual(tag1.id); + expect(result.deletedTags).toContain(tag2.id); + + // Verify that the bookmark now has tag1 and not tag2 + const updatedBookmark = await bookmarkApi.getBookmark({ + bookmarkId: createdBookmark.id, + includeContent: false, + }); + const tagNames = updatedBookmark.tags.map((tag) => tag.name); + expect(tagNames).toContain("tag1"); + expect(tagNames).not.toContain("tag2"); + }); + + test<CustomTestContext>("merge tags - invalid input", async ({ + apiCallers, + }) => { + const api = apiCallers[0].tags; + await expect(() => + api.merge({ intoTagId: "tag1", fromTagIds: ["tag1"] }), + ).rejects.toThrow(/Cannot merge tag into itself/); + }); + + test<CustomTestContext>("list tags", async ({ apiCallers }) => { + const api = apiCallers[0].tags; + await api.create({ name: "tag1" }); + await api.create({ name: "tag2" }); + + const res = await api.list(); + expect(res.tags.length).toBeGreaterThanOrEqual(2); + expect(res.tags.some((tag) => tag.name === "tag1")).toBeTruthy(); + expect(res.tags.some((tag) => tag.name === "tag2")).toBeTruthy(); + }); + + test<CustomTestContext>("list tags - privacy", async ({ apiCallers }) => { + const apiUser1 = apiCallers[0].tags; + await apiUser1.create({ name: "user1Tag" }); + + const apiUser2 = apiCallers[1].tags; // Different user + const resUser2 = await apiUser2.list(); + expect(resUser2.tags.some((tag) => tag.name === "user1Tag")).toBeFalsy(); // Should not see other user's tags + }); +}); diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts index 7378c66f..cdf47f4f 100644 --- a/packages/trpc/routers/tags.ts +++ b/packages/trpc/routers/tags.ts @@ -52,6 +52,42 @@ const ensureTagOwnership = experimental_trpcMiddleware<{ }); export const tagsAppRouter = router({ + create: authedProcedure + .input( + z.object({ + name: z.string().min(1), // Ensure the name is provided and not empty + }), + ) + .output( + z.object({ + id: z.string(), + name: z.string(), + userId: z.string(), + createdAt: z.date(), + }), + ) + .mutation(async ({ input, ctx }) => { + try { + const [newTag] = await ctx.db + .insert(bookmarkTags) + .values({ + name: input.name, + userId: ctx.user.id, + }) + .returning(); + + return newTag; + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Tag name already exists for this user.", + }); + } + throw e; + } + }), + get: authedProcedure .input( z.object({ diff --git a/packages/trpc/routers/webhooks.test.ts b/packages/trpc/routers/webhooks.test.ts new file mode 100644 index 00000000..5a136a31 --- /dev/null +++ b/packages/trpc/routers/webhooks.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach<CustomTestContext>(defaultBeforeEach(true)); + +describe("Webhook Routes", () => { + test<CustomTestContext>("create webhook", async ({ apiCallers }) => { + const api = apiCallers[0].webhooks; + const newWebhook = await api.create({ + url: "https://example.com/webhook", + events: ["created", "edited"], + }); + + expect(newWebhook).toBeDefined(); + expect(newWebhook.url).toEqual("https://example.com/webhook"); + expect(newWebhook.events).toEqual(["created", "edited"]); + expect(newWebhook.hasToken).toBe(false); // Assuming token is not set by default + }); + + test<CustomTestContext>("update webhook", async ({ apiCallers }) => { + const api = apiCallers[0].webhooks; + + // First, create a webhook to update + const createdWebhook = await api.create({ + url: "https://example.com/webhook", + events: ["created"], + }); + + // Update it + const updatedWebhook = await api.update({ + webhookId: createdWebhook.id, + url: "https://updated-example.com/webhook", + events: ["created", "edited"], + token: "test-token", + }); + + expect(updatedWebhook.url).toEqual("https://updated-example.com/webhook"); + expect(updatedWebhook.events).toEqual(["created", "edited"]); + expect(updatedWebhook.hasToken).toBe(true); + + // Test updating a non-existent webhook + await expect(() => + api.update({ + webhookId: "non-existent-id", + url: "https://fail.com", + events: ["created"], + }), + ).rejects.toThrow(/Webhook not found/); + }); + + test<CustomTestContext>("list webhooks", async ({ apiCallers }) => { + const api = apiCallers[0].webhooks; + + // Create a couple of webhooks + await api.create({ + url: "https://example1.com/webhook", + events: ["created"], + }); + await api.create({ + url: "https://example2.com/webhook", + events: ["edited"], + }); + + const result = await api.list(); + expect(result.webhooks).toBeDefined(); + expect(result.webhooks.length).toBeGreaterThanOrEqual(2); + expect( + result.webhooks.some((w) => w.url === "https://example1.com/webhook"), + ).toBe(true); + expect( + result.webhooks.some((w) => w.url === "https://example2.com/webhook"), + ).toBe(true); + }); + + test<CustomTestContext>("delete webhook", async ({ apiCallers }) => { + const api = apiCallers[0].webhooks; + + // Create a webhook to delete + const createdWebhook = await api.create({ + url: "https://example.com/webhook", + events: ["created"], + }); + + // Delete it + await api.delete({ webhookId: createdWebhook.id }); + + // Verify it's deleted + await expect(() => + api.update({ + webhookId: createdWebhook.id, + url: "https://updated.com", + events: ["created"], + }), + ).rejects.toThrow(/Webhook not found/); + }); + + test<CustomTestContext>("privacy for webhooks", async ({ apiCallers }) => { + const user1Webhook = await apiCallers[0].webhooks.create({ + url: "https://user1-webhook.com", + events: ["created"], + }); + const user2Webhook = await apiCallers[1].webhooks.create({ + url: "https://user2-webhook.com", + events: ["created"], + }); + + // User 1 should not access User 2's webhook + await expect(() => + apiCallers[0].webhooks.delete({ webhookId: user2Webhook.id }), + ).rejects.toThrow(/User is not allowed to access resource/); + await expect(() => + apiCallers[0].webhooks.update({ + webhookId: user2Webhook.id, + url: "https://fail.com", + events: ["created"], + }), + ).rejects.toThrow(/User is not allowed to access resource/); + + // List should only show the correct user's webhooks + const user1List = await apiCallers[0].webhooks.list(); + expect(user1List.webhooks.some((w) => w.id === user1Webhook.id)).toBe(true); + expect(user1List.webhooks.some((w) => w.id === user2Webhook.id)).toBe( + false, + ); + }); +}); |
