aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-04-16 19:52:01 +0000
committerMohamed Bassem <me@mbassem.com>2025-04-16 23:50:17 +0000
commit1d780485d731c077009fc76d5fa0e283f6f78d85 (patch)
treee3f95e0c23d5eedc459cd0358fe3b0d6d926b79d /packages
parent812354deaa96c37b5a06cfd7cb98df35f8202aa9 (diff)
downloadkarakeep-1d780485d731c077009fc76d5fa0e283f6f78d85.tar.zst
tests: Add tests for various trpc endpoints
Diffstat (limited to 'packages')
-rw-r--r--packages/db/drizzle.ts2
-rw-r--r--packages/trpc/routers/bookmarks.test.ts150
-rw-r--r--packages/trpc/routers/highlights.test.ts253
-rw-r--r--packages/trpc/routers/highlights.ts2
-rw-r--r--packages/trpc/routers/lists.test.ts255
-rw-r--r--packages/trpc/routers/prompts.test.ts141
-rw-r--r--packages/trpc/routers/tags.test.ts146
-rw-r--r--packages/trpc/routers/tags.ts36
-rw-r--r--packages/trpc/routers/webhooks.test.ts128
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,
+ );
+ });
+});