diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..89d7a5a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +name: BookHub CI + +on: + push: + branches: + - main + + pull_request: + branches: + - main + +jobs: + frontend: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + backend: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: backend/package-lock.json + + - name: Install dependencies + working-directory: backend + run: npm ci + + - name: Create backend environment + working-directory: backend + run: | + echo "ACCESS_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }}" >> .env + echo "REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }}" >> .env + echo "CLOUDINARY_CLOUD_NAME=${{ secrets.CLOUDINARY_CLOUD_NAME }}" >> .env + echo "CLOUDINARY_API_KEY=${{ secrets.CLOUDINARY_API_KEY }}" >> .env + echo "CLOUDINARY_API_SECRET=${{ secrets.CLOUDINARY_API_SECRET }}" >> .env + echo "EMAIL_USERNAME=${{ secrets.EMAIL_USERNAME }}" >> .env + echo "EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }}" >> .env + + - name: Check syntax + working-directory: backend + run: npm run check + + - name: Start Docker services + run: docker compose up -d --build + + - name: Wait for backend + run: | + for i in {1..30}; do + if curl -fsS http://localhost:8000/health > /dev/null; then + echo "Backend is ready" + exit 0 + fi + echo "Waiting for backend..." + sleep 2 + done + echo "Backend failed to start" + docker compose logs + exit 1 + + - name: Seed CI test user + run: | + curl -sf -X POST http://localhost:8000/api/v1/users/register \ + -H "Content-Type: application/json" \ + -d '{ + "userFullName": "CI Test User", + "email": "${{ secrets.TEST_IDENTIFIER }}", + "userName": "ci_test_user", + "password": "${{ secrets.TEST_PASSWORD }}" + }' || echo "Test user may already exist, continuing" + + - name: Run tests + working-directory: backend + env: + BASE_URL: http://localhost:8000 + TEST_IDENTIFIER: ${{ secrets.TEST_IDENTIFIER }} + TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} + run: npm test + + - name: Stop Docker services + if: always() + run: docker compose down -v + + deploy: + needs: [frontend, backend] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Trigger Render deploy + run: curl -fsS -X POST "${{ secrets.RENDER_DEPLOY_HOOK }}" diff --git a/backend/package-lock.json b/backend/package-lock.json index 6a8244f..48d25eb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "bullmq": "^6.3.1", "cloudinary": "^2.10.0", "compression": "^1.8.1", + "concurrently": "^10.0.5", "cookie-parser": "^1.4.7", "cors": "^2.8.6", "crypto": "^1.0.1", @@ -300,6 +301,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -442,9 +467,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -582,6 +607,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -607,6 +644,20 @@ "fsevents": "~2.3.2" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -706,6 +757,42 @@ "typedarray": "^0.0.6" } }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -884,6 +971,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -996,6 +1089,15 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1187,6 +1289,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2267,6 +2390,15 @@ "node": ">= 18" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2356,6 +2488,18 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -2572,6 +2716,38 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2635,6 +2811,15 @@ "node": ">=18" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2757,6 +2942,23 @@ "node": ">=18" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2783,6 +2985,41 @@ "optional": true } } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } } } } diff --git a/backend/package.json b/backend/package.json index fd9619a..ab96219 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "nodemon src/index.js", "worker": "node src/workers/blogPdf.worker.js", - "start": "node src/index.js" + "start": "concurrently --restart-tries 3 \"node src/index.js\" \"node src/workers/blogPdf.worker.js\"", + "check": "find src -name '*.js' -print0 | xargs -0 -n1 node --check", + "test": "node --test tests" }, "keywords": [], "author": "", @@ -15,9 +17,9 @@ "dependencies": { "bcrypt": "^6.0.0", "bullmq": "^6.3.1", - "ioredis": "^5.3.2", "cloudinary": "^2.10.0", "compression": "^1.8.1", + "concurrently": "^10.0.5", "cookie-parser": "^1.4.7", "cors": "^2.8.6", "crypto": "^1.0.1", @@ -25,6 +27,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.6.1", "helmet": "^8.3.0", + "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.3", "mongoose": "^9.7.4", "morgan": "^1.11.0", diff --git a/backend/tests/routes/blogs.test.js b/backend/tests/routes/blogs.test.js new file mode 100644 index 0000000..0659ae4 --- /dev/null +++ b/backend/tests/routes/blogs.test.js @@ -0,0 +1,39 @@ +import test from "node:test"; +import { request, assertStatus, json } from "../test-utils.js"; + +test("POST /blogs should reject unauthenticated requests", async () => { + const result = await request("/api/v1/blogs/", { + method: "POST", + ...json({ + title: "Test Written Note", + description: "Test description", + content: "Test content", + }), + }); + await assertStatus(result, 401); +}); + +test("GET /blogs should reject unauthenticated requests", async () => { + const result = await request("/api/v1/blogs/"); + await assertStatus(result, 401); +}); + +test("GET /blogs/:blogId should reject unauthenticated requests", async () => { + const result = await request("/api/v1/blogs/000000000000000000000000"); + await assertStatus(result, 401); +}); + +test("PATCH /blogs/:blogId should reject unauthenticated requests", async () => { + const result = await request("/api/v1/blogs/000000000000000000000000", { + method: "PATCH", + ...json({ title: "Updated title", content: "Updated content" }), + }); + await assertStatus(result, 401); +}); + +test("DELETE /blogs/:blogId should reject unauthenticated requests", async () => { + const result = await request("/api/v1/blogs/000000000000000000000000", { + method: "DELETE", + }); + await assertStatus(result, 401); +}); diff --git a/backend/tests/routes/comments.test.js b/backend/tests/routes/comments.test.js new file mode 100644 index 0000000..2680a66 --- /dev/null +++ b/backend/tests/routes/comments.test.js @@ -0,0 +1,37 @@ +import test from "node:test"; +import { request, assertStatus, json } from "../test-utils.js"; + +const routes = [ + ["GET", "/api/v1/comments/get-comments/000000000000000000000000"], + ["GET", "/api/v1/comments/get-my-comments"], +]; + +for (const [method, path] of routes) { + test(`${method} ${path} should reject unauthenticated requests`, async () => { + const result = await request(path, { method }); + await assertStatus(result, 401); + }); +} + +test("POST /comments/add-comment/:noteId should reject unauthenticated requests", async () => { + const result = await request("/api/v1/comments/add-comment/000000000000000000000000", { + method: "POST", + ...json({ content: "Test comment" }), + }); + await assertStatus(result, 401); +}); + +test("PATCH /comments/edit-comment/:commentId should reject unauthenticated requests", async () => { + const result = await request("/api/v1/comments/edit-comment/000000000000000000000000", { + method: "PATCH", + ...json({ content: "Updated comment" }), + }); + await assertStatus(result, 401); +}); + +test("DELETE /comments/delete-comment/:commentId should reject unauthenticated requests", async () => { + const result = await request("/api/v1/comments/delete-comment/000000000000000000000000", { + method: "DELETE", + }); + await assertStatus(result, 401); +}); diff --git a/backend/tests/routes/follows.test.js b/backend/tests/routes/follows.test.js new file mode 100644 index 0000000..7916afc --- /dev/null +++ b/backend/tests/routes/follows.test.js @@ -0,0 +1,18 @@ +import test from "node:test"; +import { request, assertStatus } from "../test-utils.js"; + +const routes = [ + ["POST", "/api/v1/follows/toggle/000000000000000000000000"], + ["GET", "/api/v1/follows/is-following/000000000000000000000000"], + ["GET", "/api/v1/follows/followers/000000000000000000000000"], + ["GET", "/api/v1/follows/following/000000000000000000000000"], + ["GET", "/api/v1/follows/my-friends"], + ["GET", "/api/v1/follows/suggestions"], +]; + +for (const [method, path] of routes) { + test(`${method} ${path} should reject unauthenticated requests`, async () => { + const result = await request(path, { method }); + await assertStatus(result, 401); + }); +} diff --git a/backend/tests/routes/likes.test.js b/backend/tests/routes/likes.test.js new file mode 100644 index 0000000..87f3ab8 --- /dev/null +++ b/backend/tests/routes/likes.test.js @@ -0,0 +1,16 @@ +import test from "node:test"; +import { request, assertStatus } from "../test-utils.js"; + +const routes = [ + ["POST", "/api/v1/likes/toggle/000000000000000000000000"], + ["GET", "/api/v1/likes/my-likes"], + ["GET", "/api/v1/likes/is-liked/000000000000000000000000"], + ["GET", "/api/v1/likes/note/000000000000000000000000"], +]; + +for (const [method, path] of routes) { + test(`${method} ${path} should reject unauthenticated requests`, async () => { + const result = await request(path, { method }); + await assertStatus(result, 401); + }); +} diff --git a/backend/tests/routes/notes.test.js b/backend/tests/routes/notes.test.js new file mode 100644 index 0000000..279a242 --- /dev/null +++ b/backend/tests/routes/notes.test.js @@ -0,0 +1,40 @@ +import test from "node:test"; +import { request, assertStatus, json } from "../test-utils.js"; + +const protectedRoutes = [ + ["PATCH", "/api/v1/notes/update-note-details/000000000000000000000000"], + ["PATCH", "/api/v1/notes/update-note/000000000000000000000000"], + ["GET", "/api/v1/notes/get-current-note/000000000000000000000000"], + ["GET", "/api/v1/notes/get-my-notes"], + ["GET", "/api/v1/notes/download-note/000000000000000000000000"], + ["DELETE", "/api/v1/notes/delete-note/000000000000000000000000"], +]; + +for (const [method, path] of protectedRoutes) { + test(`${method} ${path} should reject unauthenticated requests`, async () => { + const result = await request(path, { + method, + ...(method === "PATCH" ? json({ title: "Updated title" }) : {}), + }); + await assertStatus(result, 401); + }); +} + +test("POST /notes/upload-notes should reject unauthenticated requests", async () => { + const result = await request("/api/v1/notes/upload-notes", { + method: "POST", + }); + await assertStatus(result, 401); +}); + +test("GET /notes/get-user-notes/:userName should return 404 for an unknown user", async () => { + const result = await request("/api/v1/notes/get-user-notes/__definitely_unknown_user__"); + await assertStatus(result, 404); +}); + +test("GET /notes/search-notes should accept search, tag, sort, page and limit parameters", async () => { + const result = await request( + "/api/v1/notes/search-notes?search=javascript&tag=programming&sort=popular&page=1&limit=10" + ); + await assertStatus(result, 200); +}); diff --git a/backend/tests/routes/notifications.test.js b/backend/tests/routes/notifications.test.js new file mode 100644 index 0000000..dffe448 --- /dev/null +++ b/backend/tests/routes/notifications.test.js @@ -0,0 +1,18 @@ +import test from "node:test"; +import { request, assertStatus, json } from "../test-utils.js"; + +const routes = [ + ["GET", "/api/v1/notifications/count"], + ["GET", "/api/v1/notifications/unread"], + ["GET", "/api/v1/notifications/"], + ["PATCH", "/api/v1/notifications/read-all"], + ["PATCH", "/api/v1/notifications/000000000000000000000000/read"], + ["DELETE", "/api/v1/notifications/000000000000000000000000"], +]; + +for (const [method, path] of routes) { + test(`${method} ${path} should reject unauthenticated requests`, async () => { + const result = await request(path, { method }); + await assertStatus(result, 401); + }); +} diff --git a/backend/tests/routes/playlist-share.test.js b/backend/tests/routes/playlist-share.test.js new file mode 100644 index 0000000..d13f9cf --- /dev/null +++ b/backend/tests/routes/playlist-share.test.js @@ -0,0 +1,22 @@ +import test from "node:test"; +import { request, assertStatus, json } from "../test-utils.js"; + +const routes = [ + ["POST", "/api/v1/playlistShares/000000000000000000000000/share", { userId: "000000000000000000000000" }], + ["GET", "/api/v1/playlistShares/editor"], + ["GET", "/api/v1/playlistShares/viewer"], + ["GET", "/api/v1/playlistShares/000000000000000000000000/members"], + ["PATCH", "/api/v1/playlistShares/000000000000000000000000/members/000000000000000000000000", { role: "EDITOR" }], + ["DELETE", "/api/v1/playlistShares/000000000000000000000000/members/000000000000000000000000"], + ["DELETE", "/api/v1/playlistShares/000000000000000000000000/leave"], +]; + +for (const [method, path, body] of routes) { + test(`${method} ${path} should reject unauthenticated requests`, async () => { + const result = await request(path, { + method, + ...(body ? json(body) : {}), + }); + await assertStatus(result, 401); + }); +} diff --git a/backend/tests/routes/playlists.test.js b/backend/tests/routes/playlists.test.js new file mode 100644 index 0000000..ee108f8 --- /dev/null +++ b/backend/tests/routes/playlists.test.js @@ -0,0 +1,26 @@ +import test from "node:test"; +import { request, assertStatus, json } from "../test-utils.js"; + +const routes = [ + ["POST", "/api/v1/playlists/create", { name: "Test Playlist" }], + ["POST", "/api/v1/playlists/add-to-playlist/000000000000000000000000", {}], + ["PATCH", "/api/v1/playlists/edit-playlist/000000000000000000000000", { name: "Updated Playlist" }], + ["PATCH", "/api/v1/playlists/edit-playlist-item-order/000000000000000000000000", { itemOrder: [] }], + ["GET", "/api/v1/playlists/get-playlist/000000000000000000000000"], + ["GET", "/api/v1/playlists/get-my-playlists"], + ["GET", "/api/v1/playlists/get-playlist-items/000000000000000000000000"], + ["DELETE", "/api/v1/playlists/delete-playlist/000000000000000000000000"], + ["GET", "/api/v1/playlists/get-user-playlists/__unknown_user__"], + ["GET", "/api/v1/playlists/get-user-playlists-with-note-status/000000000000000000000000"], + ["GET", "/api/v1/playlists/is-note-present-in-playlist/000000000000000000000000"], +]; + +for (const [method, path, body] of routes) { + test(`${method} ${path} should reject unauthenticated requests`, async () => { + const result = await request(path, { + method, + ...(body ? json(body) : {}), + }); + await assertStatus(result, 401); + }); +} diff --git a/backend/tests/routes/users.test.js b/backend/tests/routes/users.test.js new file mode 100644 index 0000000..8a231bf --- /dev/null +++ b/backend/tests/routes/users.test.js @@ -0,0 +1,146 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + request, + assertStatus, + json, + loginAndGetCookie, + authTestAvailable, +} from "../test-utils.js"; + +test("GET /health should report the API as healthy", async () => { + const result = await request("/health"); + await assertStatus(result, 200); + assert.equal(result.body.status, "ok"); +}); + +test("POST /users/register should reject a request with missing required fields", async () => { + const result = await request("/api/v1/users/register", { + method: "POST", + ...json({}), + }); + await assertStatus(result, 400); +}); + +test("POST /users/login should reject missing identifier", async () => { + const result = await request("/api/v1/users/login", { + method: "POST", + ...json({ password: "Password123" }), + }); + await assertStatus(result, 400); +}); + +test("POST /users/login should reject missing password", async () => { + const result = await request("/api/v1/users/login", { + method: "POST", + ...json({ identifier: "missing@example.com" }), + }); + await assertStatus(result, 400); +}); + +test("POST /users/refresh-token should reject a missing refresh token", async () => { + const result = await request("/api/v1/users/refresh-token", { + method: "POST", + ...json({}), + }); + await assertStatus(result, 401); +}); + +test("POST /users/forgot-password-otp should reject missing username and email", async () => { + const result = await request("/api/v1/users/forgot-password-otp", { + method: "POST", + ...json({}), + }); + await assertStatus(result, 400); +}); + +test("POST /users/verify-reset-password-otp should reject missing email", async () => { + const result = await request("/api/v1/users/verify-reset-password-otp", { + method: "POST", + ...json({ otp: "123456", newPassword: "Password123", confirmPassword: "Password123" }), + }); + await assertStatus(result, 400); +}); + +test("PATCH /users/change-password should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/change-password", { + method: "PATCH", + ...json({ + oldPassword: "OldPassword123", + newPassword: "NewPassword123", + confirmPassword: "NewPassword123", + }), + }); + await assertStatus(result, 401); +}); + +test("PATCH /users/update-account-details should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/update-account-details", { + method: "PATCH", + ...json({ userFullName: "Updated User" }), + }); + await assertStatus(result, 401); +}); + +test("PATCH /users/change-gmail should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/change-gmail", { + method: "PATCH", + ...json({ email: "new@example.com" }), + }); + await assertStatus(result, 401); +}); + +test("PATCH /users/update-profile-pic should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/update-profile-pic", { + method: "PATCH", + }); + await assertStatus(result, 401); +}); + +test("GET /users/get-current-user should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/get-current-user"); + await assertStatus(result, 401); +}); + +test("GET /users/profile/:userName should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/profile/test-user"); + await assertStatus(result, 401); +}); + +test("GET /users/search should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/search?query=test"); + await assertStatus(result, 401); +}); + +test("DELETE /users/delete-account should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/delete-account", { + method: "DELETE", + ...json({ password: "Password123" }), + }); + await assertStatus(result, 401); +}); + +test("POST /users/logout should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/logout", { method: "POST" }); + await assertStatus(result, 401); +}); + +test("POST /users/send-verification-otp should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/send-verification-otp", { + method: "POST", + }); + await assertStatus(result, 401); +}); + +test("POST /users/verify-email-otp should reject unauthenticated requests", async () => { + const result = await request("/api/v1/users/verify-email-otp", { + method: "POST", + ...json({ otp: "123456" }), + }); + await assertStatus(result, 401); +}); + +test("Authenticated user flow should login when TEST_IDENTIFIER and TEST_PASSWORD are supplied", { skip: !authTestAvailable() }, async () => { + const cookie = await loginAndGetCookie(); + assert.ok(cookie); +}); diff --git a/backend/tests/test-utils.js b/backend/tests/test-utils.js new file mode 100644 index 0000000..08d1dc6 --- /dev/null +++ b/backend/tests/test-utils.js @@ -0,0 +1,66 @@ + +import assert from "node:assert/strict"; + +export const BASE_URL = process.env.BASE_URL || "http://localhost:8000"; +export const TEST_IDENTIFIER = process.env.TEST_IDENTIFIER; +export const TEST_PASSWORD = process.env.TEST_PASSWORD; + +export async function request(path, options = {}) { + const response = await fetch(`${BASE_URL}${path}`, { + redirect: "manual", + ...options, + }); + + const text = await response.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + + return { response, body }; +} + +export async function assertStatus(result, expected, message = "") { + assert.equal( + result.response.status, + expected, + `${message}\nExpected ${expected}, received ${result.response.status}\nResponse: ${JSON.stringify(result.body)}` + ); +} + +export function json(data) { + return { + headers: { "content-type": "application/json" }, + body: JSON.stringify(data), + }; +} + +export function authTestAvailable() { + return Boolean(TEST_IDENTIFIER && TEST_PASSWORD); +} + +export async function loginAndGetCookie() { + if (!authTestAvailable()) return null; + + const result = await request("/api/v1/users/login", { + method: "POST", + ...json({ + identifier: TEST_IDENTIFIER, + password: TEST_PASSWORD, + }), + }); + + assert.equal(result.response.status, 200, JSON.stringify(result.body)); + + const setCookie = result.response.headers.get("set-cookie"); + assert.ok(setCookie, "Login did not return a Set-Cookie header"); + + const cookies = setCookie + .split(/,(?=[^;]+=[^;]+)/) + .map((cookie) => cookie.split(";")[0]) + .join("; "); + + return cookies; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c880e14 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,254 @@ +{ + "name": "bookHub", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "concurrently": "^10.0.5" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4cc9dab --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "concurrently": "^10.0.5" + } +}