diff options
| author | MohamedBassem <me@mbassem.com> | 2024-02-28 15:43:32 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-02-28 15:43:32 +0000 |
| commit | f67ae821230da9bc92a3c9ff6c550a36d48c0ee9 (patch) | |
| tree | 75ff0d4e07bd066d3acc7e7cfa6ef126ea0eebc7 /packages | |
| parent | 0f0e7ca8d134c2cfc02ac62539ad10c811319b38 (diff) | |
| download | karakeep-f67ae821230da9bc92a3c9ff6c550a36d48c0ee9.tar.zst | |
tests: Add tests for the bookmarks routes
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/db/drizzle.ts | 11 | ||||
| -rw-r--r-- | packages/web/app/api/trpc/[trpc]/route.ts | 3 | ||||
| -rw-r--r-- | packages/web/app/dashboard/bookmarks/components/LinkCard.tsx | 2 | ||||
| -rw-r--r-- | packages/web/app/dashboard/bookmarks/components/TagModal.tsx | 4 | ||||
| -rw-r--r-- | packages/web/lib/testUtils.ts | 59 | ||||
| -rw-r--r-- | packages/web/package.json | 10 | ||||
| -rw-r--r-- | packages/web/server/api/client.ts | 4 | ||||
| -rw-r--r-- | packages/web/server/api/routers/apiKeys.ts | 5 | ||||
| -rw-r--r-- | packages/web/server/api/routers/bookmarks.test.ts | 182 | ||||
| -rw-r--r-- | packages/web/server/api/routers/bookmarks.ts | 115 | ||||
| -rw-r--r-- | packages/web/server/api/routers/tags.ts | 0 | ||||
| -rw-r--r-- | packages/web/server/api/routers/users.ts | 6 | ||||
| -rw-r--r-- | packages/web/server/api/trpc.ts | 2 | ||||
| -rw-r--r-- | packages/web/vitest.config.ts | 14 |
14 files changed, 351 insertions, 66 deletions
diff --git a/packages/db/drizzle.ts b/packages/db/drizzle.ts index def1fc0a..adfe4884 100644 --- a/packages/db/drizzle.ts +++ b/packages/db/drizzle.ts @@ -2,6 +2,17 @@ import "dotenv/config"; import { drizzle } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; import * as schema from "./schema"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import path from "path"; const sqlite = new Database(process.env.DATABASE_URL); export const db = drizzle(sqlite, { schema, logger: true }); + +export function getInMemoryDB(runMigrations: boolean) { + const mem = new Database(":memory:"); + const db = drizzle(mem, { schema, logger: true }); + if (runMigrations) { + migrate(db, { migrationsFolder: path.resolve(__dirname, "./drizzle") }); + } + return db; +} diff --git a/packages/web/app/api/trpc/[trpc]/route.ts b/packages/web/app/api/trpc/[trpc]/route.ts index aea9bc70..7d56cadc 100644 --- a/packages/web/app/api/trpc/[trpc]/route.ts +++ b/packages/web/app/api/trpc/[trpc]/route.ts @@ -2,6 +2,7 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@/server/api/routers/_app"; import { createContext } from "@/server/api/client"; import { authenticateApiKey } from "@/server/auth"; +import { db } from "@hoarder/db"; const handler = (req: Request) => fetchRequestHandler({ @@ -23,7 +24,7 @@ const handler = (req: Request) => const token = authorizationHeader.split(" ")[1]; try { const user = await authenticateApiKey(token); - return { user }; + return { user, db }; } catch (e) { // Fallthrough to cookie-based auth } diff --git a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx index 56e3d243..cd0f128c 100644 --- a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx +++ b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx @@ -60,7 +60,7 @@ export default function LinkCard({ }) { const { data: bookmark } = api.bookmarks.getBookmark.useQuery( { - id: initialData.id, + bookmarkId: initialData.id, }, { initialData, diff --git a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx index c1618541..b0e391b7 100644 --- a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx +++ b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx @@ -130,7 +130,7 @@ export default function TagModal({ toast({ description: "Tags has been updated!", }); - bookmarkInvalidationFunction({ id: bookmark.id }); + bookmarkInvalidationFunction({ bookmarkId: bookmark.id }); }, onError: () => { toast({ @@ -153,7 +153,7 @@ export default function TagModal({ } for (const t of bookmark.tags) { if (!tags.has(t.name)) { - detach.push(t.id); + detach.push({ tagId: t.id }); } } mutate({ diff --git a/packages/web/lib/testUtils.ts b/packages/web/lib/testUtils.ts new file mode 100644 index 00000000..ca9a6474 --- /dev/null +++ b/packages/web/lib/testUtils.ts @@ -0,0 +1,59 @@ +import { users } from "@hoarder/db/schema"; +import { getInMemoryDB } from "@hoarder/db/drizzle"; +import { appRouter } from "@/server/api/routers/_app"; +import { createCallerFactory } from "@/server/api/trpc"; +import { beforeEach } from "vitest"; + +export function getTestDB() { + return getInMemoryDB(true); +} + +export type TestDB = ReturnType<typeof getTestDB>; + +export async function seedUsers(db: TestDB) { + return await db + .insert(users) + .values([ + { + name: "Test User 1", + email: "test1@test.com", + }, + { + name: "Test User 2", + email: "test2@test.com", + }, + ]) + .returning(); +} + +export function getApiCaller(db: TestDB, userId: string) { + const createCaller = createCallerFactory(appRouter); + return createCaller({ + user: { + id: userId, + }, + db, + }); +} + +export type APICallerType = ReturnType<typeof getApiCaller>; + +export interface CustomTestContext { + apiCallers: APICallerType[]; + db: TestDB; +} + +export async function buildTestContext(): Promise<CustomTestContext> { + const db = getTestDB(); + const users = await seedUsers(db); + const callers = users.map((u) => getApiCaller(db, u.id)); + + return { + apiCallers: callers, + db, + }; +} + +export const defaultBeforeEach = async (context: object) => { + Object.assign(context, await buildTestContext()); +}; diff --git a/packages/web/package.json b/packages/web/package.json index 488af90f..8b135f9c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -7,12 +7,13 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest" }, "dependencies": { + "@auth/drizzle-adapter": "^0.7.0", "@hoarder/db": "0.1.0", "@hoarder/shared": "0.1.0", - "@auth/drizzle-adapter": "^0.7.0", "@hookform/resolvers": "^3.3.4", "@next/eslint-plugin-next": "^14.1.0", "@radix-ui/react-dialog": "^1.0.5", @@ -54,6 +55,9 @@ "@types/react-dom": "^18", "autoprefixer": "^10.0.1", "postcss": "^8", - "tailwindcss": "^3.3.0" + "tailwindcss": "^3.3.0", + "ts-node": "^10.9.2", + "vite-tsconfig-paths": "^4.3.1", + "vitest": "^1.3.1" } } diff --git a/packages/web/server/api/client.ts b/packages/web/server/api/client.ts index 7b4e6378..130f4f87 100644 --- a/packages/web/server/api/client.ts +++ b/packages/web/server/api/client.ts @@ -1,11 +1,13 @@ import { appRouter } from "./routers/_app"; import { getServerAuthSession } from "@/server/auth"; import { Context, createCallerFactory } from "./trpc"; +import { db } from "@hoarder/db"; -export const createContext = async (): Promise<Context> => { +export const createContext = async (database?: typeof db): Promise<Context> => { const session = await getServerAuthSession(); return { user: session?.user ?? null, + db: database ?? db, }; }; diff --git a/packages/web/server/api/routers/apiKeys.ts b/packages/web/server/api/routers/apiKeys.ts index 0538a34d..9eb36974 100644 --- a/packages/web/server/api/routers/apiKeys.ts +++ b/packages/web/server/api/routers/apiKeys.ts @@ -1,6 +1,5 @@ import { generateApiKey } from "@/server/auth"; import { authedProcedure, router } from "../trpc"; -import { db } from "@hoarder/db"; import { z } from "zod"; import { apiKeys } from "@hoarder/db/schema"; import { eq, and } from "drizzle-orm"; @@ -30,7 +29,7 @@ export const apiKeysAppRouter = router({ }), ) .mutation(async ({ input, ctx }) => { - await db + await ctx.db .delete(apiKeys) .where(and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.user.id))); }), @@ -48,7 +47,7 @@ export const apiKeysAppRouter = router({ }), ) .query(async ({ ctx }) => { - const resp = await db.query.apiKeys.findMany({ + const resp = await ctx.db.query.apiKeys.findMany({ where: eq(apiKeys.userId, ctx.user.id), columns: { id: true, diff --git a/packages/web/server/api/routers/bookmarks.test.ts b/packages/web/server/api/routers/bookmarks.test.ts new file mode 100644 index 00000000..16f82992 --- /dev/null +++ b/packages/web/server/api/routers/bookmarks.test.ts @@ -0,0 +1,182 @@ +import { CustomTestContext, defaultBeforeEach } from "@/lib/testUtils"; +import { expect, describe, test, beforeEach } from "vitest"; + +beforeEach<CustomTestContext>(defaultBeforeEach); + +describe("Bookmark Routes", () => { + test<CustomTestContext>("create bookmark", async ({ apiCallers }) => { + const api = apiCallers[0].bookmarks; + const bookmark = await api.bookmarkLink({ + url: "https://google.com", + type: "link", + }); + + const res = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(res.content.url).toEqual("https://google.com"); + expect(res.favourited).toEqual(false); + expect(res.archived).toEqual(false); + expect(res.content.type).toEqual("link"); + }); + + test<CustomTestContext>("delete bookmark", async ({ apiCallers }) => { + const api = apiCallers[0].bookmarks; + + // Create the bookmark + const bookmark = await api.bookmarkLink({ + url: "https://google.com", + type: "link", + }); + + // It should exist + await api.getBookmark({ bookmarkId: bookmark.id }); + + // Delete it + await api.deleteBookmark({ bookmarkId: bookmark.id }); + + // It shouldn't be there anymore + await expect(() => + api.getBookmark({ bookmarkId: bookmark.id }), + ).rejects.toThrow(/Bookmark not found/); + }); + + test<CustomTestContext>("update bookmark", async ({ apiCallers }) => { + const api = apiCallers[0].bookmarks; + + // Create the bookmark + const bookmark = await api.bookmarkLink({ + url: "https://google.com", + type: "link", + }); + + await api.updateBookmark({ + bookmarkId: bookmark.id, + archived: true, + favourited: true, + }); + + const res = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(res.archived).toBeTruthy(); + expect(res.favourited).toBeTruthy(); + }); + + test<CustomTestContext>("list bookmarks", async ({ apiCallers }) => { + const api = apiCallers[0].bookmarks; + const emptyBookmarks = await api.getBookmarks({}); + expect(emptyBookmarks.bookmarks.length).toEqual(0); + + const bookmark1 = await api.bookmarkLink({ + url: "https://google.com", + type: "link", + }); + + const bookmark2 = await api.bookmarkLink({ + url: "https://google2.com", + type: "link", + }); + + { + const bookmarks = await api.getBookmarks({}); + expect(bookmarks.bookmarks.length).toEqual(2); + } + + // Archive and favourite bookmark1 + await api.updateBookmark({ + bookmarkId: bookmark1.id, + archived: true, + favourited: true, + }); + + { + const bookmarks = await api.getBookmarks({ archived: false }); + expect(bookmarks.bookmarks.length).toEqual(1); + expect(bookmarks.bookmarks[0].id).toEqual(bookmark2.id); + } + + { + const bookmarks = await api.getBookmarks({ favourited: true }); + expect(bookmarks.bookmarks.length).toEqual(1); + expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id); + } + + { + const bookmarks = await api.getBookmarks({ archived: true }); + expect(bookmarks.bookmarks.length).toEqual(1); + expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id); + } + + { + const bookmarks = await api.getBookmarks({ ids: [bookmark1.id] }); + expect(bookmarks.bookmarks.length).toEqual(1); + expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id); + } + }); + + test<CustomTestContext>("update tags", async ({ apiCallers }) => { + const api = apiCallers[0].bookmarks; + let bookmark = await api.bookmarkLink({ + url: "https://google.com", + type: "link", + }); + + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [{ tag: "tag1" }, { tag: "tag2" }], + detach: [], + }); + + bookmark = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag1", "tag2"]); + + const tag1Id = bookmark.tags.filter((t) => t.name == "tag1")[0].id; + + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [{ tag: "tag3" }], + detach: [{ tagId: tag1Id }], + }); + + bookmark = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag2", "tag3"]); + }); + + test<CustomTestContext>("privacy", async ({ apiCallers }) => { + const user1Bookmark = await apiCallers[0].bookmarks.bookmarkLink({ + type: "link", + url: "https://google.com", + }); + const user2Bookmark = await apiCallers[1].bookmarks.bookmarkLink({ + type: "link", + url: "https://google.com", + }); + + // All interactions with the wrong user should fail + await expect(() => + apiCallers[0].bookmarks.deleteBookmark({ bookmarkId: user2Bookmark.id }), + ).rejects.toThrow(/User is not allowed to access resource/); + await expect(() => + apiCallers[0].bookmarks.getBookmark({ bookmarkId: user2Bookmark.id }), + ).rejects.toThrow(/User is not allowed to access resource/); + await expect(() => + apiCallers[0].bookmarks.updateBookmark({ bookmarkId: user2Bookmark.id }), + ).rejects.toThrow(/User is not allowed to access resource/); + await expect(() => + apiCallers[0].bookmarks.updateTags({ + bookmarkId: user2Bookmark.id, + attach: [], + detach: [], + }), + ).rejects.toThrow(/User is not allowed to access resource/); + + // Get bookmarks should only show the correct one + expect( + (await apiCallers[0].bookmarks.getBookmarks({})).bookmarks.map( + (b) => b.id, + ), + ).toEqual([user1Bookmark.id]); + expect( + (await apiCallers[1].bookmarks.getBookmarks({})).bookmarks.map( + (b) => b.id, + ), + ).toEqual([user2Bookmark.id]); + }); +}); diff --git a/packages/web/server/api/routers/bookmarks.ts b/packages/web/server/api/routers/bookmarks.ts index 3070eac3..64755e4e 100644 --- a/packages/web/server/api/routers/bookmarks.ts +++ b/packages/web/server/api/routers/bookmarks.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { authedProcedure, router } from "../trpc"; +import { Context, authedProcedure, router } from "../trpc"; import { ZBookmark, ZBookmarkContent, @@ -10,7 +10,6 @@ import { zNewBookmarkRequestSchema, zUpdateBookmarksRequestSchema, } from "@/lib/types/api/bookmarks"; -import { db } from "@hoarder/db"; import { bookmarkLinks, bookmarkTags, @@ -19,20 +18,27 @@ import { } from "@hoarder/db/schema"; import { LinkCrawlerQueue } from "@hoarder/shared/queues"; import { TRPCError, experimental_trpcMiddleware } from "@trpc/server"; -import { User } from "next-auth"; import { and, desc, eq, inArray } from "drizzle-orm"; import { ZBookmarkTags } from "@/lib/types/api/tags"; +import { db as DONT_USE_db } from "@hoarder/db"; + const ensureBookmarkOwnership = experimental_trpcMiddleware<{ - ctx: { user: User }; + ctx: Context; input: { bookmarkId: string }; }>().create(async (opts) => { - const bookmark = await db.query.bookmarks.findFirst({ + const bookmark = await opts.ctx.db.query.bookmarks.findFirst({ where: eq(bookmarks.id, opts.input.bookmarkId), columns: { userId: true, }, }); + if (!opts.ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "User is not authorized", + }); + } if (!bookmark) { throw new TRPCError({ code: "NOT_FOUND", @@ -50,7 +56,7 @@ const ensureBookmarkOwnership = experimental_trpcMiddleware<{ }); async function dummyDrizzleReturnType() { - const x = await db.query.bookmarks.findFirst({ + const x = await DONT_USE_db.query.bookmarks.findFirst({ with: { tagsOnBookmarks: { with: { @@ -95,37 +101,39 @@ export const bookmarksAppRouter = router({ .mutation(async ({ input, ctx }) => { const { url } = input; - const bookmark = await db.transaction(async (tx): Promise<ZBookmark> => { - const bookmark = ( - await tx - .insert(bookmarks) - .values({ - userId: ctx.user.id, - }) - .returning() - )[0]; + const bookmark = await ctx.db.transaction( + async (tx): Promise<ZBookmark> => { + const bookmark = ( + await tx + .insert(bookmarks) + .values({ + userId: ctx.user.id, + }) + .returning() + )[0]; - const link = ( - await tx - .insert(bookmarkLinks) - .values({ - id: bookmark.id, - url, - }) - .returning() - )[0]; + const link = ( + await tx + .insert(bookmarkLinks) + .values({ + id: bookmark.id, + url, + }) + .returning() + )[0]; - const content: ZBookmarkContent = { - type: "link", - ...link, - }; + const content: ZBookmarkContent = { + type: "link", + ...link, + }; - return { - tags: [] as ZBookmarkTags[], - content, - ...bookmark, - }; - }); + return { + tags: [] as ZBookmarkTags[], + content, + ...bookmark, + }; + }, + ); // Enqueue crawling request await LinkCrawlerQueue.add("crawl", { @@ -140,7 +148,7 @@ export const bookmarksAppRouter = router({ .output(zBareBookmarkSchema) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { - const res = await db + const res = await ctx.db .update(bookmarks) .set({ archived: input.archived, @@ -166,7 +174,7 @@ export const bookmarksAppRouter = router({ .input(z.object({ bookmarkId: z.string() })) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { - await db + await ctx.db .delete(bookmarks) .where( and( @@ -186,15 +194,16 @@ export const bookmarksAppRouter = router({ getBookmark: authedProcedure .input( z.object({ - id: z.string(), + bookmarkId: z.string(), }), ) .output(zBookmarkSchema) + .use(ensureBookmarkOwnership) .query(async ({ input, ctx }) => { - const bookmark = await db.query.bookmarks.findFirst({ + const bookmark = await ctx.db.query.bookmarks.findFirst({ where: and( eq(bookmarks.userId, ctx.user.id), - eq(bookmarks.id, input.id), + eq(bookmarks.id, input.bookmarkId), ), with: { tagsOnBookmarks: { @@ -218,7 +227,7 @@ export const bookmarksAppRouter = router({ .input(zGetBookmarksRequestSchema) .output(zGetBookmarksResponseSchema) .query(async ({ input, ctx }) => { - const results = await db.query.bookmarks.findMany({ + const results = await ctx.db.query.bookmarks.findMany({ where: and( eq(bookmarks.userId, ctx.user.id), input.archived !== undefined @@ -253,22 +262,24 @@ export const bookmarksAppRouter = router({ tag: z.string(), }), ), - detach: z.array(z.string()), + // Detach by tag ids + detach: z.array(z.object({ tagId: z.string() })), }), ) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { - await db.transaction(async (tx) => { + await ctx.db.transaction(async (tx) => { // Detaches if (input.detach.length > 0) { - await db - .delete(tagsOnBookmarks) - .where( - and( - eq(tagsOnBookmarks.bookmarkId, input.bookmarkId), - inArray(tagsOnBookmarks.tagId, input.detach), + await ctx.db.delete(tagsOnBookmarks).where( + and( + eq(tagsOnBookmarks.bookmarkId, input.bookmarkId), + inArray( + tagsOnBookmarks.tagId, + input.detach.map((t) => t.tagId), ), - ); + ), + ); } if (input.attach.length == 0) { @@ -284,7 +295,7 @@ export const bookmarksAppRouter = router({ })); if (toBeCreatedTags.length > 0) { - await db + await ctx.db .insert(bookmarkTags) .values(toBeCreatedTags) .onConflictDoNothing() @@ -292,7 +303,7 @@ export const bookmarksAppRouter = router({ } const allIds = ( - await db.query.bookmarkTags.findMany({ + await ctx.db.query.bookmarkTags.findMany({ where: and( eq(bookmarkTags.userId, ctx.user.id), inArray( @@ -306,7 +317,7 @@ export const bookmarksAppRouter = router({ }) ).map((t) => t.id); - await db + await ctx.db .insert(tagsOnBookmarks) .values( allIds.map((i) => ({ diff --git a/packages/web/server/api/routers/tags.ts b/packages/web/server/api/routers/tags.ts deleted file mode 100644 index e69de29b..00000000 --- a/packages/web/server/api/routers/tags.ts +++ /dev/null diff --git a/packages/web/server/api/routers/users.ts b/packages/web/server/api/routers/users.ts index 032385ac..3078a42a 100644 --- a/packages/web/server/api/routers/users.ts +++ b/packages/web/server/api/routers/users.ts @@ -1,6 +1,6 @@ import { zSignUpSchema } from "@/lib/types/api/users"; import { publicProcedure, router } from "../trpc"; -import { SqliteError, db } from "@hoarder/db"; +import { SqliteError } from "@hoarder/db"; import { z } from "zod"; import { hashPassword } from "@/server/auth"; import { TRPCError } from "@trpc/server"; @@ -15,9 +15,9 @@ export const usersAppRouter = router({ email: z.string(), }), ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { - const result = await db + const result = await ctx.db .insert(users) .values({ name: input.name, diff --git a/packages/web/server/api/trpc.ts b/packages/web/server/api/trpc.ts index 7df98372..93fc961a 100644 --- a/packages/web/server/api/trpc.ts +++ b/packages/web/server/api/trpc.ts @@ -1,3 +1,4 @@ +import { db } from "@hoarder/db"; import serverConfig from "@hoarder/shared/config"; import { TRPCError, initTRPC } from "@trpc/server"; import { User } from "next-auth"; @@ -5,6 +6,7 @@ import superjson from "superjson"; export type Context = { user: User | null; + db: typeof db; }; // Avoid exporting the entire t-object diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts new file mode 100644 index 00000000..c3d02f71 --- /dev/null +++ b/packages/web/vitest.config.ts @@ -0,0 +1,14 @@ +/// <reference types="vitest" /> + +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + alias: { + "@/*": "./*", + }, + }, +}); |
