From 99c7bb39c5de76f149c1a43cb0c5a80f4bc66196 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 25 Aug 2024 20:02:02 +0200 Subject: [PATCH 01/14] feat: build react-spa frontend --- package.json | 23 ++++++++++---- src/app.ts | 37 +++++++++++++++------- src/client/App.tsx | 5 +++ src/client/index.html | 13 ++++++++ src/client/mount.tsx | 8 +++++ src/client/public/favicon.ico | Bin 0 -> 1086 bytes src/client/tsconfig.json | 24 ++++++++++++++ src/client/vite-env.d.ts | 1 + src/plugins/external/{1-env.ts => env.ts} | 7 ++++ src/routes/home.ts | 24 -------------- test/app/rate-limit.test.ts | 2 +- test/plugins/repository.test.ts | 2 +- test/routes/api/api.test.ts | 10 +++--- test/routes/home.test.ts | 14 -------- tsconfig.json | 3 +- vite.config.js | 9 ++++++ 16 files changed, 116 insertions(+), 66 deletions(-) create mode 100644 src/client/App.tsx create mode 100644 src/client/index.html create mode 100644 src/client/mount.tsx create mode 100644 src/client/public/favicon.ico create mode 100644 src/client/tsconfig.json create mode 100644 src/client/vite-env.d.ts rename src/plugins/external/{1-env.ts => env.ts} (92%) delete mode 100644 src/routes/home.ts delete mode 100644 test/routes/home.test.ts create mode 100644 vite.config.js diff --git a/package.json b/package.json index c962b482..3e1e47dc 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,15 @@ "test": "test" }, "scripts": { - "build": "rm -rf dist && tsc", - "watch": "npm run build -- --watch", + "start": "npm run build && npm run build:client && fastify start -l info dist/app.js", + "build": "tsc", + "build:client": "vite build", + "watch": "tsc -w", + "dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"", + "dev:start": "fastify start --ignore-watch=.ts$ -l info -P dist/app.js", "test": "npm run db:seed && tap --jobs=1 test/**/*", - "start": "fastify start -l info dist/app.js", - "dev": "fastify start -w -l info -P dist/app.js", "standalone": "node --env-file=.env dist/server.js", - "lint": "eslint --ignore-pattern=dist", + "lint": "eslint src --ignore-pattern='src/client/dist'", "lint:fix": "npm run lint -- --fix", "db:migrate": "node --env-file=.env scripts/migrate.js", "db:seed": "node --env-file=.env scripts/seed-database.js" @@ -35,19 +37,26 @@ "@fastify/swagger-ui": "^4.0.1", "@fastify/type-provider-typebox": "^4.0.0", "@fastify/under-pressure": "^8.3.0", + "@fastify/vite": "^6.0.7", "@sinclair/typebox": "^0.33.7", + "@vitejs/plugin-react": "^4.3.1", + "concurrently": "^8.2.2", "fastify": "^4.26.1", "fastify-cli": "^6.1.1", "fastify-plugin": "^4.0.0", - "postgrator": "^7.2.0" + "postgrator": "^7.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" }, "devDependencies": { "@types/node": "^22.0.0", + "@types/react": "^18.3.4", + "@types/react-dom": "^18.3.0", "eslint": "^9.4.0", "fastify-tsconfig": "^2.0.0", "mysql2": "^3.10.1", "neostandard": "^0.7.0", - "tap": "^19.2.2", + "tap": "^21.0.1", "typescript": "^5.4.5" } } diff --git a/src/app.ts b/src/app.ts index 8cf458a0..4d152879 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import path from "node:path"; import fastifyAutoload from "@fastify/autoload"; import { FastifyInstance, FastifyPluginOptions } from "fastify"; +import fastifyVite from "@fastify/vite"; export default async function serviceApp( fastify: FastifyInstance, @@ -14,24 +15,24 @@ export default async function serviceApp( // those should be registered first as your custom plugins might depend on them await fastify.register(fastifyAutoload, { dir: path.join(import.meta.dirname, "plugins/external"), - options: { ...opts } + options: {} }); // This loads all your custom plugins defined in plugins/custom // those should be support plugins that are reused // through your application - fastify.register(fastifyAutoload, { + await fastify.register(fastifyAutoload, { dir: path.join(import.meta.dirname, "plugins/custom"), - options: { ...opts } + options: {} }); // This loads all plugins defined in routes // define your routes in one of these - fastify.register(fastifyAutoload, { + await fastify.register(fastifyAutoload, { dir: path.join(import.meta.dirname, "routes"), autoHooks: true, cascadeHooks: true, - options: { ...opts } + options: {} }); fastify.setErrorHandler((err, request, reply) => { @@ -48,14 +49,10 @@ export default async function serviceApp( "Unhandled error occurred" ); - reply.code(err.statusCode ?? 500); + const statusCode = err.statusCode ?? 500 + reply.code(statusCode); - let message = "Internal Server Error"; - if (err.statusCode === 401) { - message = err.message; - } - - return { message }; + return { message: "Internal Server Error" }; }); // An attacker could search for valid URLs if your 404 error handling is not rate limited. @@ -84,4 +81,20 @@ export default async function serviceApp( return { message: "Not Found" }; }); + + // We setup the SPA + await fastify.register(fastifyVite, function (fastify) { + return { + root: path.resolve(import.meta.dirname, '../'), + dev: fastify.config.FASTIFY_VITE_DEV_MODE, + spa: true + } + }); + + // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base + fastify.get('/', (req, reply) => { + return reply.html(); + }); + + await fastify.vite.ready(); } diff --git a/src/client/App.tsx b/src/client/App.tsx new file mode 100644 index 00000000..f5a76610 --- /dev/null +++ b/src/client/App.tsx @@ -0,0 +1,5 @@ +export function App () { + return ( +

Welcome to the official Fastify demo!

+ ) +} diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 00000000..ce2def89 --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Fastify demo + + +
+ + + diff --git a/src/client/mount.tsx b/src/client/mount.tsx new file mode 100644 index 00000000..deb0ce97 --- /dev/null +++ b/src/client/mount.tsx @@ -0,0 +1,8 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const rootElement = + document.getElementById("root") || document.createElement("div"); + +const root = createRoot(rootElement); +root.render(); diff --git a/src/client/public/favicon.ico b/src/client/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d0d9b4a390f888249af4573d3a43b14958a686f9 GIT binary patch literal 1086 zcmd7R%_~Gv7{~Evyd@UH6g#>NG8-wfMP_d!`3s~d78WeU1}Vvc4H;$O53sOMvtxyY zL{XQ8yp+6@L|otB%sn;9Vyt+k&$-W>&N=g(d(N0e_(Us=*0V&BS+$uZ5QPWzE7FB# zvGS};$Ny;}lu{Y`ZB@-o*v2lp!5hdssuoNx!#z~bIgW9N6q5KJs^5doqX{h-ht4sMJ}hGv`GodXnln(}`B(i- d1l%6EfRD9yiqp-Mf~*N1Y4~_Rz#Dh-z5q(@RGR<* literal 0 HcmV?d00001 diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json new file mode 100644 index 00000000..19982062 --- /dev/null +++ b/src/client/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["**/*"], + "exclude": ["dist"] +} diff --git a/src/client/vite-env.d.ts b/src/client/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/src/client/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/plugins/external/1-env.ts b/src/plugins/external/env.ts similarity index 92% rename from src/plugins/external/1-env.ts rename to src/plugins/external/env.ts index 94613ba3..1b8e83cf 100644 --- a/src/plugins/external/1-env.ts +++ b/src/plugins/external/env.ts @@ -11,6 +11,7 @@ declare module "fastify" { MYSQL_DATABASE: string; JWT_SECRET: string; RATE_LIMIT_MAX: number; + FASTIFY_VITE_DEV_MODE: boolean; }; } } @@ -52,6 +53,12 @@ const schema = { RATE_LIMIT_MAX: { type: "number", default: 100 + }, + + // Frontend + FASTIFY_VITE_DEV_MODE: { + type: "boolean", + default: true } } }; diff --git a/src/routes/home.ts b/src/routes/home.ts deleted file mode 100644 index eb2cfd7c..00000000 --- a/src/routes/home.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - FastifyPluginAsyncTypebox, - Type -} from "@fastify/type-provider-typebox"; - -const plugin: FastifyPluginAsyncTypebox = async (fastify) => { - fastify.get( - "/", - { - schema: { - response: { - 200: Type.Object({ - message: Type.String() - }) - } - } - }, - async function () { - return { message: "Welcome to the official fastify demo!" }; - } - ); -}; - -export default plugin; diff --git a/test/app/rate-limit.test.ts b/test/app/rate-limit.test.ts index 998fcd67..3b202b0d 100644 --- a/test/app/rate-limit.test.ts +++ b/test/app/rate-limit.test.ts @@ -11,7 +11,7 @@ it("should be rate limited", async (t) => { url: "/" }); - assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.statusCode, 200); } const res = await app.inject({ diff --git a/test/plugins/repository.test.ts b/test/plugins/repository.test.ts index 2f143a78..4244f636 100644 --- a/test/plugins/repository.test.ts +++ b/test/plugins/repository.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { execSync } from "child_process"; import Fastify from "fastify"; import repository from "../../src/plugins/custom/repository.js"; -import * as envPlugin from "../../src/plugins/external/1-env.js"; +import * as envPlugin from "../../src/plugins/external/env.js"; import * as mysqlPlugin from "../../src/plugins/external/mysql.js"; import { Auth } from '../../src/schemas/auth.js'; diff --git a/test/routes/api/api.test.ts b/test/routes/api/api.test.ts index b41ac46f..30c2bec9 100644 --- a/test/routes/api/api.test.ts +++ b/test/routes/api/api.test.ts @@ -9,9 +9,8 @@ test("GET /api without authorization header", async (t) => { url: "/api" }); - assert.deepStrictEqual(JSON.parse(res.payload), { - message: "No Authorization was found in request.headers" - }); + assert.equal(res.statusCode, 401) + assert.deepStrictEqual(JSON.parse(res.payload).message, "No Authorization was found in request.headers"); }); test("GET /api without JWT Token", async (t) => { @@ -25,9 +24,8 @@ test("GET /api without JWT Token", async (t) => { } }); - assert.deepStrictEqual(JSON.parse(res.payload), { - message: "Authorization token is invalid: The token is malformed." - }); + assert.equal(res.statusCode, 401) + assert.deepStrictEqual(JSON.parse(res.payload).message, "Authorization token is invalid: The token is malformed."); }); test("GET /api with JWT Token", async (t) => { diff --git a/test/routes/home.test.ts b/test/routes/home.test.ts deleted file mode 100644 index 6069230e..00000000 --- a/test/routes/home.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { build } from "../helper.js"; - -test("GET /", async (t) => { - const app = await build(t); - const res = await app.inject({ - url: "/" - }); - - assert.deepStrictEqual(JSON.parse(res.payload), { - message: "Welcome to the official fastify demo!" - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 6fe21f5a..d3f3bfbe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,6 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["@types", "src/**/*.ts"] + "include": ["@types", "src/**/*.ts"], + "exclude": ["src/client/**/*"] } diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 00000000..dbe912c2 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,9 @@ +import { resolve } from 'node:path' +import viteReact from '@vitejs/plugin-react' + +/** @type {import('vite').UserConfig} */ +export default { + base: '/', + root: resolve(import.meta.dirname, 'src/client'), + plugins: [viteReact()] +} From 1d062c879379f2523dc3c171ac293beda26db101 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 25 Aug 2024 20:13:54 +0200 Subject: [PATCH 02/14] chore: set FASTIFY_VITE_DEV_MODE = true in ci --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e040d3f..b71f5286 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,4 +68,5 @@ jobs: MYSQL_PASSWORD: test_password # JWT_SECRET is dynamically generated and loaded from the environment RATE_LIMIT_MAX: 4 + FASTIFY_VITE_DEV_MODE: true run: npm run db:migrate && npm run test From 908fb5e2e9e3f451064cec60bcacb5c97fad1150 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 25 Aug 2024 20:19:52 +0200 Subject: [PATCH 03/14] test: try to ignore vite registration to know if it is the reason it stales --- src/app.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app.ts b/src/app.ts index 4d152879..9cb5917f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,7 @@ import path from "node:path"; import fastifyAutoload from "@fastify/autoload"; import { FastifyInstance, FastifyPluginOptions } from "fastify"; -import fastifyVite from "@fastify/vite"; +// import fastifyVite from "@fastify/vite"; export default async function serviceApp( fastify: FastifyInstance, @@ -83,18 +83,18 @@ export default async function serviceApp( }); // We setup the SPA - await fastify.register(fastifyVite, function (fastify) { - return { - root: path.resolve(import.meta.dirname, '../'), - dev: fastify.config.FASTIFY_VITE_DEV_MODE, - spa: true - } - }); + // await fastify.register(fastifyVite, function (fastify) { + // return { + // root: path.resolve(import.meta.dirname, '../'), + // dev: fastify.config.FASTIFY_VITE_DEV_MODE, + // spa: true + // } + // }); // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base fastify.get('/', (req, reply) => { - return reply.html(); + return "hello"; }); - await fastify.vite.ready(); + // await fastify.vite.ready(); } From b68886a4377046fd29867ef939678f54109afb2b Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 25 Aug 2024 20:25:46 +0200 Subject: [PATCH 04/14] test: revert plugin registration --- src/app.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app.ts b/src/app.ts index 9cb5917f..4d152879 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,7 @@ import path from "node:path"; import fastifyAutoload from "@fastify/autoload"; import { FastifyInstance, FastifyPluginOptions } from "fastify"; -// import fastifyVite from "@fastify/vite"; +import fastifyVite from "@fastify/vite"; export default async function serviceApp( fastify: FastifyInstance, @@ -83,18 +83,18 @@ export default async function serviceApp( }); // We setup the SPA - // await fastify.register(fastifyVite, function (fastify) { - // return { - // root: path.resolve(import.meta.dirname, '../'), - // dev: fastify.config.FASTIFY_VITE_DEV_MODE, - // spa: true - // } - // }); + await fastify.register(fastifyVite, function (fastify) { + return { + root: path.resolve(import.meta.dirname, '../'), + dev: fastify.config.FASTIFY_VITE_DEV_MODE, + spa: true + } + }); // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base fastify.get('/', (req, reply) => { - return "hello"; + return reply.html(); }); - // await fastify.vite.ready(); + await fastify.vite.ready(); } From 236f150e7bc4c588c5d874fa8fa18c800433013c Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 14 Sep 2024 11:53:57 +0200 Subject: [PATCH 05/14] chore: add vite build in CI --- .env.example | 1 + .github/workflows/ci.yml | 7 +++++-- package.json | 1 - src/app.ts | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index d6f44897..e48c4aaf 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ MYSQL_PASSWORD=test_password # Server FASTIFY_CLOSE_GRACE_DELAY=1000 LOG_LEVEL=info +FASTIFY_VITE_DEV_MODE=false # Security JWT_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b71f5286..c781c3ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,9 @@ jobs: - name: Lint Code run: npm run lint + - name: Build Vite + run: npm run build:client + - name: Generate JWT Secret id: gen-jwt run: | @@ -61,12 +64,12 @@ jobs: - name: Test env: + # JWT_SECRET is dynamically generated and loaded from the environment MYSQL_HOST: localhost MYSQL_PORT: 3306 MYSQL_DATABASE: test_db MYSQL_USER: test_user MYSQL_PASSWORD: test_password - # JWT_SECRET is dynamically generated and loaded from the environment RATE_LIMIT_MAX: 4 - FASTIFY_VITE_DEV_MODE: true + FASTIFY_VITE_DEV_MODE: false run: npm run db:migrate && npm run test diff --git a/package.json b/package.json index d28521bc..3e1e47dc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "watch": "tsc -w", "dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"", "dev:start": "fastify start --ignore-watch=.ts$ -l info -P dist/app.js", - "test": "npm run db:seed && tap --jobs=1 test/**/*", "standalone": "node --env-file=.env dist/server.js", "lint": "eslint src --ignore-pattern='src/client/dist'", diff --git a/src/app.ts b/src/app.ts index 3b5d4c07..4b4315a9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,6 +21,7 @@ export default async function serviceApp( opts: FastifyPluginOptions ) { delete opts.skipOverride // This option only serves testing purpose + // This loads all external plugins defined in plugins/external // those should be registered first as your custom plugins might depend on them await fastify.register(fastifyAutoload, { From 8ebdf40dd25ffa023ab4fd9bbe926acde71a2b84 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 14 Sep 2024 11:58:56 +0200 Subject: [PATCH 06/14] fix: set FASTIFY_VITE_DEV_MODE to true --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c781c3ef..fefc8d92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,5 +71,5 @@ jobs: MYSQL_USER: test_user MYSQL_PASSWORD: test_password RATE_LIMIT_MAX: 4 - FASTIFY_VITE_DEV_MODE: false + FASTIFY_VITE_DEV_MODE: true run: npm run db:migrate && npm run test From 3d8f615e1c29a72b6bb09546c7be0db1e9cebf63 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 14 Sep 2024 12:03:55 +0200 Subject: [PATCH 07/14] fix: echo FASTIFY_VITE_DEV_MODE --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fefc8d92..adf72f76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,4 +72,6 @@ jobs: MYSQL_PASSWORD: test_password RATE_LIMIT_MAX: 4 FASTIFY_VITE_DEV_MODE: true - run: npm run db:migrate && npm run test + run: | + echo "FASTIFY_VITE_DEV_MODE=$FASTIFY_VITE_DEV_MODE" + npm run db:migrate && npm run test From 2c1fd0e6da5797d0473e1997e7ac94e7e3d8b9ba Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 14 Sep 2024 13:04:40 +0200 Subject: [PATCH 08/14] skip fastifyVite registration during tests --- .github/workflows/ci.yml | 6 +-- src/app.ts | 89 ++++++++++++++++++++++--------------- test/app/rate-limit.test.ts | 1 + test/helper.ts | 3 +- 4 files changed, 59 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf72f76..c781c3ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,5 @@ jobs: MYSQL_USER: test_user MYSQL_PASSWORD: test_password RATE_LIMIT_MAX: 4 - FASTIFY_VITE_DEV_MODE: true - run: | - echo "FASTIFY_VITE_DEV_MODE=$FASTIFY_VITE_DEV_MODE" - npm run db:migrate && npm run test + FASTIFY_VITE_DEV_MODE: false + run: npm run db:migrate && npm run test diff --git a/src/app.ts b/src/app.ts index 4b4315a9..bac2b265 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,7 +10,7 @@ import fastifyVite from "@fastify/vite"; export const options = { ajv: { customOptions: { - coerceTypes: "array", + coerceTypes: "array", removeAdditional: "all" } } @@ -20,7 +20,7 @@ export default async function serviceApp( fastify: FastifyInstance, opts: FastifyPluginOptions ) { - delete opts.skipOverride // This option only serves testing purpose + const { avoidViteRegistration = true } = opts; // This loads all external plugins defined in plugins/external // those should be registered first as your custom plugins might depend on them @@ -60,7 +60,7 @@ export default async function serviceApp( "Unhandled error occurred" ); - const statusCode = err.statusCode ?? 500 + const statusCode = err.statusCode ?? 500; reply.code(statusCode); return { message: "Internal Server Error" }; @@ -68,44 +68,63 @@ export default async function serviceApp( // An attacker could search for valid URLs if your 404 error handling is not rate limited. fastify.setNotFoundHandler( - { - preHandler: fastify.rateLimit({ - max: 3, - timeWindow: 500 - }) - }, + { + preHandler: fastify.rateLimit({ + max: 3, + timeWindow: 500 + }) + }, (request, reply) => { + request.log.warn( + { + request: { + method: request.method, + url: request.url, + query: request.query, + params: request.params + } + }, + "Resource not found" + ); - request.log.warn( - { - request: { - method: request.method, - url: request.url, - query: request.query, - params: request.params - } - }, - "Resource not found" - ); + reply.code(404); - reply.code(404); + return { message: "Not Found" }; + } + ); - return { message: "Not Found" }; + await handleVite(fastify, { + register: !avoidViteRegistration }); +} - // We setup the SPA - await fastify.register(fastifyVite, function (fastify) { - return { - root: path.resolve(import.meta.dirname, '../'), - dev: fastify.config.FASTIFY_VITE_DEV_MODE, - spa: true - } - }); - +async function handleVite( + fastify: FastifyInstance, + { register }: { register: boolean } +) { + if (!register) { // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base - fastify.get('/', (req, reply) => { - return reply.html(); + fastify.get("/", () => { + return "Vite is not registered."; }); - - await fastify.vite.ready(); + + return; + } + /* c8 ignore start - We don't launch the spa tests with the api tests */ + // We setup the SPA + await fastify.register(fastifyVite, function (fastify) { + return { + root: path.resolve(import.meta.dirname, "../"), + dev: fastify.config.FASTIFY_VITE_DEV_MODE, + spa: true + }; + }); + + // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base + fastify.get("/", (req, reply) => { + return reply.html(); + }); + + await fastify.vite.ready(); + /* c8 ignore end */ } diff --git a/test/app/rate-limit.test.ts b/test/app/rate-limit.test.ts index 3b202b0d..ca7059a0 100644 --- a/test/app/rate-limit.test.ts +++ b/test/app/rate-limit.test.ts @@ -11,6 +11,7 @@ it("should be rate limited", async (t) => { url: "/" }); + assert.equal(res.body, "Vite is not registered.") assert.strictEqual(res.statusCode, 200); } diff --git a/test/helper.ts b/test/helper.ts index dad5f983..074f3ecb 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -18,7 +18,8 @@ const AppPath = path.join(import.meta.dirname, "../src/app.ts"); // needed for testing the application export function config() { return { - skipOverride: "true" // Register our application with fastify-plugin + skipOverride: true, // Register our application with fastify-plugin + avoidViteRegistration : true }; } From 061303653586eae01711d4713f42552b6ab83fea Mon Sep 17 00:00:00 2001 From: jean Date: Mon, 16 Sep 2024 13:22:10 +0200 Subject: [PATCH 09/14] chore: add mysql image healthcheck && MYSQL_ROOT_PASSWORD --- .env.example | 3 ++- README.md | 3 +++ docker-compose.yml | 14 ++++++++++---- mysql-init/init.sql | 4 ++++ test/routes/api/tasks/tasks.test.ts | 1 - 5 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 mysql-init/init.sql diff --git a/.env.example b/.env.example index e48c4aaf..4c60ca67 100644 --- a/.env.example +++ b/.env.example @@ -16,4 +16,5 @@ FASTIFY_VITE_DEV_MODE=false # Security JWT_SECRET= -RATE_LIMIT_MAX= +RATE_LIMIT_MAX=4 # For tests + diff --git a/README.md b/README.md index d5301a6e..5acefc0c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ Install the dependencies: npm install ``` +Configure a `.env` file, see [.env.example](/.env.example) at root of the project. + + ### Database You can run a MySQL instance with Docker: ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 10830fc3..69bdfc7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,19 @@ services: db: image: mysql:8.4 environment: - MYSQL_DATABASE: ${MYSQL_DATABASE} - MYSQL_USER: ${MYSQL_USER} - MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} ports: - 3306:3306 + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-u${MYSQL_USER}", "-p${MYSQL_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 3 volumes: - db_data:/var/lib/mysql - + volumes: db_data: diff --git a/mysql-init/init.sql b/mysql-init/init.sql new file mode 100644 index 00000000..c7d75f43 --- /dev/null +++ b/mysql-init/init.sql @@ -0,0 +1,4 @@ +-- File: ./mysql-init/init.sql +CREATE USER IF NOT EXISTS '${MYSQL_USER}'@'%' IDENTIFIED BY '${MYSQL_PASSWORD}'; +GRANT ALL PRIVILEGES ON ${MYSQL_DATABASE}.* TO '${MYSQL_USER}'@'%'; +FLUSH PRIVILEGES; diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index ae82067c..a527ae64 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -181,7 +181,6 @@ describe('Tasks api (logged user only)', () => { }); describe('POST /api/tasks/:id/assign', () => { - it("should assign a task to a user and persist the changes", async (t) => { const app = await build(t); From e70d453a37374a2a0456fc528e7f890e98287631 Mon Sep 17 00:00:00 2001 From: jean Date: Mon, 16 Sep 2024 13:23:37 +0200 Subject: [PATCH 10/14] fix: remove mysql-init/init.sql --- mysql-init/init.sql | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 mysql-init/init.sql diff --git a/mysql-init/init.sql b/mysql-init/init.sql deleted file mode 100644 index c7d75f43..00000000 --- a/mysql-init/init.sql +++ /dev/null @@ -1,4 +0,0 @@ --- File: ./mysql-init/init.sql -CREATE USER IF NOT EXISTS '${MYSQL_USER}'@'%' IDENTIFIED BY '${MYSQL_PASSWORD}'; -GRANT ALL PRIVILEGES ON ${MYSQL_DATABASE}.* TO '${MYSQL_USER}'@'%'; -FLUSH PRIVILEGES; From 41d3f7f3ed668ad773ceea02d26bce5498f333fa Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 18:22:46 +0200 Subject: [PATCH 11/14] chore: upgrade neostandard --- package.json | 2 +- src/app.ts | 78 +++++++++---------- src/client/mount.tsx | 10 +-- src/plugins/custom/repository.ts | 96 ++++++++++++------------ src/plugins/custom/scrypt.ts | 40 +++++----- src/plugins/external/cors.ts | 6 +- src/plugins/external/env.ts | 44 +++++------ src/plugins/external/helmet.ts | 6 +- src/plugins/external/jwt.ts | 10 +-- src/plugins/external/mysql.ts | 16 ++-- src/plugins/external/rate-limit.ts | 12 +-- src/plugins/external/sensible.ts | 6 +- src/plugins/external/swagger.ts | 18 ++--- src/plugins/external/under-pressure.ts | 30 ++++---- src/routes/api/auth/index.ts | 28 +++---- src/routes/api/autohooks.ts | 11 ++- src/routes/api/index.ts | 8 +- src/routes/api/tasks/index.ts | 100 ++++++++++++------------- src/schemas/auth.ts | 4 +- src/schemas/tasks.ts | 17 ++--- src/server.ts | 48 ++++++------ 21 files changed, 292 insertions(+), 298 deletions(-) diff --git a/package.json b/package.json index 3e1e47dc..ec2615aa 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "eslint": "^9.4.0", "fastify-tsconfig": "^2.0.0", "mysql2": "^3.10.1", - "neostandard": "^0.7.0", + "neostandard": "^0.11.5", "tap": "^21.0.1", "typescript": "^5.4.5" } diff --git a/src/app.ts b/src/app.ts index bac2b265..ec504eaa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,49 +2,49 @@ * If you would like to turn your application into a standalone executable, look at server.js file */ -import path from "node:path"; -import fastifyAutoload from "@fastify/autoload"; -import { FastifyInstance, FastifyPluginOptions } from "fastify"; -import fastifyVite from "@fastify/vite"; +import path from 'node:path' +import fastifyAutoload from '@fastify/autoload' +import { FastifyInstance, FastifyPluginOptions } from 'fastify' +import fastifyVite from '@fastify/vite' export const options = { ajv: { customOptions: { - coerceTypes: "array", - removeAdditional: "all" + coerceTypes: 'array', + removeAdditional: 'all' } } -}; +} -export default async function serviceApp( +export default async function serviceApp ( fastify: FastifyInstance, opts: FastifyPluginOptions ) { - const { avoidViteRegistration = true } = opts; + const { avoidViteRegistration = true } = opts // This loads all external plugins defined in plugins/external // those should be registered first as your custom plugins might depend on them await fastify.register(fastifyAutoload, { - dir: path.join(import.meta.dirname, "plugins/external"), + dir: path.join(import.meta.dirname, 'plugins/external'), options: {} - }); + }) // This loads all your custom plugins defined in plugins/custom // those should be support plugins that are reused // through your application await fastify.register(fastifyAutoload, { - dir: path.join(import.meta.dirname, "plugins/custom"), + dir: path.join(import.meta.dirname, 'plugins/custom'), options: {} - }); + }) // This loads all plugins defined in routes // define your routes in one of these await fastify.register(fastifyAutoload, { - dir: path.join(import.meta.dirname, "routes"), + dir: path.join(import.meta.dirname, 'routes'), autoHooks: true, cascadeHooks: true, options: {} - }); + }) fastify.setErrorHandler((err, request, reply) => { request.log.error( @@ -57,14 +57,14 @@ export default async function serviceApp( params: request.params } }, - "Unhandled error occurred" - ); + 'Unhandled error occurred' + ) - const statusCode = err.statusCode ?? 500; - reply.code(statusCode); + const statusCode = err.statusCode ?? 500 + reply.code(statusCode) - return { message: "Internal Server Error" }; - }); + return { message: 'Internal Server Error' } + }) // An attacker could search for valid URLs if your 404 error handling is not rate limited. fastify.setNotFoundHandler( @@ -84,47 +84,47 @@ export default async function serviceApp( params: request.params } }, - "Resource not found" - ); + 'Resource not found' + ) - reply.code(404); + reply.code(404) - return { message: "Not Found" }; + return { message: 'Not Found' } } - ); + ) await handleVite(fastify, { register: !avoidViteRegistration - }); + }) } -async function handleVite( +async function handleVite ( fastify: FastifyInstance, { register }: { register: boolean } ) { if (!register) { // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base - fastify.get("/", () => { - return "Vite is not registered."; - }); + fastify.get('/', () => { + return 'Vite is not registered.' + }) - return; + return } /* c8 ignore start - We don't launch the spa tests with the api tests */ // We setup the SPA await fastify.register(fastifyVite, function (fastify) { return { - root: path.resolve(import.meta.dirname, "../"), + root: path.resolve(import.meta.dirname, '../'), dev: fastify.config.FASTIFY_VITE_DEV_MODE, spa: true - }; - }); + } + }) // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base - fastify.get("/", (req, reply) => { - return reply.html(); - }); + fastify.get('/', (req, reply) => { + return reply.html() + }) - await fastify.vite.ready(); + await fastify.vite.ready() /* c8 ignore end */ } diff --git a/src/client/mount.tsx b/src/client/mount.tsx index deb0ce97..d8f41317 100644 --- a/src/client/mount.tsx +++ b/src/client/mount.tsx @@ -1,8 +1,8 @@ -import { createRoot } from "react-dom/client"; -import { App } from "./App"; +import { createRoot } from 'react-dom/client' +import { App } from './App' const rootElement = - document.getElementById("root") || document.createElement("div"); + document.getElementById('root') || document.createElement('div') -const root = createRoot(rootElement); -root.render(); +const root = createRoot(rootElement) +root.render() diff --git a/src/plugins/custom/repository.ts b/src/plugins/custom/repository.ts index da3cd518..1ef3d7f8 100644 --- a/src/plugins/custom/repository.ts +++ b/src/plugins/custom/repository.ts @@ -1,96 +1,96 @@ -import { MySQLPromisePool } from "@fastify/mysql"; -import { FastifyInstance } from "fastify"; -import fp from "fastify-plugin"; -import { RowDataPacket, ResultSetHeader } from "mysql2"; +import { MySQLPromisePool } from '@fastify/mysql' +import { FastifyInstance } from 'fastify' +import fp from 'fastify-plugin' +import { RowDataPacket, ResultSetHeader } from 'mysql2' -declare module "fastify" { +declare module 'fastify' { export interface FastifyInstance { repository: Repository; } } -export type Repository = MySQLPromisePool & ReturnType; +export type Repository = MySQLPromisePool & ReturnType -type QuerySeparator = 'AND' | ','; +type QuerySeparator = 'AND' | ',' type QueryOptions = { select?: string; where?: Record; -}; +} type WriteOptions = { data: Record; where?: Record; -}; +} -function createRepository(fastify: FastifyInstance) { +function createRepository (fastify: FastifyInstance) { const processAssignmentRecord = (record: Record, separator: QuerySeparator) => { - const keys = Object.keys(record); - const values = Object.values(record); - const clause = keys.map((key) => `${key} = ?`).join(` ${separator} `); + const keys = Object.keys(record) + const values = Object.values(record) + const clause = keys.map((key) => `${key} = ?`).join(` ${separator} `) - return [clause, values] as const; - }; + return [clause, values] as const + } const repository = { ...fastify.mysql, find: async (table: string, opts: QueryOptions): Promise => { - const { select = '*', where = {1:1} } = opts; - const [clause, values] = processAssignmentRecord(where, 'AND'); + const { select = '*', where = { 1: 1 } } = opts + const [clause, values] = processAssignmentRecord(where, 'AND') - const query = `SELECT ${select} FROM ${table} WHERE ${clause} LIMIT 1`; - const [rows] = await fastify.mysql.query(query, values); + const query = `SELECT ${select} FROM ${table} WHERE ${clause} LIMIT 1` + const [rows] = await fastify.mysql.query(query, values) if (rows.length < 1) { - return null; + return null } - return rows[0] as T; + return rows[0] as T }, findMany: async (table: string, opts: QueryOptions = {}): Promise => { - const { select = '*', where = {1:1} } = opts; - const [clause, values] = processAssignmentRecord(where, 'AND'); + const { select = '*', where = { 1: 1 } } = opts + const [clause, values] = processAssignmentRecord(where, 'AND') - const query = `SELECT ${select} FROM ${table} WHERE ${clause}`; - const [rows] = await fastify.mysql.query(query, values); + const query = `SELECT ${select} FROM ${table} WHERE ${clause}` + const [rows] = await fastify.mysql.query(query, values) - return rows as T[]; + return rows as T[] }, create: async (table: string, opts: WriteOptions): Promise => { - const { data } = opts; - const columns = Object.keys(data).join(', '); - const placeholders = Object.keys(data).map(() => '?').join(', '); - const values = Object.values(data); + const { data } = opts + const columns = Object.keys(data).join(', ') + const placeholders = Object.keys(data).map(() => '?').join(', ') + const values = Object.values(data) - const query = `INSERT INTO ${table} (${columns}) VALUES (${placeholders})`; - const [result] = await fastify.mysql.query(query, values); + const query = `INSERT INTO ${table} (${columns}) VALUES (${placeholders})` + const [result] = await fastify.mysql.query(query, values) - return result.insertId; + return result.insertId }, update: async (table: string, opts: WriteOptions): Promise => { - const { data, where = {} } = opts; - const [dataClause, dataValues] = processAssignmentRecord(data, ','); - const [whereClause, whereValues] = processAssignmentRecord(where, 'AND'); + const { data, where = {} } = opts + const [dataClause, dataValues] = processAssignmentRecord(data, ',') + const [whereClause, whereValues] = processAssignmentRecord(where, 'AND') - const query = `UPDATE ${table} SET ${dataClause} WHERE ${whereClause}`; - const [result] = await fastify.mysql.query(query, [...dataValues, ...whereValues]); + const query = `UPDATE ${table} SET ${dataClause} WHERE ${whereClause}` + const [result] = await fastify.mysql.query(query, [...dataValues, ...whereValues]) - return result.affectedRows; + return result.affectedRows }, delete: async (table: string, where: Record): Promise => { - const [clause, values] = processAssignmentRecord(where, 'AND'); + const [clause, values] = processAssignmentRecord(where, 'AND') - const query = `DELETE FROM ${table} WHERE ${clause}`; - const [result] = await fastify.mysql.query(query, values); + const query = `DELETE FROM ${table} WHERE ${clause}` + const [result] = await fastify.mysql.query(query, values) - return result.affectedRows; + return result.affectedRows } - }; + } - return repository; + return repository } /** @@ -101,9 +101,9 @@ function createRepository(fastify: FastifyInstance) { */ export default fp( async function (fastify) { - fastify.decorate("repository", createRepository(fastify)); + fastify.decorate('repository', createRepository(fastify)) // You should name your plugins if you want to avoid name collisions // and/or to perform dependency checks. }, - { name: "repository", dependencies: ['mysql'] } -); + { name: 'repository', dependencies: ['mysql'] } +) diff --git a/src/plugins/custom/scrypt.ts b/src/plugins/custom/scrypt.ts index f643377b..1c78b8e2 100644 --- a/src/plugins/custom/scrypt.ts +++ b/src/plugins/custom/scrypt.ts @@ -1,12 +1,12 @@ -import fp from 'fastify-plugin'; +import fp from 'fastify-plugin' import { scrypt, timingSafeEqual, randomBytes } from 'crypto' -declare module "fastify" { - export interface FastifyInstance { - hash: typeof scryptHash; - compare: typeof compare - } +declare module 'fastify' { + export interface FastifyInstance { + hash: typeof scryptHash; + compare: typeof compare } +} const SCRYPT_KEYLEN = 32 const SCRYPT_COST = 65536 @@ -14,11 +14,11 @@ const SCRYPT_BLOCK_SIZE = 8 const SCRYPT_PARALLELIZATION = 2 const SCRYPT_MAXMEM = 128 * SCRYPT_COST * SCRYPT_BLOCK_SIZE * 2 -async function scryptHash(value: string): Promise { +async function scryptHash (value: string): Promise { return new Promise((resolve, reject) => { const salt = randomBytes(Math.min(16, SCRYPT_KEYLEN / 2)) - scrypt(value, salt, SCRYPT_KEYLEN, { + scrypt(value, salt, SCRYPT_KEYLEN, { cost: SCRYPT_COST, blockSize: SCRYPT_BLOCK_SIZE, parallelization: SCRYPT_PARALLELIZATION, @@ -27,22 +27,20 @@ async function scryptHash(value: string): Promise { /* c8 ignore start - Requires extreme or impractical configuration values */ if (error !== null) { reject(error) - } - /* c8 ignore end */ - else { + } /* c8 ignore end */ else { resolve(`${salt.toString('hex')}.${key.toString('hex')}`) } }) }) } -async function compare(value: string, hash: string): Promise { +async function compare (value: string, hash: string): Promise { const [salt, hashed] = hash.split('.') - const saltBuffer = Buffer.from(salt, 'hex'); + const saltBuffer = Buffer.from(salt, 'hex') const hashedBuffer = Buffer.from(hashed, 'hex') return new Promise((resolve) => { - scrypt(value, saltBuffer, SCRYPT_KEYLEN, { + scrypt(value, saltBuffer, SCRYPT_KEYLEN, { cost: SCRYPT_COST, blockSize: SCRYPT_BLOCK_SIZE, parallelization: SCRYPT_PARALLELIZATION, @@ -52,18 +50,16 @@ async function compare(value: string, hash: string): Promise { if (error !== null) { timingSafeEqual(hashedBuffer, hashedBuffer) resolve(false) - } - /* c8 ignore end */ - else { - resolve(timingSafeEqual(key, hashedBuffer)) - } + } /* c8 ignore end */ else { + resolve(timingSafeEqual(key, hashedBuffer)) + } }) }) } export default fp(async (fastify) => { - fastify.decorate('hash', scryptHash); - fastify.decorate('compare', compare); + fastify.decorate('hash', scryptHash) + fastify.decorate('compare', compare) }, { name: 'scrypt' -}); +}) diff --git a/src/plugins/external/cors.ts b/src/plugins/external/cors.ts index 1b6906af..7eaaa6be 100644 --- a/src/plugins/external/cors.ts +++ b/src/plugins/external/cors.ts @@ -1,12 +1,12 @@ -import cors, { FastifyCorsOptions } from "@fastify/cors"; +import cors, { FastifyCorsOptions } from '@fastify/cors' export const autoConfig: FastifyCorsOptions = { methods: ['GET', 'POST', 'PUT', 'DELETE'] -}; +} /** * This plugins enables the use of CORS. * * @see {@link https://github.com/fastify/fastify-cors} */ -export default cors; +export default cors diff --git a/src/plugins/external/env.ts b/src/plugins/external/env.ts index 1b8e83cf..55bd5d40 100644 --- a/src/plugins/external/env.ts +++ b/src/plugins/external/env.ts @@ -1,6 +1,6 @@ -import env from "@fastify/env"; +import env from '@fastify/env' -declare module "fastify" { +declare module 'fastify' { export interface FastifyInstance { config: { PORT: number; @@ -17,56 +17,56 @@ declare module "fastify" { } const schema = { - type: "object", + type: 'object', required: [ - "MYSQL_HOST", - "MYSQL_PORT", - "MYSQL_USER", - "MYSQL_PASSWORD", - "MYSQL_DATABASE", - "JWT_SECRET" + 'MYSQL_HOST', + 'MYSQL_PORT', + 'MYSQL_USER', + 'MYSQL_PASSWORD', + 'MYSQL_DATABASE', + 'JWT_SECRET' ], properties: { // Database MYSQL_HOST: { - type: "string", - default: "localhost" + type: 'string', + default: 'localhost' }, MYSQL_PORT: { - type: "number", + type: 'number', default: 3306 }, MYSQL_USER: { - type: "string" + type: 'string' }, MYSQL_PASSWORD: { - type: "string" + type: 'string' }, MYSQL_DATABASE: { - type: "string" + type: 'string' }, // Security JWT_SECRET: { - type: "string" + type: 'string' }, RATE_LIMIT_MAX: { - type: "number", + type: 'number', default: 100 }, // Frontend FASTIFY_VITE_DEV_MODE: { - type: "boolean", + type: 'boolean', default: true } } -}; +} export const autoConfig = { // Decorate Fastify instance with `config` key // Optional, default: 'config' - confKey: "config", + confKey: 'config', // Schema to validate schema, @@ -82,11 +82,11 @@ export const autoConfig = { // Source for the configuration data // Optional, default: process.env data: process.env -}; +} /** * This plugins helps to check environment variables. * * @see {@link https://github.com/fastify/fastify-env} */ -export default env; +export default env diff --git a/src/plugins/external/helmet.ts b/src/plugins/external/helmet.ts index 86f7e5e8..77d61123 100644 --- a/src/plugins/external/helmet.ts +++ b/src/plugins/external/helmet.ts @@ -1,12 +1,12 @@ -import helmet from "@fastify/helmet"; +import helmet from '@fastify/helmet' export const autoConfig = { // Set plugin options here -}; +} /** * This plugins sets the basic security headers. * * @see {@link https://github.com/fastify/fastify-helmet} */ -export default helmet; +export default helmet diff --git a/src/plugins/external/jwt.ts b/src/plugins/external/jwt.ts index 60f4e34e..f4213ec6 100644 --- a/src/plugins/external/jwt.ts +++ b/src/plugins/external/jwt.ts @@ -1,10 +1,10 @@ -import fastifyJwt from "@fastify/jwt"; -import { FastifyInstance } from "fastify"; +import fastifyJwt from '@fastify/jwt' +import { FastifyInstance } from 'fastify' export const autoConfig = (fastify: FastifyInstance) => { return { secret: fastify.config.JWT_SECRET - }; -}; + } +} -export default fastifyJwt; +export default fastifyJwt diff --git a/src/plugins/external/mysql.ts b/src/plugins/external/mysql.ts index 73051bf8..0d401936 100644 --- a/src/plugins/external/mysql.ts +++ b/src/plugins/external/mysql.ts @@ -1,8 +1,8 @@ -import fp from "fastify-plugin"; -import fastifyMysql, { MySQLPromisePool } from "@fastify/mysql"; -import { FastifyInstance } from "fastify"; +import fp from 'fastify-plugin' +import fastifyMysql, { MySQLPromisePool } from '@fastify/mysql' +import { FastifyInstance } from 'fastify' -declare module "fastify" { +declare module 'fastify' { export interface FastifyInstance { mysql: MySQLPromisePool; } @@ -16,9 +16,9 @@ export const autoConfig = (fastify: FastifyInstance) => { password: fastify.config.MYSQL_PASSWORD, database: fastify.config.MYSQL_DATABASE, port: Number(fastify.config.MYSQL_PORT) - }; -}; + } +} export default fp(fastifyMysql, { - name: "mysql" -}); + name: 'mysql' +}) diff --git a/src/plugins/external/rate-limit.ts b/src/plugins/external/rate-limit.ts index d366bd95..2e5821e8 100644 --- a/src/plugins/external/rate-limit.ts +++ b/src/plugins/external/rate-limit.ts @@ -1,11 +1,11 @@ -import fastifyRateLimit from "@fastify/rate-limit"; -import { FastifyInstance } from "fastify"; +import fastifyRateLimit from '@fastify/rate-limit' +import { FastifyInstance } from 'fastify' export const autoConfig = (fastify: FastifyInstance) => { - return { - max: fastify.config.RATE_LIMIT_MAX, - timeWindow: "1 minute" - } + return { + max: fastify.config.RATE_LIMIT_MAX, + timeWindow: '1 minute' + } } /** diff --git a/src/plugins/external/sensible.ts b/src/plugins/external/sensible.ts index fba13930..213e09d1 100644 --- a/src/plugins/external/sensible.ts +++ b/src/plugins/external/sensible.ts @@ -1,12 +1,12 @@ -import sensible from "@fastify/sensible"; +import sensible from '@fastify/sensible' export const autoConfig = { // Set plugin options here -}; +} /** * This plugin adds some utilities to handle http errors * * @see {@link https://github.com/fastify/fastify-sensible} */ -export default sensible; +export default sensible diff --git a/src/plugins/external/swagger.ts b/src/plugins/external/swagger.ts index 5b8ccfe6..3a569614 100644 --- a/src/plugins/external/swagger.ts +++ b/src/plugins/external/swagger.ts @@ -1,6 +1,6 @@ -import fp from "fastify-plugin"; -import fastifySwaggerUi from "@fastify/swagger-ui"; -import fastifySwagger from "@fastify/swagger"; +import fp from 'fastify-plugin' +import fastifySwaggerUi from '@fastify/swagger-ui' +import fastifySwagger from '@fastify/swagger' export default fp(async function (fastify) { /** @@ -12,12 +12,12 @@ export default fp(async function (fastify) { hideUntagged: true, openapi: { info: { - title: "Fastify demo API", - description: "The official Fastify demo API", - version: "0.0.0" + title: 'Fastify demo API', + description: 'The official Fastify demo API', + version: '0.0.0' } } - }); + }) /** * A Fastify plugin for serving Swagger UI. @@ -26,5 +26,5 @@ export default fp(async function (fastify) { */ await fastify.register(fastifySwaggerUi, { routePrefix: '/api/docs' - }); -}); + }) +}) diff --git a/src/plugins/external/under-pressure.ts b/src/plugins/external/under-pressure.ts index e9efd064..103a613e 100644 --- a/src/plugins/external/under-pressure.ts +++ b/src/plugins/external/under-pressure.ts @@ -1,6 +1,6 @@ -import { FastifyInstance } from "fastify"; -import fastifyUnderPressure from "@fastify/under-pressure"; -import fp from "fastify-plugin"; +import { FastifyInstance } from 'fastify' +import fastifyUnderPressure from '@fastify/under-pressure' +import fp from 'fastify-plugin' export const autoConfig = (fastify: FastifyInstance) => { return { @@ -8,26 +8,26 @@ export const autoConfig = (fastify: FastifyInstance) => { maxHeapUsedBytes: 100_000_000, maxRssBytes: 1_000_000_000, maxEventLoopUtilization: 0.98, - message: "The server is under pressure, retry later!", + message: 'The server is under pressure, retry later!', retryAfter: 50, healthCheck: async () => { - let connection; + let connection try { - connection = await fastify.mysql.getConnection(); - await connection.query("SELECT 1;"); - return true; + connection = await fastify.mysql.getConnection() + await connection.query('SELECT 1;') + return true /* c8 ignore start */ } catch (err) { - fastify.log.error(err, "healthCheck has failed"); - throw new Error("Database connection is not available"); + fastify.log.error(err, 'healthCheck has failed') + throw new Error('Database connection is not available') } finally { - connection?.release(); + connection?.release() } /* c8 ignore stop */ }, healthCheckInterval: 5000 - }; -}; + } +} /** * A Fastify plugin for mesuring process load and automatically @@ -39,5 +39,5 @@ export const autoConfig = (fastify: FastifyInstance) => { * @see {@link https://www.youtube.com/watch?v=VI29mUA8n9w} */ export default fp(fastifyUnderPressure, { - dependencies: ["mysql"] -}); + dependencies: ['mysql'] +}) diff --git a/src/routes/api/auth/index.ts b/src/routes/api/auth/index.ts index 174a9821..292f7191 100644 --- a/src/routes/api/auth/index.ts +++ b/src/routes/api/auth/index.ts @@ -1,12 +1,12 @@ import { FastifyPluginAsyncTypebox, Type -} from "@fastify/type-provider-typebox"; -import { CredentialsSchema, Auth } from "../../../schemas/auth.js"; +} from '@fastify/type-provider-typebox' +import { CredentialsSchema, Auth } from '../../../schemas/auth.js' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.post( - "/login", + '/login', { schema: { body: CredentialsSchema, @@ -18,11 +18,11 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { message: Type.String() }) }, - tags: ["Authentication"] + tags: ['Authentication'] } }, async function (request, reply) { - const { username, password } = request.body; + const { username, password } = request.body const user = await fastify.repository.find('users', { select: 'username, password', @@ -30,19 +30,19 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }) if (user) { - const isPasswordValid = await fastify.compare(password, user.password); + const isPasswordValid = await fastify.compare(password, user.password) if (isPasswordValid) { - const token = fastify.jwt.sign({ username: user.username }); + const token = fastify.jwt.sign({ username: user.username }) - return { token }; + return { token } } } - reply.status(401); - - return { message: "Invalid username or password." }; + reply.status(401) + + return { message: 'Invalid username or password.' } } - ); -}; + ) +} -export default plugin; +export default plugin diff --git a/src/routes/api/autohooks.ts b/src/routes/api/autohooks.ts index 3a554d48..08d70345 100644 --- a/src/routes/api/autohooks.ts +++ b/src/routes/api/autohooks.ts @@ -1,10 +1,9 @@ -import { FastifyInstance } from "fastify"; - +import { FastifyInstance } from 'fastify' export default async function (fastify: FastifyInstance) { - fastify.addHook("onRequest", async (request) => { - if (!request.url.startsWith("/api/auth/login")) { - await request.jwtVerify(); + fastify.addHook('onRequest', async (request) => { + if (!request.url.startsWith('/api/auth/login')) { + await request.jwtVerify() } - }); + }) } diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 71cc0462..d897d911 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -1,10 +1,10 @@ -import { FastifyInstance } from "fastify"; +import { FastifyInstance } from 'fastify' export default async function (fastify: FastifyInstance) { - fastify.get("/", ({ user, protocol, hostname }) => { + fastify.get('/', ({ user, protocol, hostname }) => { return { message: `Hello ${user.username}! See documentation at ${protocol}://${hostname}/documentation` - }; - }); + } + }) } diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 8db0749f..4ad70d91 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -1,36 +1,36 @@ import { FastifyPluginAsyncTypebox, Type -} from "@fastify/type-provider-typebox"; +} from '@fastify/type-provider-typebox' import { TaskSchema, Task, CreateTaskSchema, UpdateTaskSchema, TaskStatus -} from "../../../schemas/tasks.js"; -import { FastifyReply } from "fastify"; +} from '../../../schemas/tasks.js' +import { FastifyReply } from 'fastify' const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( - "/", + '/', { schema: { response: { 200: Type.Array(TaskSchema) }, - tags: ["Tasks"] + tags: ['Tasks'] } }, async function () { - const tasks = await fastify.repository.findMany("tasks"); + const tasks = await fastify.repository.findMany('tasks') - return tasks; + return tasks } - ); + ) fastify.get( - "/:id", + '/:id', { schema: { params: Type.Object({ @@ -40,23 +40,23 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 200: TaskSchema, 404: Type.Object({ message: Type.String() }) }, - tags: ["Tasks"] + tags: ['Tasks'] } }, async function (request, reply) { - const { id } = request.params; - const task = await fastify.repository.find("tasks", { where: { id } }); + const { id } = request.params + const task = await fastify.repository.find('tasks', { where: { id } }) if (!task) { - return notFound(reply); + return notFound(reply) } - return task; + return task } - ); + ) fastify.post( - "/", + '/', { schema: { body: CreateTaskSchema, @@ -65,21 +65,21 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { id: Type.Number() } }, - tags: ["Tasks"] + tags: ['Tasks'] } }, async function (request, reply) { - const id = await fastify.repository.create("tasks", { data: {...request.body, status: TaskStatus.New} }); - reply.code(201); + const id = await fastify.repository.create('tasks', { data: { ...request.body, status: TaskStatus.New } }) + reply.code(201) return { id - }; + } } - ); + ) fastify.patch( - "/:id", + '/:id', { schema: { params: Type.Object({ @@ -90,28 +90,28 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 200: TaskSchema, 404: Type.Object({ message: Type.String() }) }, - tags: ["Tasks"] + tags: ['Tasks'] } }, async function (request, reply) { - const { id } = request.params; - const affectedRows = await fastify.repository.update("tasks", { + const { id } = request.params + const affectedRows = await fastify.repository.update('tasks', { data: request.body, where: { id } - }); + }) if (affectedRows === 0) { return notFound(reply) } - const task = await fastify.repository.find("tasks", { where: { id } }); + const task = await fastify.repository.find('tasks', { where: { id } }) - return task as Task; + return task as Task } - ); + ) fastify.delete( - "/:id", + '/:id', { schema: { params: Type.Object({ @@ -121,23 +121,23 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 204: Type.Null(), 404: Type.Object({ message: Type.String() }) }, - tags: ["Tasks"] + tags: ['Tasks'] } }, async function (request, reply) { - const { id } = request.params; - const affectedRows = await fastify.repository.delete("tasks", { id }); + const { id } = request.params + const affectedRows = await fastify.repository.delete('tasks', { id }) if (affectedRows === 0) { return notFound(reply) } - reply.code(204).send(null); + reply.code(204).send(null) } - ); + ) fastify.post( - "/:id/assign", + '/:id/assign', { schema: { params: Type.Object({ @@ -150,33 +150,33 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { 200: TaskSchema, 404: Type.Object({ message: Type.String() }) }, - tags: ["Tasks"] + tags: ['Tasks'] } }, async function (request, reply) { - const { id } = request.params; - const { userId } = request.body; - - const task = await fastify.repository.find("tasks", { where: { id } }); + const { id } = request.params + const { userId } = request.body + + const task = await fastify.repository.find('tasks', { where: { id } }) if (!task) { - return notFound(reply); + return notFound(reply) } - - await fastify.repository.update("tasks", { + + await fastify.repository.update('tasks', { data: { assigned_user_id: userId }, where: { id } - }); + }) task.assigned_user_id = userId - return task; + return task } ) -}; +} -function notFound(reply: FastifyReply) { +function notFound (reply: FastifyReply) { reply.code(404) - return { message: "Task not found" } + return { message: 'Task not found' } } -export default plugin; +export default plugin diff --git a/src/schemas/auth.ts b/src/schemas/auth.ts index 83ba0b0d..4acdb0d1 100644 --- a/src/schemas/auth.ts +++ b/src/schemas/auth.ts @@ -1,8 +1,8 @@ -import { Static, Type } from "@sinclair/typebox"; +import { Static, Type } from '@sinclair/typebox' export const CredentialsSchema = Type.Object({ username: Type.String(), password: Type.String() -}); +}) export interface Auth extends Static {} diff --git a/src/schemas/tasks.ts b/src/schemas/tasks.ts index 3ab96734..3315f8f3 100644 --- a/src/schemas/tasks.ts +++ b/src/schemas/tasks.ts @@ -1,4 +1,4 @@ -import { Static, Type } from "@sinclair/typebox"; +import { Static, Type } from '@sinclair/typebox' export const TaskStatus = { New: 'new', @@ -7,9 +7,9 @@ export const TaskStatus = { Completed: 'completed', Canceled: 'canceled', Archived: 'archived' -} as const; +} as const -export type TaskStatusType = typeof TaskStatus[keyof typeof TaskStatus]; +export type TaskStatusType = typeof TaskStatus[keyof typeof TaskStatus] export const TaskSchema = Type.Object({ id: Type.Number(), @@ -17,9 +17,9 @@ export const TaskSchema = Type.Object({ author_id: Type.Number(), assigned_user_id: Type.Optional(Type.Number()), status: Type.String(), - created_at: Type.String({ format: "date-time" }), - updated_at: Type.String({ format: "date-time" }) -}); + created_at: Type.String({ format: 'date-time' }), + updated_at: Type.String({ format: 'date-time' }) +}) export interface Task extends Static {} @@ -27,10 +27,9 @@ export const CreateTaskSchema = Type.Object({ name: Type.String(), author_id: Type.Number(), assigned_user_id: Type.Optional(Type.Number()) -}); +}) export const UpdateTaskSchema = Type.Object({ name: Type.Optional(Type.String()), assigned_user_id: Type.Optional(Type.Number()) -}); - +}) diff --git a/src/server.ts b/src/server.ts index 21ed6631..0d80e015 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,73 +5,73 @@ * You can launch it with the command `npm run standalone` */ -import Fastify from "fastify"; -import fp from "fastify-plugin"; +import Fastify from 'fastify' +import fp from 'fastify-plugin' // Import library to exit fastify process, gracefully (if possible) -import closeWithGrace from "close-with-grace"; +import closeWithGrace from 'close-with-grace' // Import your application as a normal plugin. -import serviceApp from "./app.js"; +import serviceApp from './app.js' /** * Do not use NODE_ENV to determine what logger (or any env related feature) to use * @see {@link https://www.youtube.com/watch?v=HMM7GJC5E2o} */ -function getLoggerOptions() { +function getLoggerOptions () { // Only if the program is running in an interactive terminal if (process.stdout.isTTY) { return { - level: "info", + level: 'info', transport: { - target: "pino-pretty", + target: 'pino-pretty', options: { - translateTime: "HH:MM:ss Z", - ignore: "pid,hostname" + translateTime: 'HH:MM:ss Z', + ignore: 'pid,hostname' } } - }; + } } - return { level: process.env.LOG_LEVEL ?? "silent" }; + return { level: process.env.LOG_LEVEL ?? 'silent' } } const app = Fastify({ logger: getLoggerOptions(), ajv: { customOptions: { - coerceTypes: "array", // change type of data to match type keyword - removeAdditional: "all" // Remove additional body properties + coerceTypes: 'array', // change type of data to match type keyword + removeAdditional: 'all' // Remove additional body properties } } -}); +}) -async function init() { +async function init () { // Register your application as a normal plugin. // fp must be used to override default error handler - app.register(fp(serviceApp)); + app.register(fp(serviceApp)) // Delay is the number of milliseconds for the graceful close to finish closeWithGrace( { delay: process.env.FASTIFY_CLOSE_GRACE_DELAY ?? 500 }, async ({ err }) => { if (err != null) { - app.log.error(err); + app.log.error(err) } - await app.close(); + await app.close() } - ); + ) - await app.ready(); + await app.ready() try { // Start listening. - await app.listen({ port: process.env.PORT ?? 3000 }); + await app.listen({ port: process.env.PORT ?? 3000 }) } catch (err) { - app.log.error(err); - process.exit(1); + app.log.error(err) + process.exit(1) } } -init(); +init() From 3eee489db4d7ff120bd379c7259b95f32c26c812 Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 19:00:51 +0200 Subject: [PATCH 12/14] chore update dependencies --- package.json | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index ec2615aa..d7e26480 100644 --- a/package.json +++ b/package.json @@ -25,26 +25,26 @@ "author": "Michelet Jean ", "license": "MIT", "dependencies": { - "@fastify/autoload": "^5.10.0", - "@fastify/cors": "^9.0.1", - "@fastify/env": "^4.3.0", - "@fastify/helmet": "^11.1.1", - "@fastify/jwt": "^8.0.1", - "@fastify/mysql": "^4.3.0", - "@fastify/rate-limit": "^9.1.0", - "@fastify/sensible": "^5.0.0", - "@fastify/swagger": "^8.14.0", - "@fastify/swagger-ui": "^4.0.1", - "@fastify/type-provider-typebox": "^4.0.0", - "@fastify/under-pressure": "^8.3.0", - "@fastify/vite": "^6.0.7", - "@sinclair/typebox": "^0.33.7", + "@fastify/autoload": "^6.0.0", + "@fastify/cors": "^10.0.0", + "@fastify/env": "^5.0.1", + "@fastify/helmet": "^12.0.0", + "@fastify/jwt": "^9.0.0", + "@fastify/mysql": "^5.0.1", + "@fastify/rate-limit": "^10.0.1", + "@fastify/sensible": "^6.0.1", + "@fastify/swagger": "^9.0.0", + "@fastify/swagger-ui": "^5.0.1", + "@fastify/type-provider-typebox": "^5.0.0", + "@fastify/under-pressure": "^9.0.1", + "@fastify/vite": "^7.0.1", + "@sinclair/typebox": "^0.33.12", "@vitejs/plugin-react": "^4.3.1", - "concurrently": "^8.2.2", - "fastify": "^4.26.1", - "fastify-cli": "^6.1.1", - "fastify-plugin": "^4.0.0", - "postgrator": "^7.2.0", + "concurrently": "^9.0.1", + "fastify": "^5.0.0", + "fastify-cli": "^7.0.0", + "fastify-plugin": "^5.0.1", + "postgrator": "^7.3.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, From 443026e651dd5a270ef756a946c5a5b317e0e37b Mon Sep 17 00:00:00 2001 From: jean Date: Sat, 21 Sep 2024 19:22:45 +0200 Subject: [PATCH 13/14] fix: feedbacks architecture --- {src/client => client}/App.tsx | 0 {src/client => client}/index.html | 0 {src/client => client}/mount.tsx | 3 +- {src/client => client}/public/favicon.ico | Bin {src/client => client}/tsconfig.json | 1 - {src/client => client}/vite-env.d.ts | 0 package.json | 2 +- src/app.ts | 47 ++- test/app/cors.test.ts | 34 +-- test/app/error-handler.test.ts | 38 +-- test/app/not-found-handler.test.ts | 46 +-- test/app/rate-limit.test.ts | 42 +-- test/helper.ts | 51 ++-- test/plugins/repository.test.ts | 78 ++--- test/plugins/scrypt.test.ts | 36 +-- test/routes/api/api.test.ts | 50 ++-- test/routes/api/auth/auth.test.ts | 64 ++-- test/routes/api/tasks/tasks.test.ts | 345 +++++++++++----------- tsconfig.json | 1 - vite.config.js | 6 +- 20 files changed, 415 insertions(+), 429 deletions(-) rename {src/client => client}/App.tsx (100%) rename {src/client => client}/index.html (100%) rename {src/client => client}/mount.tsx (60%) rename {src/client => client}/public/favicon.ico (100%) rename {src/client => client}/tsconfig.json (95%) rename {src/client => client}/vite-env.d.ts (100%) diff --git a/src/client/App.tsx b/client/App.tsx similarity index 100% rename from src/client/App.tsx rename to client/App.tsx diff --git a/src/client/index.html b/client/index.html similarity index 100% rename from src/client/index.html rename to client/index.html diff --git a/src/client/mount.tsx b/client/mount.tsx similarity index 60% rename from src/client/mount.tsx rename to client/mount.tsx index d8f41317..fd7d2078 100644 --- a/src/client/mount.tsx +++ b/client/mount.tsx @@ -1,8 +1,7 @@ import { createRoot } from 'react-dom/client' import { App } from './App' -const rootElement = - document.getElementById('root') || document.createElement('div') +const rootElement = document.getElementById('root')! const root = createRoot(rootElement) root.render() diff --git a/src/client/public/favicon.ico b/client/public/favicon.ico similarity index 100% rename from src/client/public/favicon.ico rename to client/public/favicon.ico diff --git a/src/client/tsconfig.json b/client/tsconfig.json similarity index 95% rename from src/client/tsconfig.json rename to client/tsconfig.json index 19982062..cb394505 100644 --- a/src/client/tsconfig.json +++ b/client/tsconfig.json @@ -20,5 +20,4 @@ "noFallthroughCasesInSwitch": true }, "include": ["**/*"], - "exclude": ["dist"] } diff --git a/src/client/vite-env.d.ts b/client/vite-env.d.ts similarity index 100% rename from src/client/vite-env.d.ts rename to client/vite-env.d.ts diff --git a/package.json b/package.json index d7e26480..393b4189 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dev:start": "fastify start --ignore-watch=.ts$ -l info -P dist/app.js", "test": "npm run db:seed && tap --jobs=1 test/**/*", "standalone": "node --env-file=.env dist/server.js", - "lint": "eslint src --ignore-pattern='src/client/dist'", + "lint": "eslint test src client vite.config.js", "lint:fix": "npm run lint -- --fix", "db:migrate": "node --env-file=.env scripts/migrate.js", "db:seed": "node --env-file=.env scripts/seed-database.js" diff --git a/src/app.ts b/src/app.ts index ec504eaa..29662200 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,7 +20,7 @@ export default async function serviceApp ( fastify: FastifyInstance, opts: FastifyPluginOptions ) { - const { avoidViteRegistration = true } = opts + const { registerVite = true } = opts // This loads all external plugins defined in plugins/external // those should be registered first as your custom plugins might depend on them @@ -93,38 +93,27 @@ export default async function serviceApp ( } ) - await handleVite(fastify, { - register: !avoidViteRegistration - }) -} + if (registerVite) { + /* c8 ignore start - We don't launch the spa tests with the api tests */ + // We setup the SPA + await fastify.register(fastifyVite, function (fastify) { + return { + root: path.resolve(import.meta.dirname, '../'), + dev: fastify.config.FASTIFY_VITE_DEV_MODE, + spa: true + } + }) -async function handleVite ( - fastify: FastifyInstance, - { register }: { register: boolean } -) { - if (!register) { // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base + fastify.get('/', (req, reply) => { + return reply.html() + }) + + await fastify.vite.ready() + /* c8 ignore end */ + } else { fastify.get('/', () => { return 'Vite is not registered.' }) - - return } - /* c8 ignore start - We don't launch the spa tests with the api tests */ - // We setup the SPA - await fastify.register(fastifyVite, function (fastify) { - return { - root: path.resolve(import.meta.dirname, '../'), - dev: fastify.config.FASTIFY_VITE_DEV_MODE, - spa: true - } - }) - - // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base - fastify.get('/', (req, reply) => { - return reply.html() - }) - - await fastify.vite.ready() - /* c8 ignore end */ } diff --git a/test/app/cors.test.ts b/test/app/cors.test.ts index 9893ab81..024f7cd2 100644 --- a/test/app/cors.test.ts +++ b/test/app/cors.test.ts @@ -1,20 +1,20 @@ -import { it } from "node:test"; -import { build } from "../helper.js"; -import assert from "node:assert"; +import { it } from 'node:test' +import { build } from '../helper.js' +import assert from 'node:assert' -it("should correctly handle CORS preflight requests", async (t) => { - const app = await build(t); +it('should correctly handle CORS preflight requests', async (t) => { + const app = await build(t) - const res = await app.inject({ - method: "OPTIONS", - url: "/", - headers: { - "Origin": "http://example.com", - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": "Content-Type" - } - }); + const res = await app.inject({ + method: 'OPTIONS', + url: '/', + headers: { + Origin: 'http://example.com', + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'Content-Type' + } + }) - assert.strictEqual(res.statusCode, 204); - assert.strictEqual(res.headers['access-control-allow-methods'], 'GET, POST, PUT, DELETE'); -}); + assert.strictEqual(res.statusCode, 204) + assert.strictEqual(res.headers['access-control-allow-methods'], 'GET, POST, PUT, DELETE') +}) diff --git a/test/app/error-handler.test.ts b/test/app/error-handler.test.ts index deed749e..df3c9c95 100644 --- a/test/app/error-handler.test.ts +++ b/test/app/error-handler.test.ts @@ -1,27 +1,27 @@ -import { it } from "node:test"; -import assert from "node:assert"; -import fastify from "fastify"; -import serviceApp from "../../src/app.ts"; -import fp from "fastify-plugin"; +import { it } from 'node:test' +import assert from 'node:assert' +import fastify from 'fastify' +import serviceApp from '../../src/app.ts' +import fp from 'fastify-plugin' -it("should call errorHandler", async (t) => { - const app = fastify(); - await app.register(fp(serviceApp)); +it('should call errorHandler', async (t) => { + const app = fastify() + await app.register(fp(serviceApp)) - app.get("/error", () => { - throw new Error("Kaboom!"); - }); + app.get('/error', () => { + throw new Error('Kaboom!') + }) - await app.ready(); + await app.ready() - t.after(() => app.close()); + t.after(() => app.close()) const res = await app.inject({ - method: "GET", - url: "/error" - }); + method: 'GET', + url: '/error' + }) assert.deepStrictEqual(JSON.parse(res.payload), { - message: "Internal Server Error" - }); -}); + message: 'Internal Server Error' + }) +}) diff --git a/test/app/not-found-handler.test.ts b/test/app/not-found-handler.test.ts index 4abfa12b..497c5f5c 100644 --- a/test/app/not-found-handler.test.ts +++ b/test/app/not-found-handler.test.ts @@ -1,35 +1,35 @@ -import { it } from "node:test"; -import assert from "node:assert"; -import { build } from "../helper.js"; +import { it } from 'node:test' +import assert from 'node:assert' +import { build } from '../helper.js' -it("should call notFoundHandler", async (t) => { - const app = await build(t); +it('should call notFoundHandler', async (t) => { + const app = await build(t) const res = await app.inject({ - method: "GET", - url: "/this-route-does-not-exist" - }); + method: 'GET', + url: '/this-route-does-not-exist' + }) - assert.strictEqual(res.statusCode, 404); - assert.deepStrictEqual(JSON.parse(res.payload), { message: "Not Found" }); -}); + assert.strictEqual(res.statusCode, 404) + assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Not Found' }) +}) -it("should be rate limited", async (t) => { - const app = await build(t); +it('should be rate limited', async (t) => { + const app = await build(t) for (let i = 0; i < 3; i++) { const res = await app.inject({ - method: "GET", - url: "/this-route-does-not-exist" - }); - - assert.strictEqual(res.statusCode, 404); + method: 'GET', + url: '/this-route-does-not-exist' + }) + + assert.strictEqual(res.statusCode, 404) } const res = await app.inject({ - method: "GET", - url: "/this-route-does-not-exist" - }); + method: 'GET', + url: '/this-route-does-not-exist' + }) - assert.strictEqual(res.statusCode, 429); -}); + assert.strictEqual(res.statusCode, 429) +}) diff --git a/test/app/rate-limit.test.ts b/test/app/rate-limit.test.ts index ca7059a0..0bd8a7e9 100644 --- a/test/app/rate-limit.test.ts +++ b/test/app/rate-limit.test.ts @@ -1,24 +1,24 @@ -import { it } from "node:test"; -import { build } from "../helper.js"; -import assert from "node:assert"; +import { it } from 'node:test' +import { build } from '../helper.js' +import assert from 'node:assert' -it("should be rate limited", async (t) => { - const app = await build(t); - - for (let i = 0; i < 4; i++) { - const res = await app.inject({ - method: "GET", - url: "/" - }); +it('should be rate limited', async (t) => { + const app = await build(t) - assert.equal(res.body, "Vite is not registered.") - assert.strictEqual(res.statusCode, 200); - } - + for (let i = 0; i < 4; i++) { const res = await app.inject({ - method: "GET", - url: "/" - }); - - assert.strictEqual(res.statusCode, 429); -}); + method: 'GET', + url: '/' + }) + + assert.equal(res.body, 'Vite is not registered.') + assert.strictEqual(res.statusCode, 200) + } + + const res = await app.inject({ + method: 'GET', + url: '/' + }) + + assert.strictEqual(res.statusCode, 429) +}) diff --git a/test/helper.ts b/test/helper.ts index 074f3ecb..eb4eb9a2 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,74 +1,73 @@ +import { FastifyInstance, InjectOptions } from 'fastify' +import { build as buildApplication } from 'fastify-cli/helper.js' +import path from 'node:path' +import { TestContext } from 'node:test' +import { options as serverOptions } from '../src/app.js' -import { FastifyInstance, InjectOptions } from "fastify"; -import { build as buildApplication } from "fastify-cli/helper.js"; -import path from "node:path"; -import { TestContext } from "node:test"; -import { options as serverOptions } from "../src/app.js"; - -declare module "fastify" { +declare module 'fastify' { interface FastifyInstance { login: typeof login; injectWithLogin: typeof injectWithLogin } } -const AppPath = path.join(import.meta.dirname, "../src/app.ts"); +const AppPath = path.join(import.meta.dirname, '../src/app.ts') // Fill in this config with all the configurations // needed for testing the application -export function config() { +export function config () { return { skipOverride: true, // Register our application with fastify-plugin - avoidViteRegistration : true - }; + registerVite: false + } } const tokens: Record = {} // We will create different users with different roles -async function login(this: FastifyInstance, username: string) { +async function login (this: FastifyInstance, username: string) { if (tokens[username]) { return tokens[username] } const res = await this.inject({ - method: "POST", - url: "/api/auth/login", + method: 'POST', + url: '/api/auth/login', payload: { username, - password: "password" + password: 'password' } - }); + }) - tokens[username] = JSON.parse(res.payload).token; + tokens[username] = JSON.parse(res.payload).token return tokens[username] } -async function injectWithLogin(this: FastifyInstance, username: string, opts: InjectOptions) { +async function injectWithLogin (this: FastifyInstance, username: string, opts: InjectOptions) { opts.headers = { ...opts.headers, Authorization: `Bearer ${await this.login(username)}` - }; + } - return this.inject(opts); + return this.inject(opts) }; // automatically build and tear down our instance -export async function build(t: TestContext) { +export async function build (t: TestContext) { // you can set all the options supported by the fastify CLI command - const argv = [AppPath]; + const argv = [AppPath] // fastify-plugin ensures that all decorators // are exposed for testing purposes, this is // different from the production setup - const app = await buildApplication(argv, config(), serverOptions) as FastifyInstance; + const app = await buildApplication(argv, config(), serverOptions) as FastifyInstance - app.login = login; + app.login = login app.injectWithLogin = injectWithLogin // close the app after we are done - t.after(() => app.close()); + t.after(() => app.close()) - return app; + return app } diff --git a/test/plugins/repository.test.ts b/test/plugins/repository.test.ts index 4244f636..9d534af4 100644 --- a/test/plugins/repository.test.ts +++ b/test/plugins/repository.test.ts @@ -1,65 +1,65 @@ -import { test } from "tap"; -import assert from "node:assert"; -import { execSync } from "child_process"; -import Fastify from "fastify"; -import repository from "../../src/plugins/custom/repository.js"; -import * as envPlugin from "../../src/plugins/external/env.js"; -import * as mysqlPlugin from "../../src/plugins/external/mysql.js"; -import { Auth } from '../../src/schemas/auth.js'; +import { test } from 'tap' +import assert from 'node:assert' +import { execSync } from 'child_process' +import Fastify from 'fastify' +import repository from '../../src/plugins/custom/repository.js' +import * as envPlugin from '../../src/plugins/external/env.js' +import * as mysqlPlugin from '../../src/plugins/external/mysql.js' +import { Auth } from '../../src/schemas/auth.js' -test("repository works standalone", async (t) => { - const app = Fastify(); +test('repository works standalone', async (t) => { + const app = Fastify() t.after(() => { - app.close(); + app.close() // Run the seed script again to clean up after tests - execSync('npm run db:seed'); - }); + execSync('npm run db:seed') + }) - app.register(envPlugin.default, envPlugin.autoConfig); - app.register(mysqlPlugin.default, mysqlPlugin.autoConfig); - app.register(repository); + app.register(envPlugin.default, envPlugin.autoConfig) + app.register(mysqlPlugin.default, mysqlPlugin.autoConfig) + app.register(repository) - await app.ready(); + await app.ready() // Test find method - const user = await app.repository.find('users', { select: 'username', where: { username: 'basic' } }); - assert.deepStrictEqual(user, { username: 'basic' }); + const user = await app.repository.find('users', { select: 'username', where: { username: 'basic' } }) + assert.deepStrictEqual(user, { username: 'basic' }) - const firstUser = await app.repository.find('users', { select: 'username' }); - assert.deepStrictEqual(firstUser, { username: 'basic' }); + const firstUser = await app.repository.find('users', { select: 'username' }) + assert.deepStrictEqual(firstUser, { username: 'basic' }) - const nullUser = await app.repository.find('users', { select: 'username', where: { username: 'unknown' } }); - assert.equal(nullUser, null); + const nullUser = await app.repository.find('users', { select: 'username', where: { username: 'unknown' } }) + assert.equal(nullUser, null) // Test findMany method - const users = await app.repository.findMany('users', { select: 'username', where: { username: 'basic' } }); + const users = await app.repository.findMany('users', { select: 'username', where: { username: 'basic' } }) assert.deepStrictEqual(users, [ { username: 'basic' } - ]); + ]) // Test findMany method - const allUsers = await app.repository.findMany('users', { select: 'username' }); + const allUsers = await app.repository.findMany('users', { select: 'username' }) assert.deepStrictEqual(allUsers, [ { username: 'basic' }, { username: 'moderator' }, { username: 'admin' } - ]); + ]) // Test create method - const newUserId = await app.repository.create('users', { data: { username: 'new_user', password: 'new_password' } }); - const newUser = await app.repository.find('users', { select: 'username', where: { id: newUserId } }); - assert.deepStrictEqual(newUser, { username: 'new_user' }); + const newUserId = await app.repository.create('users', { data: { username: 'new_user', password: 'new_password' } }) + const newUser = await app.repository.find('users', { select: 'username', where: { id: newUserId } }) + assert.deepStrictEqual(newUser, { username: 'new_user' }) // Test update method - const updateCount = await app.repository.update('users', { data: { password: 'updated_password' }, where: { username: 'new_user' } }); - assert.equal(updateCount, 1); - const updatedUser = await app.repository.find('users', { select: 'password', where: { username: 'new_user' } }); - assert.deepStrictEqual(updatedUser, { password: 'updated_password' }); + const updateCount = await app.repository.update('users', { data: { password: 'updated_password' }, where: { username: 'new_user' } }) + assert.equal(updateCount, 1) + const updatedUser = await app.repository.find('users', { select: 'password', where: { username: 'new_user' } }) + assert.deepStrictEqual(updatedUser, { password: 'updated_password' }) // Test delete method - const deleteCount = await app.repository.delete('users', { username: 'new_user' }); - assert.equal(deleteCount, 1); - const deletedUser = await app.repository.find('users', { select: 'username', where: { username: 'new_user' } }); - assert.equal(deletedUser, null); -}); + const deleteCount = await app.repository.delete('users', { username: 'new_user' }) + assert.equal(deleteCount, 1) + const deletedUser = await app.repository.find('users', { select: 'username', where: { username: 'new_user' } }) + assert.equal(deletedUser, null) +}) diff --git a/test/plugins/scrypt.test.ts b/test/plugins/scrypt.test.ts index 2d2f6d62..ea513d80 100644 --- a/test/plugins/scrypt.test.ts +++ b/test/plugins/scrypt.test.ts @@ -1,28 +1,28 @@ -import { test } from "tap"; -import Fastify from "fastify"; -import scryptPlugin from "../../src/plugins/custom/scrypt.js"; +import { test } from 'tap' +import Fastify from 'fastify' +import scryptPlugin from '../../src/plugins/custom/scrypt.js' -test("scrypt works standalone", async t => { - const app = Fastify(); +test('scrypt works standalone', async t => { + const app = Fastify() - t.teardown(() => app.close()); + t.teardown(() => app.close()) - app.register(scryptPlugin); + app.register(scryptPlugin) - await app.ready(); + await app.ready() - const password = "test_password"; - const hash = await app.hash(password); - t.type(hash, 'string'); + const password = 'test_password' + const hash = await app.hash(password) + t.type(hash, 'string') - const isValid = await app.compare(password, hash); - t.ok(isValid, 'compare should return true for correct password'); + const isValid = await app.compare(password, hash) + t.ok(isValid, 'compare should return true for correct password') - const isInvalid = await app.compare("wrong_password", hash); - t.notOk(isInvalid, 'compare should return false for incorrect password'); + const isInvalid = await app.compare('wrong_password', hash) + t.notOk(isInvalid, 'compare should return false for incorrect password') await t.rejects( - () => app.compare(password, "malformed_hash"), + () => app.compare(password, 'malformed_hash'), 'compare should throw an error for malformed hash' - ); -}); + ) +}) diff --git a/test/routes/api/api.test.ts b/test/routes/api/api.test.ts index 30c2bec9..3eb758a0 100644 --- a/test/routes/api/api.test.ts +++ b/test/routes/api/api.test.ts @@ -1,40 +1,40 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { build } from "../../helper.js"; +import { test } from 'node:test' +import assert from 'node:assert' +import { build } from '../../helper.js' -test("GET /api without authorization header", async (t) => { - const app = await build(t); +test('GET /api without authorization header', async (t) => { + const app = await build(t) const res = await app.inject({ - url: "/api" - }); + url: '/api' + }) assert.equal(res.statusCode, 401) - assert.deepStrictEqual(JSON.parse(res.payload).message, "No Authorization was found in request.headers"); -}); + assert.deepStrictEqual(JSON.parse(res.payload).message, 'No Authorization was found in request.headers') +}) -test("GET /api without JWT Token", async (t) => { - const app = await build(t); +test('GET /api without JWT Token', async (t) => { + const app = await build(t) const res = await app.inject({ - method: "GET", - url: "/api", + method: 'GET', + url: '/api', headers: { - Authorization: "Bearer invalidtoken" + Authorization: 'Bearer invalidtoken' } - }); + }) assert.equal(res.statusCode, 401) - assert.deepStrictEqual(JSON.parse(res.payload).message, "Authorization token is invalid: The token is malformed."); -}); + assert.deepStrictEqual(JSON.parse(res.payload).message, 'Authorization token is invalid: The token is malformed.') +}) -test("GET /api with JWT Token", async (t) => { - const app = await build(t); +test('GET /api with JWT Token', async (t) => { + const app = await build(t) - const res = await app.injectWithLogin("basic", { - url: "/api" - }); + const res = await app.injectWithLogin('basic', { + url: '/api' + }) - assert.equal(res.statusCode, 200); - assert.ok(JSON.parse(res.payload).message.startsWith("Hello basic!")); -}); + assert.equal(res.statusCode, 200) + assert.ok(JSON.parse(res.payload).message.startsWith('Hello basic!')) +}) diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts index fbb2f725..5ae66520 100644 --- a/test/routes/api/auth/auth.test.ts +++ b/test/routes/api/auth/auth.test.ts @@ -1,62 +1,62 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { build } from "../../../helper.js"; +import { test } from 'node:test' +import assert from 'node:assert' +import { build } from '../../../helper.js' -test("POST /api/auth/login with valid credentials", async (t) => { - const app = await build(t); +test('POST /api/auth/login with valid credentials', async (t) => { + const app = await build(t) const res = await app.inject({ - method: "POST", - url: "/api/auth/login", + method: 'POST', + url: '/api/auth/login', payload: { - username: "basic", - password: "password" + username: 'basic', + password: 'password' } - }); + }) - assert.strictEqual(res.statusCode, 200); - assert.ok(JSON.parse(res.payload).token); -}); + assert.strictEqual(res.statusCode, 200) + assert.ok(JSON.parse(res.payload).token) +}) -test("POST /api/auth/login with invalid credentials", async (t) => { - const app = await build(t); +test('POST /api/auth/login with invalid credentials', async (t) => { + const app = await build(t) const testCases = [ { - username: "invalid_user", - password: "password", - description: "invalid username" + username: 'invalid_user', + password: 'password', + description: 'invalid username' }, { - username: "basic", - password: "wrong_password", - description: "invalid password" + username: 'basic', + password: 'wrong_password', + description: 'invalid password' }, { - username: "invalid_user", - password: "wrong_password", - description: "both invalid" + username: 'invalid_user', + password: 'wrong_password', + description: 'both invalid' } - ]; + ] for (const testCase of testCases) { const res = await app.inject({ - method: "POST", - url: "/api/auth/login", + method: 'POST', + url: '/api/auth/login', payload: { username: testCase.username, password: testCase.password } - }); + }) assert.strictEqual( res.statusCode, 401, `Failed for case: ${testCase.description}` - ); + ) assert.deepStrictEqual(JSON.parse(res.payload), { - message: "Invalid username or password." - }); + message: 'Invalid username or password.' + }) } -}); +}) diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index a527ae64..d4d9987e 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,247 +1,244 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { build } from "../../../helper.js"; -import { Task, TaskStatus } from "../../../../src/schemas/tasks.js"; -import { FastifyInstance } from "fastify"; - - - - -async function createTask(app: FastifyInstance, taskData: Partial) { - return await app.repository.create("tasks", { data: taskData }); +import { describe, it } from 'node:test' +import assert from 'node:assert' +import { build } from '../../../helper.js' +import { Task, TaskStatus } from '../../../../src/schemas/tasks.js' +import { FastifyInstance } from 'fastify' + +async function createTask (app: FastifyInstance, taskData: Partial) { + return await app.repository.create('tasks', { data: taskData }) } describe('Tasks api (logged user only)', () => { describe('GET /api/tasks', () => { - it("should return a list of tasks", async (t) => { - const app = await build(t); - + it('should return a list of tasks', async (t) => { + const app = await build(t) + const taskData = { - name: "New Task", + name: 'New Task', author_id: 1, status: TaskStatus.New - }; - - const newTaskId = await app.repository.create("tasks", { data: taskData }); - - const res = await app.injectWithLogin("basic", { - method: "GET", - url: "/api/tasks" - }); - - assert.strictEqual(res.statusCode, 200); - const tasks = JSON.parse(res.payload) as Task[]; - const createdTask = tasks.find((task) => task.id === newTaskId); - assert.ok(createdTask, "Created task should be in the response"); - - assert.deepStrictEqual(taskData.name, createdTask.name); - assert.strictEqual(taskData.author_id, createdTask.author_id); - assert.strictEqual(taskData.status, createdTask.status); - }); + } + + const newTaskId = await app.repository.create('tasks', { data: taskData }) + + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks' + }) + + assert.strictEqual(res.statusCode, 200) + const tasks = JSON.parse(res.payload) as Task[] + const createdTask = tasks.find((task) => task.id === newTaskId) + assert.ok(createdTask, 'Created task should be in the response') + + assert.deepStrictEqual(taskData.name, createdTask.name) + assert.strictEqual(taskData.author_id, createdTask.author_id) + assert.strictEqual(taskData.status, createdTask.status) + }) }) describe('GET /api/tasks/:id', () => { - it("should return a task", async (t) => { - const app = await build(t); - + it('should return a task', async (t) => { + const app = await build(t) + const taskData = { - name: "Single Task", + name: 'Single Task', author_id: 1, status: TaskStatus.New - }; - - const newTaskId = await createTask(app, taskData); - - const res = await app.injectWithLogin("basic", { - method: "GET", + } + + const newTaskId = await createTask(app, taskData) + + const res = await app.injectWithLogin('basic', { + method: 'GET', url: `/api/tasks/${newTaskId}` - }); - - assert.strictEqual(res.statusCode, 200); - const task = JSON.parse(res.payload) as Task; - assert.equal(task.id, newTaskId); - }); - - it("should return 404 if task is not found", async (t) => { - const app = await build(t); - - const res = await app.injectWithLogin("basic", { - method: "GET", - url: "/api/tasks/9999" - }); - - assert.strictEqual(res.statusCode, 404); - const payload = JSON.parse(res.payload); - assert.strictEqual(payload.message, "Task not found"); - }); - }); + }) + + assert.strictEqual(res.statusCode, 200) + const task = JSON.parse(res.payload) as Task + assert.equal(task.id, newTaskId) + }) + + it('should return 404 if task is not found', async (t) => { + const app = await build(t) + + const res = await app.injectWithLogin('basic', { + method: 'GET', + url: '/api/tasks/9999' + }) + + assert.strictEqual(res.statusCode, 404) + const payload = JSON.parse(res.payload) + assert.strictEqual(payload.message, 'Task not found') + }) + }) describe('POST /api/tasks', () => { - it("should create a new task", async (t) => { - const app = await build(t); + it('should create a new task', async (t) => { + const app = await build(t) const taskData = { - name: "New Task", + name: 'New Task', author_id: 1 - }; + } - const res = await app.injectWithLogin("basic", { - method: "POST", - url: "/api/tasks", + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: '/api/tasks', payload: taskData - }); + }) - assert.strictEqual(res.statusCode, 201); - const { id } = JSON.parse(res.payload); + assert.strictEqual(res.statusCode, 201) + const { id } = JSON.parse(res.payload) - const createdTask = await app.repository.find("tasks", { select: 'name', where: { id } }) as Task; - assert.equal(createdTask.name, taskData.name); - }); - }); + const createdTask = await app.repository.find('tasks', { select: 'name', where: { id } }) as Task + assert.equal(createdTask.name, taskData.name) + }) + }) describe('PATCH /api/tasks/:id', () => { - it("should update an existing task", async (t) => { - const app = await build(t); + it('should update an existing task', async (t) => { + const app = await build(t) const taskData = { - name: "Task to Update", + name: 'Task to Update', author_id: 1, status: TaskStatus.New - }; - const newTaskId = await createTask(app, taskData); + } + const newTaskId = await createTask(app, taskData) const updatedData = { - name: "Updated Task" - }; + name: 'Updated Task' + } - const res = await app.injectWithLogin("basic", { - method: "PATCH", + const res = await app.injectWithLogin('basic', { + method: 'PATCH', url: `/api/tasks/${newTaskId}`, payload: updatedData - }); + }) - assert.strictEqual(res.statusCode, 200); - const updatedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; - assert.equal(updatedTask.name, updatedData.name); - }); + assert.strictEqual(res.statusCode, 200) + const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task + assert.equal(updatedTask.name, updatedData.name) + }) - it("should return 404 if task is not found for update", async (t) => { - const app = await build(t); + it('should return 404 if task is not found for update', async (t) => { + const app = await build(t) const updatedData = { - name: "Updated Task" - }; + name: 'Updated Task' + } - const res = await app.injectWithLogin("basic", { - method: "PATCH", - url: "/api/tasks/9999", + const res = await app.injectWithLogin('basic', { + method: 'PATCH', + url: '/api/tasks/9999', payload: updatedData - }); + }) - assert.strictEqual(res.statusCode, 404); - const payload = JSON.parse(res.payload); - assert.strictEqual(payload.message, "Task not found"); - }); - }); + assert.strictEqual(res.statusCode, 404) + const payload = JSON.parse(res.payload) + assert.strictEqual(payload.message, 'Task not found') + }) + }) describe('DELETE /api/tasks/:id', () => { - it("should delete an existing task", async (t) => { - const app = await build(t); + it('should delete an existing task', async (t) => { + const app = await build(t) const taskData = { - name: "Task to Delete", + name: 'Task to Delete', author_id: 1, status: TaskStatus.New - }; - const newTaskId = await createTask(app, taskData); + } + const newTaskId = await createTask(app, taskData) - const res = await app.injectWithLogin("basic", { - method: "DELETE", + const res = await app.injectWithLogin('basic', { + method: 'DELETE', url: `/api/tasks/${newTaskId}` - }); + }) - assert.strictEqual(res.statusCode, 204); + assert.strictEqual(res.statusCode, 204) - const deletedTask = await app.repository.find("tasks", { where: { id: newTaskId } }); - assert.strictEqual(deletedTask, null); - }); + const deletedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) + assert.strictEqual(deletedTask, null) + }) - it("should return 404 if task is not found for deletion", async (t) => { - const app = await build(t); + it('should return 404 if task is not found for deletion', async (t) => { + const app = await build(t) - const res = await app.injectWithLogin("basic", { - method: "DELETE", - url: "/api/tasks/9999" - }); + const res = await app.injectWithLogin('basic', { + method: 'DELETE', + url: '/api/tasks/9999' + }) - assert.strictEqual(res.statusCode, 404); - const payload = JSON.parse(res.payload); - assert.strictEqual(payload.message, "Task not found"); - }); - }); + assert.strictEqual(res.statusCode, 404) + const payload = JSON.parse(res.payload) + assert.strictEqual(payload.message, 'Task not found') + }) + }) describe('POST /api/tasks/:id/assign', () => { - it("should assign a task to a user and persist the changes", async (t) => { - const app = await build(t); - + it('should assign a task to a user and persist the changes', async (t) => { + const app = await build(t) + const taskData = { - name: "Task to Assign", + name: 'Task to Assign', author_id: 1, status: TaskStatus.New - }; - const newTaskId = await createTask(app, taskData); - - const res = await app.injectWithLogin("basic", { - method: "POST", + } + const newTaskId = await createTask(app, taskData) + + const res = await app.injectWithLogin('basic', { + method: 'POST', url: `/api/tasks/${newTaskId}/assign`, payload: { userId: 2 } - }); - - assert.strictEqual(res.statusCode, 200); - - const updatedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task - assert.strictEqual(updatedTask.assigned_user_id, 2); - }); - - it("should unassign a task from a user and persist the changes", async (t) => { - const app = await build(t); - + }) + + assert.strictEqual(res.statusCode, 200) + + const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task + assert.strictEqual(updatedTask.assigned_user_id, 2) + }) + + it('should unassign a task from a user and persist the changes', async (t) => { + const app = await build(t) + const taskData = { - name: "Task to Unassign", + name: 'Task to Unassign', author_id: 1, assigned_user_id: 2, status: TaskStatus.New - }; - const newTaskId = await createTask(app, taskData); - - const res = await app.injectWithLogin("basic", { - method: "POST", + } + const newTaskId = await createTask(app, taskData) + + const res = await app.injectWithLogin('basic', { + method: 'POST', url: `/api/tasks/${newTaskId}/assign`, payload: {} - }); - - assert.strictEqual(res.statusCode, 200); - - const updatedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; - assert.strictEqual(updatedTask.assigned_user_id, null); - }); - - it("should return 404 if task is not found", async (t) => { - const app = await build(t); - - const res = await app.injectWithLogin("basic", { - method: "POST", - url: "/api/tasks/9999/assign", + }) + + assert.strictEqual(res.statusCode, 200) + + const updatedTask = await app.repository.find('tasks', { where: { id: newTaskId } }) as Task + assert.strictEqual(updatedTask.assigned_user_id, null) + }) + + it('should return 404 if task is not found', async (t) => { + const app = await build(t) + + const res = await app.injectWithLogin('basic', { + method: 'POST', + url: '/api/tasks/9999/assign', payload: { userId: 2 } - }); - - assert.strictEqual(res.statusCode, 404); - const payload = JSON.parse(res.payload); - assert.strictEqual(payload.message, "Task not found"); - }); - }); + }) + + assert.strictEqual(res.statusCode, 404) + const payload = JSON.parse(res.payload) + assert.strictEqual(payload.message, 'Task not found') + }) + }) }) diff --git a/tsconfig.json b/tsconfig.json index d3f3bfbe..9136c517 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,4 @@ "rootDir": "src" }, "include": ["@types", "src/**/*.ts"], - "exclude": ["src/client/**/*"] } diff --git a/vite.config.js b/vite.config.js index dbe912c2..eac35e7b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,10 @@ import viteReact from '@vitejs/plugin-react' /** @type {import('vite').UserConfig} */ export default { base: '/', - root: resolve(import.meta.dirname, 'src/client'), + root: resolve(import.meta.dirname, 'client'), + build: { + emptyOutDir: true, + outDir: resolve(import.meta.dirname, 'dist/client') + }, plugins: [viteReact()] } From f4721604bda367367f545f99d0fd2891998fcf23 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 22 Sep 2024 10:32:36 +0200 Subject: [PATCH 14/14] test: fix coverage --- src/app.ts | 9 +++++---- test/tsconfig.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app.ts b/src/app.ts index 07eed0f0..23805d59 100644 --- a/src/app.ts +++ b/src/app.ts @@ -60,11 +60,12 @@ export default async function serviceApp ( 'Unhandled error occurred' ) - reply.code(err.statusCode ?? 500) - let message = 'Internal Server Error' - if (err.statusCode === 401) { - message = err.message + const statusCode = err.statusCode ?? 500 + reply.code(statusCode) + + if (statusCode === 429) { + message = 'Rate limit exceeded' } return { message } diff --git a/test/tsconfig.json b/test/tsconfig.json index 66246dfe..7392ed0a 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.json", "compilerOptions": { "baseUrl": ".", - "noEmit": false + "noEmit": false, }, "include": ["@types", "../src/**/*.ts", "**/*.ts"] }