aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2024-12-30 16:55:49 +0000
committerMohamed Bassem <me@mbassem.com>2024-12-30 16:56:23 +0000
commit058e7238840b362135fd080045478025e31bf720 (patch)
treef094b35163961065d3d145e8ff826219b0bb3506 /packages
parent5aee3404aa7f446a221a88c9b2cd529d1249e22f (diff)
downloadkarakeep-058e7238840b362135fd080045478025e31bf720.tar.zst
chore: Setup and add e2e tests for the API endpoints
Diffstat (limited to 'packages')
-rw-r--r--packages/e2e_tests/docker-compose.yml12
-rw-r--r--packages/e2e_tests/package.json33
-rw-r--r--packages/e2e_tests/setup/seed.ts27
-rw-r--r--packages/e2e_tests/setup/startContainers.ts75
-rw-r--r--packages/e2e_tests/tests/api/assets.test.ts134
-rw-r--r--packages/e2e_tests/tests/api/bookmarks.test.ts288
-rw-r--r--packages/e2e_tests/tests/api/highlights.test.ts242
-rw-r--r--packages/e2e_tests/tests/api/lists.test.ts185
-rw-r--r--packages/e2e_tests/tests/api/tags.test.ts215
-rw-r--r--packages/e2e_tests/tsconfig.json9
-rw-r--r--packages/e2e_tests/utils/api.ts57
-rw-r--r--packages/e2e_tests/utils/trpc.ts20
-rw-r--r--packages/e2e_tests/vitest.config.ts17
-rw-r--r--packages/open-api/package.json2
14 files changed, 1315 insertions, 1 deletions
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<number> {
+ const server = net.createServer();
+ return new Promise<number>((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<void> {
+ 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<typeof createHoarderClient>;
+ 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<typeof createHoarderClient>;
+ 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<typeof createHoarderClient>;
+ 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<typeof createHoarderClient>;
+ 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<typeof createHoarderClient>;
+ 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<AppRouter>({
+ 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 @@
+/// <reference types="vitest" />
+
+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 ."
},