Browse Source

Add api and util tests by OpenCode

jerryliao 4 days ago
parent
commit
615c769e74
10 changed files with 1019 additions and 18 deletions
  1. 1 0
      .dockerignore
  2. 4 1
      deno.json
  3. 30 7
      deno.lock
  4. 1 1
      routes/api/user/reset.tsx
  5. 252 0
      tests/api/post_test.ts
  6. 149 0
      tests/api/share_test.ts
  7. 272 0
      tests/api/user_test.ts
  8. 210 0
      tests/utils/db_test.ts
  9. 83 0
      tests/utils/server_test.ts
  10. 17 9
      utils/db.ts

+ 1 - 0
.dockerignore

@@ -1,4 +1,5 @@
 data
+tests
 .git
 .vscode
 .drone.yml

+ 4 - 1
deno.json

@@ -5,7 +5,8 @@
     "dev": "vite",
     "build": "vite build",
     "start": "deno serve -A _fresh/server.js",
-    "update": "deno run -A -r jsr:@fresh/update ."
+    "update": "deno run -A -r jsr:@fresh/update .",
+    "test": "deno test -A"
   },
   "lint": {
     "rules": {
@@ -20,10 +21,12 @@
   ],
   "imports": {
     "@std/async": "jsr:@std/async@^1.2.0",
+    "@std/assert": "jsr:@std/assert@^1.0.19",
     "@std/crypto": "jsr:@std/crypto@^1.0.5",
     "@std/dotenv": "jsr:@std/dotenv@^0.225.6",
     "@std/encoding": "jsr:@std/encoding@^1.0.10",
     "@std/http": "jsr:@std/http@^1.0.25",
+    "@types/node": "npm:@types/node@^25.5.2",
     "@types/showdown": "npm:@types/showdown@^2.0.6",
     "fresh": "jsr:@fresh/core@^2.2.2",
     "preact": "npm:preact@^10.27.2",

+ 30 - 7
deno.lock

@@ -9,6 +9,7 @@
     "jsr:@fresh/core@^2.2.0": "2.2.2",
     "jsr:@fresh/core@^2.2.2": "2.2.2",
     "jsr:@fresh/plugin-vite@^1.0.8": "1.0.8",
+    "jsr:@std/assert@^1.0.19": "1.0.19",
     "jsr:@std/async@^1.2.0": "1.2.0",
     "jsr:@std/bytes@^1.0.6": "1.0.6",
     "jsr:@std/cli@^1.0.28": "1.0.28",
@@ -42,8 +43,9 @@
     "npm:@opentelemetry/api@^1.9.0": "1.9.1",
     "npm:@preact/signals@^2.5.0": "2.9.0_preact@10.29.0",
     "npm:@preact/signals@^2.5.1": "2.9.0_preact@10.29.0",
-    "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.0_vite@7.3.1",
-    "npm:@tailwindcss/vite@^4.2.2": "4.2.2_vite@7.3.1",
+    "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.0_vite@7.3.1__@types+node@25.5.2_@types+node@25.5.2",
+    "npm:@tailwindcss/vite@^4.2.2": "4.2.2_vite@7.3.1__@types+node@25.5.2_@types+node@25.5.2",
+    "npm:@types/node@^25.5.2": "25.5.2",
     "npm:@types/showdown@^2.0.6": "2.0.6",
     "npm:esbuild-wasm@~0.25.11": "0.25.12",
     "npm:esbuild@0.25.7": "0.25.7",
@@ -56,8 +58,8 @@
     "npm:showdown@^2.1.0": "2.1.0",
     "npm:tailwindcss@^4.2.2": "4.2.2",
     "npm:usid@2": "2.0.0",
-    "npm:vite@^7.1.3": "7.3.1",
-    "npm:vite@^7.1.4": "7.3.1"
+    "npm:vite@^7.1.3": "7.3.1_@types+node@25.5.2",
+    "npm:vite@^7.1.4": "7.3.1_@types+node@25.5.2"
   },
   "jsr": {
     "@deno/esbuild-plugin@1.2.1": {
@@ -118,6 +120,12 @@
         "npm:vite@^7.1.4"
       ]
     },
+    "@std/assert@1.0.19": {
+      "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
+      "dependencies": [
+        "jsr:@std/internal"
+      ]
+    },
     "@std/async@1.2.0": {
       "integrity": "c059c6f6d95ca7cc012ae8e8d7164d1697113d54b0b679e4372b354b11c2dee5"
     },
@@ -826,7 +834,7 @@
     "@prefresh/utils@1.2.1": {
       "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="
     },
-    "@prefresh/vite@2.4.12_preact@10.29.0_vite@7.3.1": {
+    "@prefresh/vite@2.4.12_preact@10.29.0_vite@7.3.1__@types+node@25.5.2_@types+node@25.5.2": {
       "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==",
       "dependencies": [
         "@babel/core",
@@ -1058,7 +1066,7 @@
         "@tailwindcss/oxide-win32-x64-msvc"
       ]
     },
-    "@tailwindcss/vite@4.2.2_vite@7.3.1": {
+    "@tailwindcss/vite@4.2.2_vite@7.3.1__@types+node@25.5.2_@types+node@25.5.2": {
       "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
       "dependencies": [
         "@tailwindcss/node",
@@ -1070,6 +1078,12 @@
     "@types/estree@1.0.8": {
       "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
     },
+    "@types/node@25.5.2": {
+      "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
+      "dependencies": [
+        "undici-types"
+      ]
+    },
     "@types/showdown@2.0.6": {
       "integrity": "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ=="
     },
@@ -1447,6 +1461,9 @@
         "picomatch@4.0.4"
       ]
     },
+    "undici-types@7.18.2": {
+      "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="
+    },
     "update-browserslist-db@1.2.3_browserslist@4.28.1": {
       "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
       "dependencies": [
@@ -1462,9 +1479,10 @@
         "numesis"
       ]
     },
-    "vite@7.3.1": {
+    "vite@7.3.1_@types+node@25.5.2": {
       "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
       "dependencies": [
+        "@types/node",
         "esbuild@0.27.4",
         "fdir",
         "picomatch@4.0.4",
@@ -1475,6 +1493,9 @@
       "optionalDependencies": [
         "fsevents"
       ],
+      "optionalPeers": [
+        "@types/node"
+      ],
       "bin": true
     },
     "yallist@3.1.1": {
@@ -1492,6 +1513,7 @@
     "dependencies": [
       "jsr:@fresh/core@^2.2.2",
       "jsr:@fresh/plugin-vite@^1.0.8",
+      "jsr:@std/assert@^1.0.19",
       "jsr:@std/async@^1.2.0",
       "jsr:@std/crypto@^1.0.5",
       "jsr:@std/dotenv@~0.225.6",
@@ -1499,6 +1521,7 @@
       "jsr:@std/http@^1.0.25",
       "npm:@preact/signals@^2.5.0",
       "npm:@tailwindcss/vite@^4.2.2",
+      "npm:@types/node@^25.5.2",
       "npm:@types/showdown@^2.0.6",
       "npm:preact@^10.27.2",
       "npm:showdown@^2.1.0",

+ 1 - 1
routes/api/user/reset.tsx

@@ -17,7 +17,7 @@ export const handler = define.handlers({
       if (user.length > 0) {
         // Match old password
         if (
-          await getCryptoString(reqJson.old, "MD5") === user[0][0] as string
+          await getCryptoString(reqJson.old, "MD5") === user[0]["password"] as string
         ) {
           // Store new password
           update("User", tokenUserId, {

+ 252 - 0
tests/api/post_test.ts

@@ -0,0 +1,252 @@
+import { assertEquals } from "@std/assert";
+import { find, insert } from "utils/db.ts";
+import { getCryptoString } from "utils/server.ts";
+
+let testCounter = 0;
+
+function getTestDbPath() {
+  testCounter++;
+  return `data/test_api_post_${testCounter}_${Date.now()}.db`;
+}
+
+function cleanup(dbPath: string) {
+  try {
+    Deno.removeSync(dbPath);
+  } catch {
+    // File may not exist
+  }
+}
+
+function makeCtx(method: string, body?: object, cookie?: string) {
+  const headers: Record<string, string> = { "Content-Type": "application/json" };
+  if (cookie) headers["Cookie"] = cookie;
+  const initMethod = method === "GET" && body ? "POST" : method;
+  const req = new Request("http://localhost", {
+    method: initMethod,
+    headers,
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  if (method === "GET" && body) {
+    Object.defineProperty(req, "method", { value: "GET" });
+  }
+  return { req };
+}
+
+async function seedUserAndToken(email: string, password: string) {
+  const hashedPw = await getCryptoString(password, "MD5");
+  const user = insert("User", {
+    name: email.split("@")[0],
+    email,
+    password: hashedPw,
+  });
+  const userId = user[0]["id"] as string | number;
+  const token = await getCryptoString("post-token-" + userId + Date.now(), "MD5");
+  insert("Token", { token, user_id: userId });
+  return { userId, token };
+}
+
+Deno.test("API post - create a new post", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { token } = await seedUserAndToken("postcreate@example.com", "password");
+    const { handler } = await import("../../routes/api/post.tsx");
+
+    const ctx = makeCtx("POST", {
+      title: "My First Post",
+      content: "# Hello World",
+    }, `pd-user-token=${token}`);
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+    assertEquals(typeof body.data, "string");
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API post - create without token returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/post.tsx");
+    const ctx = makeCtx("POST", {
+      title: "Unauthorized Post",
+      content: "Should fail",
+    });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API post - get own post", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId, token } = await seedUserAndToken("postget@example.com", "password");
+    insert("Post", {
+      id: "test-post-1",
+      title: "Test Post",
+      content: "Some content",
+      user_id: userId,
+      shared: 0,
+    });
+
+    const { handler } = await import("../../routes/api/post.tsx");
+    const ctx = makeCtx("GET", { id: "test-post-1" }, `pd-user-token=${token}`);
+    const res = await handler.GET!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+    assertEquals(body.data.title, "Test Post");
+    assertEquals(body.data.content, "Some content");
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API post - get shared post without token", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId } = await seedUserAndToken("postshared@example.com", "password");
+    insert("Post", {
+      id: "shared-post-1",
+      title: "Shared Post",
+      content: "Public content",
+      user_id: userId,
+      shared: 1,
+    });
+
+    const { handler } = await import("../../routes/api/post.tsx");
+    const ctx = makeCtx("GET", { id: "shared-post-1" });
+    const res = await handler.GET!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+    assertEquals(body.data.title, "Shared Post");
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API post - get non-shared post without token returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId } = await seedUserAndToken("postprivate@example.com", "password");
+    insert("Post", {
+      id: "private-post-1",
+      title: "Private Post",
+      content: "Secret content",
+      user_id: userId,
+      shared: 0,
+    });
+
+    const { handler } = await import("../../routes/api/post.tsx");
+    const ctx = makeCtx("GET", { id: "private-post-1" });
+    const res = await handler.GET!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API post - update post title", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId, token } = await seedUserAndToken("postupdate@example.com", "password");
+    insert("Post", {
+      id: "update-post-1",
+      title: "Old Title",
+      content: "Content",
+      user_id: userId,
+      shared: 0,
+    });
+
+    const { handler } = await import("../../routes/api/post.tsx");
+    const ctx = makeCtx("PUT", {
+      id: "update-post-1",
+      title: "New Title",
+    }, `pd-user-token=${token}`);
+    const res = await handler.PUT!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+
+    const post = find("Post", { id: "update-post-1" }, ["title"]);
+    assertEquals(post[0]["title"], "New Title");
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API post - update without token returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/post.tsx");
+    const ctx = makeCtx("PUT", {
+      id: "some-post",
+      title: "Hacked Title",
+    });
+    const res = await handler.PUT!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API post - delete own post", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId, token } = await seedUserAndToken("postdelete@example.com", "password");
+    insert("Post", {
+      id: "delete-post-1",
+      title: "Delete Me",
+      content: "Bye",
+      user_id: userId,
+      shared: 0,
+    });
+
+    const { handler } = await import("../../routes/api/post.tsx");
+    const ctx = makeCtx("DELETE", {
+      id: "delete-post-1",
+    }, `pd-user-token=${token}`);
+    const res = await handler.DELETE!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+
+    const post = find("Post", { id: "delete-post-1" }, ["id"]);
+    assertEquals(post.length, 0);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API post - delete without token returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/post.tsx");
+    const ctx = makeCtx("DELETE", { id: "some-post" });
+    const res = await handler.DELETE!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});

+ 149 - 0
tests/api/share_test.ts

@@ -0,0 +1,149 @@
+import { assertEquals } from "@std/assert";
+import { find, insert } from "utils/db.ts";
+import { getCryptoString } from "utils/server.ts";
+
+let testCounter = 0;
+
+function getTestDbPath() {
+  testCounter++;
+  return `data/test_api_share_${testCounter}_${Date.now()}.db`;
+}
+
+function cleanup(dbPath: string) {
+  try {
+    Deno.removeSync(dbPath);
+  } catch {
+    // File may not exist
+  }
+}
+
+function makeCtx(method: string, body?: object, cookie?: string) {
+  const headers: Record<string, string> = { "Content-Type": "application/json" };
+  if (cookie) headers["Cookie"] = cookie;
+  const req = new Request("http://localhost", {
+    method,
+    headers,
+    body: method === "GET" ? undefined : JSON.stringify(body || {}),
+  });
+  return { req };
+}
+
+async function seedUserAndToken(email: string, password: string) {
+  const hashedPw = await getCryptoString(password, "MD5");
+  const user = insert("User", {
+    name: email.split("@")[0],
+    email,
+    password: hashedPw,
+  });
+  const userId = user[0]["id"] as string | number;
+  const token = await getCryptoString("share-token-" + userId + Date.now(), "MD5");
+  insert("Token", { token, user_id: userId });
+  return { userId, token };
+}
+
+Deno.test("API share - share a post", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId, token } = await seedUserAndToken("shareshare@example.com", "password");
+    insert("Post", {
+      id: "share-post-1",
+      title: "Share Me",
+      content: "Content",
+      user_id: userId,
+      shared: 0,
+    });
+
+    const { handler } = await import("../../routes/api/share.tsx");
+    const ctx = makeCtx("POST", {
+      id: "share-post-1",
+      shared: true,
+    }, `pd-user-token=${token}`);
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+
+    const post = find("Post", { id: "share-post-1" }, ["shared"]);
+    assertEquals(post[0]["shared"], 1);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API share - unshare a post", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId, token } = await seedUserAndToken("shareunshare@example.com", "password");
+    insert("Post", {
+      id: "unshare-post-1",
+      title: "Unshare Me",
+      content: "Content",
+      user_id: userId,
+      shared: 1,
+    });
+
+    const { handler } = await import("../../routes/api/share.tsx");
+    const ctx = makeCtx("POST", {
+      id: "unshare-post-1",
+      shared: false,
+    }, `pd-user-token=${token}`);
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+
+    const post = find("Post", { id: "unshare-post-1" }, ["shared"]);
+    assertEquals(post[0]["shared"], 0);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API share - without token returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/share.tsx");
+    const ctx = makeCtx("POST", {
+      id: "some-post",
+      shared: true,
+    });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API share - share another user's post returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { userId: otherUserId } = await seedUserAndToken("shareother@example.com", "password");
+    insert("Post", {
+      id: "other-post-1",
+      title: "Other's Post",
+      content: "Content",
+      user_id: otherUserId,
+      shared: 0,
+    });
+
+    const { token: myToken } = await seedUserAndToken("shareme@example.com", "password2");
+
+    const { handler } = await import("../../routes/api/share.tsx");
+    const ctx = makeCtx("POST", {
+      id: "other-post-1",
+      shared: true,
+    }, `pd-user-token=${myToken}`);
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});

+ 272 - 0
tests/api/user_test.ts

@@ -0,0 +1,272 @@
+import { assertEquals } from "@std/assert";
+import { find, insert } from "utils/db.ts";
+import { getCryptoString } from "utils/server.ts";
+
+let testCounter = 0;
+
+function getTestDbPath() {
+  testCounter++;
+  return `data/test_api_user_${testCounter}_${Date.now()}.db`;
+}
+
+function cleanup(dbPath: string) {
+  try {
+    Deno.removeSync(dbPath);
+  } catch {
+    // File may not exist
+  }
+}
+
+function makeCtx(method: string, body?: object, cookie?: string) {
+  const headers: Record<string, string> = { "Content-Type": "application/json" };
+  if (cookie) headers["Cookie"] = cookie;
+  const req = new Request("http://localhost", {
+    method,
+    headers,
+    body: method === "GET" ? undefined : JSON.stringify(body || {}),
+  });
+  return { req };
+}
+
+async function seedUser(email: string, password: string) {
+  const hashedPw = await getCryptoString(password, "MD5");
+  const user = insert("User", {
+    name: email.split("@")[0],
+    email,
+    password: hashedPw,
+  });
+  return user;
+}
+
+async function seedToken(userId: string | number) {
+  const token = await getCryptoString("test-token-" + userId + Date.now(), "MD5");
+  insert("Token", { token, user_id: userId });
+  return token;
+}
+
+Deno.test("API user/register - successful registration", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/user/register.tsx");
+    const ctx = makeCtx("POST", {
+      email: "newuser@example.com",
+      password: "password123",
+    });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+    assertEquals(body.data, true);
+
+    const users = find("User", { email: "newuser@example.com" }, ["id"]);
+    assertEquals(users.length, 1);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/register - missing email returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/user/register.tsx");
+    const ctx = makeCtx("POST", { password: "password123" });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/register - missing password returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/user/register.tsx");
+    const ctx = makeCtx("POST", { email: "test@example.com" });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/login - successful login", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    await seedUser("login@example.com", "mypassword");
+
+    const { handler } = await import("../../routes/api/user/login.tsx");
+    const ctx = makeCtx("POST", {
+      email: "login@example.com",
+      password: "mypassword",
+    });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+    assertEquals(body.data, true);
+
+    const setCookie = res.headers.get("set-cookie");
+    assertEquals(setCookie !== null, true);
+    assertEquals(setCookie!.includes("pd-user-token="), true);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/login - wrong email returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/user/login.tsx");
+    const ctx = makeCtx("POST", {
+      email: "nonexistent@example.com",
+      password: "password",
+    });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/login - missing fields returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/user/login.tsx");
+    const ctx = makeCtx("POST", { email: "test@example.com" });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/login GET - returns user info with valid token", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const user = await seedUser("getuser@example.com", "password");
+    const userId = user[0]["id"] as string | number;
+    const token = await seedToken(userId);
+
+    const { handler } = await import("../../routes/api/user/login.tsx");
+    const ctx = makeCtx("GET", undefined, `pd-user-token=${token}`);
+    const res = await handler.GET!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+    assertEquals(body.data.name, "getuser");
+    assertEquals(body.data.email, "getuser@example.com");
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/login GET - returns error without token", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/user/login.tsx");
+    const ctx = makeCtx("GET");
+    const res = await handler.GET!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/logout - returns success and clears cookie", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/user/logout.tsx");
+    const ctx = makeCtx("GET");
+    const res = await handler.GET!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+
+    const setCookie = res.headers.get("set-cookie");
+    assertEquals(setCookie !== null, true);
+    assertEquals(setCookie!.includes("pd-user-token="), true);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/reset - successful password reset", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const user = await seedUser("reset@example.com", "oldpassword");
+    const userId = user[0]["id"] as string | number;
+    const token = await seedToken(userId);
+
+    const { handler } = await import("../../routes/api/user/reset.tsx");
+    const ctx = makeCtx("POST", {
+      old: "oldpassword",
+      new: "newpassword",
+    }, `pd-user-token=${token}`);
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, true);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/reset - wrong old password returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const user = await seedUser("resetwrong@example.com", "oldpassword");
+    const userId = user[0]["id"] as string | number;
+    const token = await seedToken(userId);
+
+    const { handler } = await import("../../routes/api/user/reset.tsx");
+    const ctx = makeCtx("POST", {
+      old: "wrongpassword",
+      new: "newpassword",
+    }, `pd-user-token=${token}`);
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});
+
+Deno.test("API user/reset - without token returns error", async () => {
+  const dbPath = getTestDbPath();
+  Deno.env.set("POSTDOWN_DB_PATH", dbPath);
+  try {
+    const { handler } = await import("../../routes/api/user/reset.tsx");
+    const ctx = makeCtx("POST", {
+      old: "oldpassword",
+      new: "newpassword",
+    });
+    const res = await handler.POST!(ctx as any);
+    const body = await res.json();
+    assertEquals(body.success, false);
+  } finally {
+    Deno.env.delete("POSTDOWN_DB_PATH");
+    cleanup(dbPath);
+  }
+});

+ 210 - 0
tests/utils/db_test.ts

@@ -0,0 +1,210 @@
+import { assertEquals, assertExists } from "@std/assert";
+import { del, find, insert, update } from "utils/db.ts";
+
+const TEST_DB_PATH = "data/test_postdown.db";
+
+function cleanup() {
+  try {
+    Deno.removeSync(TEST_DB_PATH);
+  } catch {
+    // File may not exist
+  }
+}
+
+Deno.test("db - insert and find a User", () => {
+  cleanup();
+  try {
+    const result = insert("User", {
+      name: "testuser",
+      email: "test@example.com",
+      password: "hashedpassword",
+    }, TEST_DB_PATH);
+    assertEquals(result.length, 1);
+    assertExists(result[0]["id"]);
+
+    const found = find("User", { email: "test@example.com" }, ["name", "email"], undefined, TEST_DB_PATH);
+    assertEquals(found.length, 1);
+    assertEquals(found[0]["name"], "testuser");
+    assertEquals(found[0]["email"], "test@example.com");
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - find with no results returns empty array", () => {
+  cleanup();
+  try {
+    const found = find("User", { email: "nonexistent@example.com" }, ["id"], undefined, TEST_DB_PATH);
+    assertEquals(found.length, 0);
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - find with targetKeys returns only specified columns", () => {
+  cleanup();
+  try {
+    insert("User", {
+      name: "testuser",
+      email: "test@example.com",
+      password: "hashedpassword",
+    }, TEST_DB_PATH);
+
+    const found = find("User", { email: "test@example.com" }, ["name"], undefined, TEST_DB_PATH);
+    assertEquals(found.length, 1);
+    assertEquals(Object.keys(found[0]).length, 1);
+    assertEquals(found[0]["name"], "testuser");
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - find with limit", () => {
+  cleanup();
+  try {
+    insert("User", {
+      name: "user1",
+      email: "user1@example.com",
+      password: "pass1",
+    }, TEST_DB_PATH);
+    insert("User", {
+      name: "user2",
+      email: "user2@example.com",
+      password: "pass2",
+    }, TEST_DB_PATH);
+
+    const found = find("User", { name: "user1" }, ["id"], 1, TEST_DB_PATH);
+    assertEquals(found.length, 1);
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - update a User record", () => {
+  cleanup();
+  try {
+    const inserted = insert("User", {
+      name: "oldname",
+      email: "test@example.com",
+      password: "hashedpassword",
+    }, TEST_DB_PATH);
+    const userId = inserted[0]["id"] as string | number;
+
+    const result = update("User", userId, { name: "newname" }, TEST_DB_PATH);
+    assertEquals(result.length, 1);
+
+    const found = find("User", { id: userId }, ["name"], undefined, TEST_DB_PATH);
+    assertEquals(found[0]["name"], "newname");
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - delete a User record", () => {
+  cleanup();
+  try {
+    const inserted = insert("User", {
+      name: "deleteme",
+      email: "delete@example.com",
+      password: "hashedpassword",
+    }, TEST_DB_PATH);
+    const userId = inserted[0]["id"] as string | number;
+
+    const result = del("User", { id: userId }, TEST_DB_PATH);
+    assertEquals(result, true);
+
+    const found = find("User", { id: userId }, ["id"], undefined, TEST_DB_PATH);
+    assertEquals(found.length, 0);
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - insert and find a Post", () => {
+  cleanup();
+  try {
+    const result = insert("Post", {
+      id: "abc123",
+      title: "Test Post",
+      content: "# Hello World",
+      user_id: 1,
+      shared: 0,
+    }, TEST_DB_PATH);
+    assertEquals(result.length, 1);
+    assertEquals(result[0]["id"], "abc123");
+
+    const found = find("Post", { id: "abc123" }, ["title", "content"], undefined, TEST_DB_PATH);
+    assertEquals(found.length, 1);
+    assertEquals(found[0]["title"], "Test Post");
+    assertEquals(found[0]["content"], "# Hello World");
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - insert and find a Token", () => {
+  cleanup();
+  try {
+    const result = insert("Token", {
+      token: "test-token-123",
+      user_id: 1,
+    }, TEST_DB_PATH);
+    assertEquals(result.length, 1);
+
+    const found = find("Token", { token: "test-token-123" }, ["user_id"], undefined, TEST_DB_PATH);
+    assertEquals(found.length, 1);
+    assertEquals(found[0]["user_id"], 1);
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - update Post shared status", () => {
+  cleanup();
+  try {
+    insert("Post", {
+      id: "share-test",
+      title: "Share Test",
+      content: "content",
+      user_id: 1,
+      shared: 0,
+    }, TEST_DB_PATH);
+
+    update("Post", "share-test", { shared: 1 }, TEST_DB_PATH);
+
+    const found = find("Post", { id: "share-test" }, ["shared"], undefined, TEST_DB_PATH);
+    assertEquals(found[0]["shared"], 1);
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - del returns true on non-existent record", () => {
+  cleanup();
+  try {
+    const result = del("User", { id: 99999 }, TEST_DB_PATH);
+    assertEquals(result, true);
+  } finally {
+    cleanup();
+  }
+});
+
+Deno.test("db - insert duplicate name returns empty array", () => {
+  cleanup();
+  try {
+    insert("User", {
+      name: "dupuser",
+      email: "first@example.com",
+      password: "pass1",
+    }, TEST_DB_PATH);
+
+    const result = insert("User", {
+      name: "dupuser",
+      email: "second@example.com",
+      password: "pass2",
+    }, TEST_DB_PATH);
+    assertEquals(result.length, 0);
+  } finally {
+    cleanup();
+  }
+});

+ 83 - 0
tests/utils/server_test.ts

@@ -0,0 +1,83 @@
+import { assertEquals, assertExists } from "@std/assert";
+import {
+  clearToken,
+  getCryptoString,
+  makeErrorResponse,
+  makeSuccessResponse,
+  setToken,
+} from "utils/server.ts";
+
+Deno.test("getCryptoString - produces correct MD5 hash", async () => {
+  const result = await getCryptoString("hello", "MD5");
+  assertEquals(result, "5d41402abc4b2a76b9719d911017c592");
+});
+
+Deno.test("getCryptoString - produces correct SHA-256 hash", async () => {
+  const result = await getCryptoString("hello", "SHA-256");
+  assertEquals(
+    result,
+    "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
+  );
+});
+
+Deno.test("getCryptoString - different inputs produce different hashes", async () => {
+  const hash1 = await getCryptoString("input1", "MD5");
+  const hash2 = await getCryptoString("input2", "MD5");
+  assertExists(hash1);
+  assertExists(hash2);
+  assertEquals(hash1 !== hash2, true);
+});
+
+Deno.test("makeSuccessResponse - returns correct JSON structure with object data", async () => {
+  const res = makeSuccessResponse({ name: "test", value: 42 });
+  const body = await res.json();
+  assertEquals(body.success, true);
+  assertEquals(body.data, { name: "test", value: 42 });
+  assertEquals(res.headers.get("Content-Type"), "application/json");
+});
+
+Deno.test("makeSuccessResponse - returns correct JSON structure with string data", async () => {
+  const res = makeSuccessResponse("hello");
+  const body = await res.json();
+  assertEquals(body.success, true);
+  assertEquals(body.data, "hello");
+});
+
+Deno.test("makeSuccessResponse - returns correct JSON structure with boolean data", async () => {
+  const res = makeSuccessResponse(true);
+  const body = await res.json();
+  assertEquals(body.success, true);
+  assertEquals(body.data, true);
+});
+
+Deno.test("makeSuccessResponse - returns correct JSON structure with array data", async () => {
+  const res = makeSuccessResponse([{ id: 1 }, { id: 2 }]);
+  const body = await res.json();
+  assertEquals(body.success, true);
+  assertEquals(body.data, [{ id: 1 }, { id: 2 }]);
+});
+
+Deno.test("makeErrorResponse - returns correct JSON structure", async () => {
+  const res = makeErrorResponse();
+  const body = await res.json();
+  assertEquals(body.success, false);
+  assertEquals(body.data, undefined);
+  assertEquals(res.headers.get("Content-Type"), "application/json");
+});
+
+Deno.test("setToken - sets pd-user-token cookie on response", () => {
+  const res = new Response(null, { headers: new Headers() });
+  setToken(res, "test-token-value");
+  const cookieHeader = res.headers.get("set-cookie");
+  assertExists(cookieHeader);
+  assertEquals(cookieHeader!.includes("pd-user-token=test-token-value"), true);
+  assertEquals(cookieHeader!.includes("Path=/"), true);
+});
+
+Deno.test("clearToken - deletes pd-user-token cookie", () => {
+  const res = new Response(null, { headers: new Headers() });
+  clearToken(res);
+  const cookieHeader = res.headers.get("set-cookie");
+  assertExists(cookieHeader);
+  assertEquals(cookieHeader!.includes("pd-user-token="), true);
+});

+ 17 - 9
utils/db.ts

@@ -1,7 +1,11 @@
 import { DatabaseSync } from "node:sqlite";
 
-function prepareDB(tableName: string) {
-  const db = new DatabaseSync("data/postdown.db");
+function getDbPath(): string {
+  return Deno.env.get("POSTDOWN_DB_PATH") || "data/postdown.db";
+}
+
+function prepareDB(tableName: string, dbPath?: string) {
+  const db = new DatabaseSync(dbPath || getDbPath());
   switch (tableName) {
     case "User":
       db.exec(`
@@ -48,8 +52,9 @@ export function find(
   queryObject: { [key: string]: string | number | boolean },
   targetKeys: string[] = [],
   limit?: number,
+  dbPath?: string,
 ) {
-  const db = prepareDB(tableName);
+  const db = prepareDB(tableName, dbPath);
   const findQuery = db.prepare(
     `SELECT ${
       targetKeys.length > 0 ? targetKeys.join(", ") : "*"
@@ -74,8 +79,9 @@ export function find(
 export function insert(
   tableName: string,
   userInsertObject: { [key: string]: string | number | boolean },
+  dbPath?: string,
 ) {
-  const db = prepareDB(tableName);
+  const db = prepareDB(tableName, dbPath);
   const insertObject = {
     ...userInsertObject,
     updated: new Date().toISOString().slice(0, 19).replace("T", " "),
@@ -93,7 +99,7 @@ export function insert(
   );
   try {
     insertQuery.run(...Object.values(insertObject).map((v) => v.toString()));
-    return find(tableName, userInsertObject, ["id"], 1);
+    return find(tableName, userInsertObject, ["id"], 1, dbPath);
   } catch (e) {
     console.error("Insert error:", e);
     return [];
@@ -106,8 +112,9 @@ export function update(
   tableName: string,
   id: number | string,
   userUpdateObject: { [key: string]: string | number | boolean },
+  dbPath?: string,
 ) {
-  const db = prepareDB(tableName);
+  const db = prepareDB(tableName, dbPath);
   const updateObject = {
     ...userUpdateObject,
     updated: new Date().toISOString().slice(0, 19).replace("T", " "),
@@ -124,9 +131,9 @@ export function update(
       ...Object.values(updateObject).map((v) => v.toString()),
       id,
     );
-    return find(tableName, userUpdateObject, ["id"], 1);
+    return find(tableName, userUpdateObject, ["id"], 1, dbPath);
   } catch (e) {
-    console.error("Insert error:", e);
+    console.error("Update error:", e);
     return [];
   } finally {
     db.close();
@@ -136,8 +143,9 @@ export function update(
 export function del(
   tableName: string,
   queryObject: { [key: string]: string | number | boolean },
+  dbPath?: string,
 ) {
-  const db = prepareDB(tableName);
+  const db = prepareDB(tableName, dbPath);
   const deleteQuery = db.prepare(
     `DELETE FROM ${tableName.toLowerCase()} WHERE ${
       Object.keys(queryObject)