From 058e7238840b362135fd080045478025e31bf720 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 30 Dec 2024 16:55:49 +0000 Subject: chore: Setup and add e2e tests for the API endpoints --- packages/e2e_tests/docker-compose.yml | 12 + packages/e2e_tests/package.json | 33 +++ packages/e2e_tests/setup/seed.ts | 27 +++ packages/e2e_tests/setup/startContainers.ts | 75 ++++++ packages/e2e_tests/tests/api/assets.test.ts | 134 +++++++++++ packages/e2e_tests/tests/api/bookmarks.test.ts | 288 ++++++++++++++++++++++++ packages/e2e_tests/tests/api/highlights.test.ts | 242 ++++++++++++++++++++ packages/e2e_tests/tests/api/lists.test.ts | 185 +++++++++++++++ packages/e2e_tests/tests/api/tags.test.ts | 215 ++++++++++++++++++ packages/e2e_tests/tsconfig.json | 9 + packages/e2e_tests/utils/api.ts | 57 +++++ packages/e2e_tests/utils/trpc.ts | 20 ++ packages/e2e_tests/vitest.config.ts | 17 ++ packages/open-api/package.json | 2 +- pnpm-lock.yaml | 159 +++++++++++-- 15 files changed, 1454 insertions(+), 21 deletions(-) create mode 100644 packages/e2e_tests/docker-compose.yml create mode 100644 packages/e2e_tests/package.json create mode 100644 packages/e2e_tests/setup/seed.ts create mode 100644 packages/e2e_tests/setup/startContainers.ts create mode 100644 packages/e2e_tests/tests/api/assets.test.ts create mode 100644 packages/e2e_tests/tests/api/bookmarks.test.ts create mode 100644 packages/e2e_tests/tests/api/highlights.test.ts create mode 100644 packages/e2e_tests/tests/api/lists.test.ts create mode 100644 packages/e2e_tests/tests/api/tags.test.ts create mode 100644 packages/e2e_tests/tsconfig.json create mode 100644 packages/e2e_tests/utils/api.ts create mode 100644 packages/e2e_tests/utils/trpc.ts create mode 100644 packages/e2e_tests/vitest.config.ts diff --git a/packages/e2e_tests/docker-compose.yml b/packages/e2e_tests/docker-compose.yml new file mode 100644 index 00000000..2c75057c --- /dev/null +++ b/packages/e2e_tests/docker-compose.yml @@ -0,0 +1,12 @@ +services: + hoarder: + build: + dockerfile: docker/Dockerfile + context: ../../ + target: aio + restart: unless-stopped + ports: + - "${HOARDER_PORT:-3000}:3000" + environment: + DATA_DIR: /tmp + NEXTAUTH_SECRET: secret diff --git a/packages/e2e_tests/package.json b/packages/e2e_tests/package.json new file mode 100644 index 00000000..0e407a62 --- /dev/null +++ b/packages/e2e_tests/package.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@hoarder/e2e_tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "format": "prettier . --ignore-path ../../.prettierignore", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@hoarder/trpc": "workspace:^0.1.0", + "@hoarderapp/sdk": "workspace:^0.20.0", + "superjson": "^2.2.1" + }, + "devDependencies": { + "@hoarder/eslint-config": "workspace:^0.2.0", + "@hoarder/prettier-config": "workspace:^0.1.0", + "@hoarder/tsconfig": "workspace:^0.1.0", + "vite-tsconfig-paths": "^4.3.1", + "vitest": "^1.3.1" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@hoarder/eslint-config/base" + ] + }, + "prettier": "@hoarder/prettier-config" +} diff --git a/packages/e2e_tests/setup/seed.ts b/packages/e2e_tests/setup/seed.ts new file mode 100644 index 00000000..9ab68499 --- /dev/null +++ b/packages/e2e_tests/setup/seed.ts @@ -0,0 +1,27 @@ +import { GlobalSetupContext } from "vitest/node"; + +import { getTrpcClient } from "../utils/trpc"; + +export async function setup({ provide }: GlobalSetupContext) { + const trpc = getTrpcClient(); + await trpc.users.create.mutate({ + name: "Test User", + email: "admin@example.com", + password: "test1234", + confirmPassword: "test1234", + }); + + const { key } = await trpc.apiKeys.exchange.mutate({ + email: "admin@example.com", + password: "test1234", + keyName: "test-key", + }); + provide("adminApiKey", key); + return () => ({}); +} + +declare module "vitest" { + export interface ProvidedContext { + adminApiKey: string; + } +} diff --git a/packages/e2e_tests/setup/startContainers.ts b/packages/e2e_tests/setup/startContainers.ts new file mode 100644 index 00000000..8cc30162 --- /dev/null +++ b/packages/e2e_tests/setup/startContainers.ts @@ -0,0 +1,75 @@ +import { execSync } from "child_process"; +import net from "net"; +import path from "path"; +import { fileURLToPath } from "url"; +import type { GlobalSetupContext } from "vitest/node"; + +async function getRandomPort(): Promise { + const server = net.createServer(); + return new Promise((resolve, reject) => { + server.unref(); + server.on("error", reject); + server.listen(0, () => { + const port = (server.address() as net.AddressInfo).port; + server.close(() => resolve(port)); + }); + }); +} + +async function waitForHealthy(port: number, timeout = 60000): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + try { + const response = await fetch(`http://localhost:${port}/api/health`); + if (response.status === 200) { + return; + } + } catch (error) { + // Ignore errors and retry + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error(`Health check failed after ${timeout}ms`); +} + +export default async function ({ provide }: GlobalSetupContext) { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const port = await getRandomPort(); + + console.log(`Starting docker compose on port ${port}...`); + execSync(`docker compose up -d`, { + cwd: __dirname, + stdio: "inherit", + env: { + ...process.env, + HOARDER_PORT: port.toString(), + }, + }); + + console.log("Waiting for service to become healthy..."); + await waitForHealthy(port); + + // Wait 5 seconds for the worker to start + await new Promise((resolve) => setTimeout(resolve, 5000)); + + provide("hoarderPort", port); + + process.env.HOARDER_PORT = port.toString(); + + return async () => { + console.log("Stopping docker compose..."); + execSync("docker compose down", { + cwd: __dirname, + stdio: "inherit", + }); + return Promise.resolve(); + }; +} + +declare module "vitest" { + export interface ProvidedContext { + hoarderPort: number; + } +} diff --git a/packages/e2e_tests/tests/api/assets.test.ts b/packages/e2e_tests/tests/api/assets.test.ts new file mode 100644 index 00000000..0fab3d3f --- /dev/null +++ b/packages/e2e_tests/tests/api/assets.test.ts @@ -0,0 +1,134 @@ +import { createHoarderClient } from "@hoarderapp/sdk"; +import { assert, beforeEach, describe, expect, inject, it } from "vitest"; + +import { createTestUser, uploadTestAsset } from "../../utils/api"; + +describe("Assets API", () => { + const port = inject("hoarderPort"); + + if (!port) { + throw new Error("Missing required environment variables"); + } + + let client: ReturnType; + let apiKey: string; + + beforeEach(async () => { + apiKey = await createTestUser(); + client = createHoarderClient({ + baseUrl: `http://localhost:${port}/api/v1/`, + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + }); + }); + + it("should upload and retrieve an asset", async () => { + // Create a test file + const file = new File(["test content"], "test.pdf", { + type: "application/pdf", + }); + + // Upload the asset + const uploadResponse = await uploadTestAsset(apiKey, port, file); + expect(uploadResponse.assetId).toBeDefined(); + expect(uploadResponse.contentType).toBe("application/pdf"); + expect(uploadResponse.fileName).toBe("test.pdf"); + + // Retrieve the asset + const resp = await fetch( + `http://localhost:${port}/api/assets/${uploadResponse.assetId}`, + { + headers: { + authorization: `Bearer ${apiKey}`, + }, + }, + ); + + expect(resp.status).toBe(200); + }); + + it("should attach an asset to a bookmark", async () => { + // Create a test file + const file = new File(["test content"], "test.pdf", { + type: "application/pdf", + }); + + // Upload the asset + const uploadResponse = await uploadTestAsset(apiKey, port, file); + + // Create a bookmark + const { data: createdBookmark } = await client.POST("/bookmarks", { + body: { + type: "asset", + title: "Test Asset Bookmark", + assetType: "pdf", + assetId: uploadResponse.assetId, + }, + }); + + expect(createdBookmark).toBeDefined(); + expect(createdBookmark?.id).toBeDefined(); + + // Get the bookmark and verify asset + const { data: retrievedBookmark } = await client.GET( + "/bookmarks/{bookmarkId}", + { + params: { + path: { + bookmarkId: createdBookmark!.id, + }, + }, + }, + ); + + expect(retrievedBookmark).toBeDefined(); + assert(retrievedBookmark!.content.type === "asset"); + expect(retrievedBookmark!.content.assetId).toBe(uploadResponse.assetId); + }); + + it("should delete asset when deleting bookmark", async () => { + // Create a test file + const file = new File(["test content"], "test.pdf", { + type: "application/pdf", + }); + + // Upload the asset + const uploadResponse = await uploadTestAsset(apiKey, port, file); + + // Create a bookmark + const { data: createdBookmark } = await client.POST("/bookmarks", { + body: { + type: "asset", + title: "Test Asset Bookmark", + assetType: "pdf", + assetId: uploadResponse.assetId, + }, + }); + + // Delete the bookmark + const { response: deleteResponse } = await client.DELETE( + "/bookmarks/{bookmarkId}", + { + params: { + path: { + bookmarkId: createdBookmark!.id, + }, + }, + }, + ); + expect(deleteResponse.status).toBe(204); + + // Verify asset is deleted + const assetResponse = await fetch( + `http://localhost:${port}/api/assets/${uploadResponse.assetId}`, + { + headers: { + authorization: `Bearer ${apiKey}`, + }, + }, + ); + expect(assetResponse.status).toBe(404); + }); +}); diff --git a/packages/e2e_tests/tests/api/bookmarks.test.ts b/packages/e2e_tests/tests/api/bookmarks.test.ts new file mode 100644 index 00000000..727ca758 --- /dev/null +++ b/packages/e2e_tests/tests/api/bookmarks.test.ts @@ -0,0 +1,288 @@ +import { createHoarderClient } from "@hoarderapp/sdk"; +import { assert, beforeEach, describe, expect, inject, it } from "vitest"; + +import { createTestUser } from "../../utils/api"; + +describe("Bookmarks API", () => { + const port = inject("hoarderPort"); + + if (!port) { + throw new Error("Missing required environment variables"); + } + + let client: ReturnType; + let apiKey: string; + + beforeEach(async () => { + apiKey = await createTestUser(); + client = createHoarderClient({ + baseUrl: `http://localhost:${port}/api/v1/`, + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + }); + }); + + it("should create and retrieve a bookmark", async () => { + // Create a new bookmark + const { + data: createdBookmark, + response: createResponse, + error, + } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }); + + if (error) { + console.error("Error creating bookmark:", error); + } + + expect(createResponse.status).toBe(201); + expect(createdBookmark).toBeDefined(); + expect(createdBookmark?.id).toBeDefined(); + + // Get the created bookmark + const { data: retrievedBookmark, response: getResponse } = await client.GET( + "/bookmarks/{bookmarkId}", + { + params: { + path: { + bookmarkId: createdBookmark.id, + }, + }, + }, + ); + + expect(getResponse.status).toBe(200); + expect(retrievedBookmark!.id).toBe(createdBookmark.id); + expect(retrievedBookmark!.title).toBe("Test Bookmark"); + assert(retrievedBookmark!.content.type === "text"); + expect(retrievedBookmark!.content.text).toBe("This is a test bookmark"); + }); + + it("should update a bookmark", async () => { + // Create a new bookmark + const { data: createdBookmark, error: createError } = await client.POST( + "/bookmarks", + { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }, + ); + + if (createError) { + console.error("Error creating bookmark:", createError); + throw createError; + } + if (!createdBookmark) { + throw new Error("Bookmark creation failed"); + } + + // Update the bookmark + const { data: updatedBookmark, response: updateResponse } = + await client.PATCH("/bookmarks/{bookmarkId}", { + params: { + path: { + bookmarkId: createdBookmark.id, + }, + }, + body: { + title: "Updated Title", + }, + }); + + expect(updateResponse.status).toBe(200); + expect(updatedBookmark!.title).toBe("Updated Title"); + }); + + it("should delete a bookmark", async () => { + // Create a new bookmark + const { data: createdBookmark, error: createError } = await client.POST( + "/bookmarks", + { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }, + ); + + if (createError) { + console.error("Error creating bookmark:", createError); + throw createError; + } + if (!createdBookmark) { + throw new Error("Bookmark creation failed"); + } + + // Delete the bookmark + const { response: deleteResponse } = await client.DELETE( + "/bookmarks/{bookmarkId}", + { + params: { + path: { + bookmarkId: createdBookmark.id, + }, + }, + }, + ); + + expect(deleteResponse.status).toBe(204); + + // Verify it's deleted + const { response: getResponse } = await client.GET( + "/bookmarks/{bookmarkId}", + { + params: { + path: { + bookmarkId: createdBookmark.id, + }, + }, + }, + ); + + expect(getResponse.status).toBe(404); + }); + + it("should paginate through bookmarks", async () => { + // Create multiple bookmarks + const bookmarkPromises = Array.from({ length: 5 }, (_, i) => + client.POST("/bookmarks", { + body: { + type: "text", + title: `Test Bookmark ${i}`, + text: `This is test bookmark ${i}`, + }, + }), + ); + + const createdBookmarks = await Promise.all(bookmarkPromises); + const bookmarkIds = createdBookmarks.map((b) => b.data!.id); + + // Get first page + const { data: firstPage, response: firstResponse } = await client.GET( + "/bookmarks", + { + params: { + query: { + limit: 2, + }, + }, + }, + ); + + expect(firstResponse.status).toBe(200); + expect(firstPage!.bookmarks.length).toBe(2); + expect(firstPage!.nextCursor).toBeDefined(); + + // Get second page + const { data: secondPage, response: secondResponse } = await client.GET( + "/bookmarks", + { + params: { + query: { + limit: 2, + cursor: firstPage!.nextCursor!, + }, + }, + }, + ); + + expect(secondResponse.status).toBe(200); + expect(secondPage!.bookmarks.length).toBe(2); + expect(secondPage!.nextCursor).toBeDefined(); + + // Get final page + const { data: finalPage, response: finalResponse } = await client.GET( + "/bookmarks", + { + params: { + query: { + limit: 2, + cursor: secondPage!.nextCursor!, + }, + }, + }, + ); + + expect(finalResponse.status).toBe(200); + expect(finalPage!.bookmarks.length).toBe(1); + expect(finalPage!.nextCursor).toBeNull(); + + // Verify all bookmarks were returned + const allBookmarks = [ + ...firstPage!.bookmarks, + ...secondPage!.bookmarks, + ...finalPage!.bookmarks, + ]; + expect(allBookmarks.map((b) => b.id)).toEqual( + expect.arrayContaining(bookmarkIds), + ); + }); + + it("should manage tags on a bookmark", async () => { + // Create a new bookmark + const { data: createdBookmark, error: createError } = await client.POST( + "/bookmarks", + { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }, + ); + + if (createError) { + console.error("Error creating bookmark:", createError); + throw createError; + } + if (!createdBookmark) { + throw new Error("Bookmark creation failed"); + } + + // Add tags + const { data: addTagsResponse, response: addTagsRes } = await client.POST( + "/bookmarks/{bookmarkId}/tags", + { + params: { + path: { + bookmarkId: createdBookmark.id, + }, + }, + body: { + tags: [{ tagName: "test-tag" }], + }, + }, + ); + + expect(addTagsRes.status).toBe(200); + expect(addTagsResponse!.attached.length).toBe(1); + + // Remove tags + const { response: removeTagsRes } = await client.DELETE( + "/bookmarks/{bookmarkId}/tags", + { + params: { + path: { + bookmarkId: createdBookmark.id, + }, + }, + body: { + tags: [{ tagId: addTagsResponse!.attached[0] }], + }, + }, + ); + + expect(removeTagsRes.status).toBe(200); + }); +}); diff --git a/packages/e2e_tests/tests/api/highlights.test.ts b/packages/e2e_tests/tests/api/highlights.test.ts new file mode 100644 index 00000000..94a4d28b --- /dev/null +++ b/packages/e2e_tests/tests/api/highlights.test.ts @@ -0,0 +1,242 @@ +import { createHoarderClient } from "@hoarderapp/sdk"; +import { beforeEach, describe, expect, inject, it } from "vitest"; + +import { createTestUser } from "../../utils/api"; + +describe("Highlights API", () => { + const port = inject("hoarderPort"); + + if (!port) { + throw new Error("Missing required environment variables"); + } + + let client: ReturnType; + let apiKey: string; + + beforeEach(async () => { + apiKey = await createTestUser(); + client = createHoarderClient({ + baseUrl: `http://localhost:${port}/api/v1/`, + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + }); + }); + + it("should create, get, update and delete a highlight", async () => { + // Create a bookmark first + const { data: createdBookmark } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }); + + // Create a new highlight + const { data: createdHighlight, response: createResponse } = + await client.POST("/highlights", { + body: { + bookmarkId: createdBookmark!.id, + startOffset: 0, + endOffset: 5, + text: "This ", + note: "Test note", + color: "yellow", + }, + }); + + expect(createResponse.status).toBe(201); + expect(createdHighlight).toBeDefined(); + expect(createdHighlight?.id).toBeDefined(); + expect(createdHighlight?.text).toBe("This "); + expect(createdHighlight?.note).toBe("Test note"); + + // Get the created highlight + const { data: retrievedHighlight, response: getResponse } = + await client.GET("/highlights/{highlightId}", { + params: { + path: { + highlightId: createdHighlight!.id, + }, + }, + }); + + expect(getResponse.status).toBe(200); + expect(retrievedHighlight!.id).toBe(createdHighlight!.id); + expect(retrievedHighlight!.text).toBe("This "); + expect(retrievedHighlight!.note).toBe("Test note"); + + // Update the highlight + const { data: updatedHighlight, response: updateResponse } = + await client.PATCH("/highlights/{highlightId}", { + params: { + path: { + highlightId: createdHighlight!.id, + }, + }, + body: { + color: "blue", + }, + }); + + expect(updateResponse.status).toBe(200); + expect(updatedHighlight!.color).toBe("blue"); + + // Delete the highlight + const { response: deleteResponse } = await client.DELETE( + "/highlights/{highlightId}", + { + params: { + path: { + highlightId: createdHighlight!.id, + }, + }, + }, + ); + + expect(deleteResponse.status).toBe(200); + + // Verify it's deleted + const { response: getDeletedResponse } = await client.GET( + "/highlights/{highlightId}", + { + params: { + path: { + highlightId: createdHighlight!.id, + }, + }, + }, + ); + + expect(getDeletedResponse.status).toBe(404); + }); + + it("should paginate through highlights", async () => { + // Create a bookmark first + const { data: createdBookmark } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }); + + // Create multiple highlights + const highlightPromises = Array.from({ length: 5 }, (_, i) => + client.POST("/highlights", { + body: { + bookmarkId: createdBookmark!.id, + startOffset: i * 5, + endOffset: (i + 1) * 5, + text: `Highlight ${i}`, + note: `Note ${i}`, + }, + }), + ); + + await Promise.all(highlightPromises); + + // Get first page + const { data: firstPage, response: firstResponse } = await client.GET( + "/highlights", + { + params: { + query: { + limit: 2, + }, + }, + }, + ); + + expect(firstResponse.status).toBe(200); + expect(firstPage!.highlights.length).toBe(2); + expect(firstPage!.nextCursor).toBeDefined(); + + // Get second page + const { data: secondPage, response: secondResponse } = await client.GET( + "/highlights", + { + params: { + query: { + limit: 2, + cursor: firstPage!.nextCursor!, + }, + }, + }, + ); + + expect(secondResponse.status).toBe(200); + expect(secondPage!.highlights.length).toBe(2); + expect(secondPage!.nextCursor).toBeDefined(); + + // Get final page + const { data: finalPage, response: finalResponse } = await client.GET( + "/highlights", + { + params: { + query: { + limit: 2, + cursor: secondPage!.nextCursor!, + }, + }, + }, + ); + + expect(finalResponse.status).toBe(200); + expect(finalPage!.highlights.length).toBe(1); + expect(finalPage!.nextCursor).toBeNull(); + }); + + it("should get highlights for a bookmark", async () => { + // Create a bookmark first + const { data: createdBookmark } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }); + + // Create highlights + await client.POST("/highlights", { + body: { + bookmarkId: createdBookmark!.id, + startOffset: 0, + endOffset: 5, + text: "This ", + note: "First highlight", + color: "yellow", + }, + }); + + await client.POST("/highlights", { + body: { + bookmarkId: createdBookmark!.id, + startOffset: 5, + endOffset: 10, + text: "is a ", + note: "Second highlight", + color: "blue", + }, + }); + + // Get highlights for bookmark + const { data: highlights, response: getResponse } = await client.GET( + "/bookmarks/{bookmarkId}/highlights", + { + params: { + path: { + bookmarkId: createdBookmark!.id, + }, + }, + }, + ); + + expect(getResponse.status).toBe(200); + expect(highlights!.highlights.length).toBe(2); + expect(highlights!.highlights.map((h) => h.text)).toContain("This "); + expect(highlights!.highlights.map((h) => h.text)).toContain("is a "); + }); +}); diff --git a/packages/e2e_tests/tests/api/lists.test.ts b/packages/e2e_tests/tests/api/lists.test.ts new file mode 100644 index 00000000..2a954b6f --- /dev/null +++ b/packages/e2e_tests/tests/api/lists.test.ts @@ -0,0 +1,185 @@ +import { createHoarderClient } from "@hoarderapp/sdk"; +import { beforeEach, describe, expect, inject, it } from "vitest"; + +import { createTestUser } from "../../utils/api"; + +describe("Lists API", () => { + const port = inject("hoarderPort"); + + if (!port) { + throw new Error("Missing required environment variables"); + } + + let client: ReturnType; + let apiKey: string; + + beforeEach(async () => { + apiKey = await createTestUser(); + client = createHoarderClient({ + baseUrl: `http://localhost:${port}/api/v1/`, + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + }); + }); + + it("should create, get, update and delete a list", async () => { + // Create a new list + const { data: createdList, response: createResponse } = await client.POST( + "/lists", + { + body: { + name: "Test List", + icon: "🚀", + }, + }, + ); + + expect(createResponse.status).toBe(201); + expect(createdList).toBeDefined(); + expect(createdList?.id).toBeDefined(); + expect(createdList?.name).toBe("Test List"); + + // Get the created list + const { data: retrievedList, response: getResponse } = await client.GET( + "/lists/{listId}", + { + params: { + path: { + listId: createdList!.id, + }, + }, + }, + ); + + expect(getResponse.status).toBe(200); + expect(retrievedList!.id).toBe(createdList!.id); + expect(retrievedList!.name).toBe("Test List"); + + // Update the list + const { data: updatedList, response: updateResponse } = await client.PATCH( + "/lists/{listId}", + { + params: { + path: { + listId: createdList!.id, + }, + }, + body: { + name: "Updated List", + }, + }, + ); + + expect(updateResponse.status).toBe(200); + expect(updatedList!.name).toBe("Updated List"); + + // Delete the list + const { response: deleteResponse } = await client.DELETE( + "/lists/{listId}", + { + params: { + path: { + listId: createdList!.id, + }, + }, + }, + ); + + expect(deleteResponse.status).toBe(204); + + // Verify it's deleted + const { response: getDeletedResponse } = await client.GET( + "/lists/{listId}", + { + params: { + path: { + listId: createdList!.id, + }, + }, + }, + ); + + expect(getDeletedResponse.status).toBe(404); + }); + + it("should manage bookmarks in a list", async () => { + // Create a list + const { data: createdList } = await client.POST("/lists", { + body: { + name: "Test List", + icon: "🚀", + }, + }); + + // Create a bookmark + const { data: createdBookmark } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }); + + // Add bookmark to list + const { response: addResponse } = await client.PUT( + "/lists/{listId}/bookmarks/{bookmarkId}", + { + params: { + path: { + listId: createdList!.id, + bookmarkId: createdBookmark!.id, + }, + }, + }, + ); + + expect(addResponse.status).toBe(204); + + // Get bookmarks in list + const { data: listBookmarks, response: getResponse } = await client.GET( + "/lists/{listId}/bookmarks", + { + params: { + path: { + listId: createdList!.id, + }, + }, + }, + ); + + expect(getResponse.status).toBe(200); + expect(listBookmarks!.bookmarks.length).toBe(1); + expect(listBookmarks!.bookmarks[0].id).toBe(createdBookmark!.id); + + // Remove bookmark from list + const { response: removeResponse } = await client.DELETE( + "/lists/{listId}/bookmarks/{bookmarkId}", + { + params: { + path: { + listId: createdList!.id, + bookmarkId: createdBookmark!.id, + }, + }, + }, + ); + + expect(removeResponse.status).toBe(204); + + // Verify bookmark is removed + const { data: updatedListBookmarks } = await client.GET( + "/lists/{listId}/bookmarks", + { + params: { + path: { + listId: createdList!.id, + }, + }, + }, + ); + + expect(updatedListBookmarks!.bookmarks.length).toBe(0); + }); +}); diff --git a/packages/e2e_tests/tests/api/tags.test.ts b/packages/e2e_tests/tests/api/tags.test.ts new file mode 100644 index 00000000..dfd3f14b --- /dev/null +++ b/packages/e2e_tests/tests/api/tags.test.ts @@ -0,0 +1,215 @@ +import { createHoarderClient } from "@hoarderapp/sdk"; +import { beforeEach, describe, expect, inject, it } from "vitest"; + +import { createTestUser } from "../../utils/api"; + +describe("Tags API", () => { + const port = inject("hoarderPort"); + + if (!port) { + throw new Error("Missing required environment variables"); + } + + let client: ReturnType; + let apiKey: string; + + beforeEach(async () => { + apiKey = await createTestUser(); + client = createHoarderClient({ + baseUrl: `http://localhost:${port}/api/v1/`, + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + }); + }); + + it("should get, update and delete a tag", async () => { + // Create a bookmark first + const { data: createdBookmark } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }); + + // Create a tag by attaching it to the bookmark + const { data: addTagResponse } = await client.POST( + "/bookmarks/{bookmarkId}/tags", + { + params: { + path: { + bookmarkId: createdBookmark!.id, + }, + }, + body: { + tags: [{ tagName: "Test Tag" }], + }, + }, + ); + + const tagId = addTagResponse!.attached[0]; + + // Get the tag + const { data: retrievedTag, response: getResponse } = await client.GET( + "/tags/{tagId}", + { + params: { + path: { + tagId, + }, + }, + }, + ); + + expect(getResponse.status).toBe(200); + expect(retrievedTag!.id).toBe(tagId); + expect(retrievedTag!.name).toBe("Test Tag"); + + // Update the tag + const { data: updatedTag, response: updateResponse } = await client.PATCH( + "/tags/{tagId}", + { + params: { + path: { + tagId, + }, + }, + body: { + name: "Updated Tag", + }, + }, + ); + + expect(updateResponse.status).toBe(200); + expect(updatedTag!.name).toBe("Updated Tag"); + + // Delete the tag + const { response: deleteResponse } = await client.DELETE("/tags/{tagId}", { + params: { + path: { + tagId, + }, + }, + }); + + expect(deleteResponse.status).toBe(204); + + // Verify it's deleted + const { response: getDeletedResponse } = await client.GET("/tags/{tagId}", { + params: { + path: { + tagId, + }, + }, + }); + + expect(getDeletedResponse.status).toBe(404); + }); + + it("should manage bookmarks with a tag", async () => { + // Create a bookmark first + const { data: firstBookmark } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + }, + }); + + // Create a tag by attaching it to the bookmark + const { data: addTagResponse } = await client.POST( + "/bookmarks/{bookmarkId}/tags", + { + params: { + path: { + bookmarkId: firstBookmark!.id, + }, + }, + body: { + tags: [{ tagName: "Test Tag" }], + }, + }, + ); + + const tagId = addTagResponse!.attached[0]; + + // Add tag to another bookmark + const { data: secondBookmark } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Second Bookmark", + text: "This is another test bookmark", + }, + }); + + const { data: addSecondTagResponse, response: addResponse } = + await client.POST("/bookmarks/{bookmarkId}/tags", { + params: { + path: { + bookmarkId: secondBookmark!.id, + }, + }, + body: { + tags: [{ tagId }], + }, + }); + + expect(addResponse.status).toBe(200); + expect(addSecondTagResponse!.attached.length).toBe(1); + + // Get bookmarks with tag + const { data: taggedBookmarks, response: getResponse } = await client.GET( + "/tags/{tagId}/bookmarks", + { + params: { + path: { + tagId, + }, + }, + }, + ); + + expect(getResponse.status).toBe(200); + expect(taggedBookmarks!.bookmarks.length).toBe(2); + expect(taggedBookmarks!.bookmarks.map((b) => b.id)).toContain( + firstBookmark!.id, + ); + expect(taggedBookmarks!.bookmarks.map((b) => b.id)).toContain( + secondBookmark!.id, + ); + + // Remove tag from first bookmark + const { response: removeResponse } = await client.DELETE( + "/bookmarks/{bookmarkId}/tags", + { + params: { + path: { + bookmarkId: firstBookmark!.id, + }, + }, + body: { + tags: [{ tagId }], + }, + }, + ); + + expect(removeResponse.status).toBe(200); + + // Verify tag is still on second bookmark + const { data: updatedTaggedBookmarks } = await client.GET( + "/tags/{tagId}/bookmarks", + { + params: { + path: { + tagId, + }, + }, + }, + ); + + expect(updatedTaggedBookmarks!.bookmarks.length).toBe(1); + expect(updatedTaggedBookmarks!.bookmarks[0].id).toBe(secondBookmark!.id); + }); +}); diff --git a/packages/e2e_tests/tsconfig.json b/packages/e2e_tests/tsconfig.json new file mode 100644 index 00000000..dbd0afdc --- /dev/null +++ b/packages/e2e_tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@hoarder/tsconfig/node.json", + "include": ["**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + } +} diff --git a/packages/e2e_tests/utils/api.ts b/packages/e2e_tests/utils/api.ts new file mode 100644 index 00000000..84a6eb91 --- /dev/null +++ b/packages/e2e_tests/utils/api.ts @@ -0,0 +1,57 @@ +import { getTrpcClient } from "./trpc"; + +export function getAuthHeader(apiKey: string) { + return { + "Content-Type": "application/json", + authorization: `Bearer ${apiKey}`, + }; +} + +export async function uploadTestAsset( + apiKey: string, + port: number, + file: File, +) { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch(`http://localhost:${port}/api/assets`, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + }, + body: formData, + }); + + if (!response.ok) { + throw new Error(`Failed to upload asset: ${response.statusText}`); + } + + return response.json() as Promise<{ + assetId: string; + contentType: string; + fileName: string; + }>; +} + +export async function createTestUser() { + const trpc = getTrpcClient(); + + const random = Math.random().toString(36).substring(7); + const email = `testuser+${random}@example.com`; + + await trpc.users.create.mutate({ + name: "Test User", + email, + password: "test1234", + confirmPassword: "test1234", + }); + + const { key } = await trpc.apiKeys.exchange.mutate({ + email, + password: "test1234", + keyName: "test-key", + }); + + return key; +} diff --git a/packages/e2e_tests/utils/trpc.ts b/packages/e2e_tests/utils/trpc.ts new file mode 100644 index 00000000..7d916d93 --- /dev/null +++ b/packages/e2e_tests/utils/trpc.ts @@ -0,0 +1,20 @@ +import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import superjson from "superjson"; + +import type { AppRouter } from "@hoarder/trpc/routers/_app"; + +export function getTrpcClient(apiKey?: string) { + return createTRPCClient({ + links: [ + httpBatchLink({ + transformer: superjson, + url: `http://localhost:${process.env.HOARDER_PORT}/api/trpc`, + headers() { + return { + authorization: apiKey ? `Bearer ${apiKey}` : undefined, + }; + }, + }), + ], + }); +} diff --git a/packages/e2e_tests/vitest.config.ts b/packages/e2e_tests/vitest.config.ts new file mode 100644 index 00000000..d8c1cec1 --- /dev/null +++ b/packages/e2e_tests/vitest.config.ts @@ -0,0 +1,17 @@ +/// + +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + alias: { + "@/*": "./*", + }, + globalSetup: ["./setup/startContainers.ts", "./setup/seed.ts"], + teardownTimeout: 30000, + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/packages/open-api/package.json b/packages/open-api/package.json index 2d478018..c53d7dc7 100644 --- a/packages/open-api/package.json +++ b/packages/open-api/package.json @@ -17,7 +17,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "generate": "tsx index.ts", + "generate": "tsx index.ts && pnpm dlx openapi-typescript hoarder-openapi-spec.json -o hoarder-api.d.ts", "format": "prettier . --ignore-path ../../.prettierignore", "lint": "eslint ." }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92b55477..2fed988b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,7 +18,7 @@ importers: version: link:tooling/prettier '@tanstack/eslint-plugin-query': specifier: ^5.20.1 - version: 5.20.1(eslint@8.57.0)(typescript@5.3.3) + version: 5.20.1(eslint@8.57.0)(typescript@5.4.2) '@types/node': specifier: ^20 version: 20.11.20 @@ -226,7 +226,7 @@ importers: version: 1.0.2(@types/react@18.2.58)(react@18.3.1) '@svgr/webpack': specifier: ^8.1.0 - version: 8.1.0(typescript@5.3.3) + version: 8.1.0(typescript@5.4.2) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -540,7 +540,7 @@ importers: version: 1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@svgr/webpack': specifier: ^8.1.0 - version: 8.1.0(typescript@5.3.3) + version: 8.1.0(typescript@5.4.2) '@tanstack/react-query': specifier: ^5.24.8 version: 5.24.8(react@18.3.1) @@ -709,7 +709,7 @@ importers: version: 3.4.1 vite-tsconfig-paths: specifier: ^4.3.1 - version: 4.3.1(typescript@5.3.3) + version: 4.3.1(typescript@5.4.2) vitest: specifier: ^1.3.1 version: 1.3.1(@types/node@20.11.20) @@ -930,6 +930,34 @@ importers: specifier: ^0.24.02 version: 0.24.2 + packages/e2e_tests: + dependencies: + '@hoarder/trpc': + specifier: workspace:^0.1.0 + version: link:../trpc + '@hoarderapp/sdk': + specifier: workspace:^0.20.0 + version: link:../sdk + superjson: + specifier: ^2.2.1 + version: 2.2.1 + devDependencies: + '@hoarder/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@hoarder/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@hoarder/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + vite-tsconfig-paths: + specifier: ^4.3.1 + version: 4.3.1(typescript@5.4.2) + vitest: + specifier: ^1.3.1 + version: 1.3.1(@types/node@20.11.20) + packages/open-api: dependencies: '@asteasolutions/zod-to-openapi': @@ -981,7 +1009,7 @@ importers: version: 5.1.4(@types/node@20.11.20) vite-plugin-dts: specifier: ^4.4.0 - version: 4.4.0(@types/node@20.11.20)(typescript@5.3.3)(vite@5.1.4(@types/node@20.11.20)) + version: 4.4.0(@types/node@20.11.20)(typescript@5.4.2)(vite@5.1.4(@types/node@20.11.20)) packages/shared: dependencies: @@ -1092,7 +1120,7 @@ importers: version: 2.4.6 vite-tsconfig-paths: specifier: ^4.3.1 - version: 4.3.1(typescript@5.3.3) + version: 4.3.1(typescript@5.4.2) vitest: specifier: ^1.3.1 version: 1.3.1(@types/node@20.11.20) @@ -18464,6 +18492,18 @@ snapshots: - typescript dev: false + '@svgr/core@8.1.0(typescript@5.4.2)': + dependencies: + '@babel/core': 7.26.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.26.0) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.4.2) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: false + '@svgr/hast-util-to-babel-ast@8.0.0': dependencies: '@babel/types': 7.26.0 @@ -18481,6 +18521,17 @@ snapshots: - supports-color dev: false + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.4.2))': + dependencies: + '@babel/core': 7.26.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.26.0) + '@svgr/core': 8.1.0(typescript@5.4.2) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + dev: false + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.3.3))(typescript@5.3.3)': dependencies: '@svgr/core': 8.1.0(typescript@5.3.3) @@ -18491,6 +18542,16 @@ snapshots: - typescript dev: false + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.4.2))(typescript@5.4.2)': + dependencies: + '@svgr/core': 8.1.0(typescript@5.4.2) + cosmiconfig: 8.3.6(typescript@5.4.2) + deepmerge: 4.3.1 + svgo: 3.2.0 + transitivePeerDependencies: + - typescript + dev: false + '@svgr/webpack@8.1.0(typescript@5.3.3)': dependencies: '@babel/core': 7.26.0 @@ -18506,6 +18567,21 @@ snapshots: - typescript dev: false + '@svgr/webpack@8.1.0(typescript@5.4.2)': + dependencies: + '@babel/core': 7.26.0 + '@babel/plugin-transform-react-constant-elements': 7.23.3(@babel/core@7.26.0) + '@babel/preset-env': 7.24.0(@babel/core@7.26.0) + '@babel/preset-react': 7.23.3(@babel/core@7.26.0) + '@babel/preset-typescript': 7.23.3(@babel/core@7.26.0) + '@svgr/core': 8.1.0(typescript@5.4.2) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.4.2)) + '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(typescript@5.4.2))(typescript@5.4.2) + transitivePeerDependencies: + - supports-color + - typescript + dev: false + '@swc/core-darwin-arm64@1.4.2': dev: true optional: true @@ -18592,9 +18668,9 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.1 - '@tanstack/eslint-plugin-query@5.20.1(eslint@8.57.0)(typescript@5.3.3)': + '@tanstack/eslint-plugin-query@5.20.1(eslint@8.57.0)(typescript@5.4.2)': dependencies: - '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.4.2) eslint: 8.57.0 transitivePeerDependencies: - supports-color @@ -19191,6 +19267,21 @@ snapshots: - supports-color dev: true + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.4.2)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.7 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.6.3 + ts-api-utils: 1.2.1(typescript@5.4.2) + typescript: 5.4.2 + transitivePeerDependencies: + - supports-color + dev: true + '@typescript-eslint/typescript-estree@7.6.0(typescript@5.3.3)': dependencies: '@typescript-eslint/types': 7.6.0 @@ -19221,6 +19312,21 @@ snapshots: - typescript dev: true + '@typescript-eslint/utils@6.21.0(eslint@8.57.0)(typescript@5.4.2)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.8 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.4.2) + eslint: 8.57.0 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + '@typescript-eslint/utils@7.6.0(eslint@8.57.0)(typescript@5.3.3)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) @@ -19348,7 +19454,7 @@ snapshots: he: 1.2.0 dev: true - '@vue/language-core@2.1.10(typescript@5.3.3)': + '@vue/language-core@2.1.10(typescript@5.4.2)': dependencies: '@volar/language-core': 2.4.11 '@vue/compiler-dom': 3.5.13 @@ -19358,7 +19464,7 @@ snapshots: minimatch: 9.0.4 muggle-string: 0.4.1 path-browserify: 1.0.1 - typescript: 5.3.3 + typescript: 5.4.2 dev: true '@vue/shared@3.5.13': @@ -20931,6 +21037,15 @@ snapshots: typescript: 5.3.3 dev: false + cosmiconfig@8.3.6(typescript@5.4.2): + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + typescript: 5.4.2 + dev: false + cosmiconfig@9.0.0(typescript@5.3.3): dependencies: env-paths: 2.2.1 @@ -29716,6 +29831,11 @@ snapshots: typescript: 5.3.3 dev: true + ts-api-utils@1.2.1(typescript@5.4.2): + dependencies: + typescript: 5.4.2 + dev: true + ts-api-utils@1.3.0(typescript@5.3.3): dependencies: typescript: 5.3.3 @@ -29723,9 +29843,9 @@ snapshots: ts-interface-checker@0.1.13: {} - tsconfck@3.0.2(typescript@5.3.3): + tsconfck@3.0.2(typescript@5.4.2): dependencies: - typescript: 5.3.3 + typescript: 5.4.2 dev: true tsconfig-paths@3.15.0: @@ -29854,8 +29974,7 @@ snapshots: typescript@5.3.3: {} - typescript@5.4.2: - dev: true + typescript@5.4.2: {} ua-parser-js@1.0.37: dev: false @@ -30167,7 +30286,7 @@ snapshots: cac: 6.7.14 debug: 4.3.7 pathe: 1.1.2 - picocolors: 1.0.0 + picocolors: 1.1.1 vite: 5.1.4(@types/node@20.11.20) transitivePeerDependencies: - '@types/node' @@ -30180,18 +30299,18 @@ snapshots: - terser dev: true - vite-plugin-dts@4.4.0(@types/node@20.11.20)(typescript@5.3.3)(vite@5.1.4(@types/node@20.11.20)): + vite-plugin-dts@4.4.0(@types/node@20.11.20)(typescript@5.4.2)(vite@5.1.4(@types/node@20.11.20)): dependencies: '@microsoft/api-extractor': 7.48.1(@types/node@20.11.20) '@rollup/pluginutils': 5.1.4 '@volar/typescript': 2.4.11 - '@vue/language-core': 2.1.10(typescript@5.3.3) + '@vue/language-core': 2.1.10(typescript@5.4.2) compare-versions: 6.1.1 debug: 4.4.0 kolorist: 1.8.0 local-pkg: 0.5.1 magic-string: 0.30.17 - typescript: 5.3.3 + typescript: 5.4.2 vite: 5.1.4(@types/node@20.11.20) transitivePeerDependencies: - '@types/node' @@ -30199,11 +30318,11 @@ snapshots: - supports-color dev: true - vite-tsconfig-paths@4.3.1(typescript@5.3.3): + vite-tsconfig-paths@4.3.1(typescript@5.4.2): dependencies: debug: 4.3.4 globrex: 0.1.2 - tsconfck: 3.0.2(typescript@5.3.3) + tsconfck: 3.0.2(typescript@5.4.2) transitivePeerDependencies: - supports-color - typescript -- cgit v1.2.3-70-g09d2