diff --git a/apps/template/package.json b/apps/template/package.json index 95447aa6..7b8e5555 100644 --- a/apps/template/package.json +++ b/apps/template/package.json @@ -1,6 +1,6 @@ { "name": "@stanlemon/app-template", - "version": "0.3.80", + "version": "0.3.82", "description": "A template for creating apps using the webdev package.", "author": "Stan Lemon ", "license": "MIT", @@ -27,7 +27,7 @@ "@stanlemon/server-with-auth": "*", "@stanlemon/webdev": "*", "react": "^19.0.0", - "react-cookie": "^7.2.2", + "react-cookie": "^8.0.1", "react-dom": "^19.0.0", "wouter": "^3.6.0" }, diff --git a/apps/template/src/App.test.tsx b/apps/template/src/App.test.tsx index 9d62b533..48df849b 100644 --- a/apps/template/src/App.test.tsx +++ b/apps/template/src/App.test.tsx @@ -4,6 +4,7 @@ import App from "./App"; import { ItemData } from "./views"; import { SessionAware } from "./Session"; import { fetchApi } from "./helpers/fetchApi"; +import { CookiesProvider } from "react-cookie"; jest.mock("./helpers/fetchApi"); @@ -14,9 +15,11 @@ describe("", () => { it("logged out", async () => { render( - - - + + + + + ); expect( @@ -37,17 +40,19 @@ describe("", () => { mockedFetchApi.mockResolvedValue([]); render( - - - + + + + + ); // The header is present diff --git a/apps/template/src/index.tsx b/apps/template/src/index.tsx index c80a98d4..14c1932c 100644 --- a/apps/template/src/index.tsx +++ b/apps/template/src/index.tsx @@ -1,6 +1,7 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import Session from "./Session"; +import { CookiesProvider } from "react-cookie"; document.title = "App"; @@ -8,9 +9,11 @@ const root = createRoot( document.body.appendChild(document.createElement("div")) ); root.render( - - - + + + + + ); const link = document.createElement("link"); diff --git a/package-lock.json b/package-lock.json index eb0e7a7d..6189e7f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "javascript", "workspaces": [ "packages/*", "apps/*" @@ -65,13 +66,13 @@ }, "apps/template": { "name": "@stanlemon/app-template", - "version": "0.3.80", + "version": "0.3.81", "license": "MIT", "dependencies": { "@stanlemon/server-with-auth": "*", "@stanlemon/webdev": "*", "react": "^19.0.0", - "react-cookie": "^7.2.2", + "react-cookie": "^8.0.1", "react-dom": "^19.0.0", "wouter": "^3.6.0" }, @@ -1829,9 +1830,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -13731,14 +13732,14 @@ } }, "node_modules/react-cookie": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.2.2.tgz", - "integrity": "sha512-e+hi6axHcw9VODoeVu8WyMWyoosa1pzpyjfvrLdF7CexfU+WSGZdDuRfHa4RJgTpfv3ZjdIpHE14HpYBieHFhg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz", + "integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==", "license": "MIT", "dependencies": { - "@types/hoist-non-react-statics": "^3.3.5", + "@types/hoist-non-react-statics": "^3.3.6", "hoist-non-react-statics": "^3.3.2", - "universal-cookie": "^7.0.0" + "universal-cookie": "^8.0.0" }, "peerDependencies": { "react": ">= 16.3.0" @@ -15295,9 +15296,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", "dev": true, "license": "MIT", "dependencies": { @@ -15998,13 +15999,21 @@ } }, "node_modules/universal-cookie": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.2.tgz", - "integrity": "sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz", + "integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^0.7.2" + "cookie": "^1.0.2" + } + }, + "node_modules/universal-cookie/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/universalify": { @@ -16982,6 +16991,20 @@ "node": ">=0.10.0" } }, + "packages/cli/node_modules/react-cookie": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.2.2.tgz", + "integrity": "sha512-e+hi6axHcw9VODoeVu8WyMWyoosa1pzpyjfvrLdF7CexfU+WSGZdDuRfHa4RJgTpfv3ZjdIpHE14HpYBieHFhg==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.5", + "hoist-non-react-statics": "^3.3.2", + "universal-cookie": "^7.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "packages/cli/node_modules/react-dom": { "version": "18.3.1", "license": "MIT", @@ -17000,6 +17023,16 @@ "loose-envify": "^1.1.0" } }, + "packages/cli/node_modules/universal-cookie": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.2.tgz", + "integrity": "sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^0.7.2" + } + }, "packages/eslint-config": { "name": "@stanlemon/eslint-config", "version": "3.0.27", diff --git a/packages/server/package.json b/packages/server/package.json index 4a7b6634..da5a7b6c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@stanlemon/server", - "version": "0.3.45", + "version": "0.3.46", "description": "A basic express web server setup.", "author": "Stan Lemon ", "license": "MIT", diff --git a/packages/server/src/asyncJsonHandler.test.js b/packages/server/src/asyncJsonHandler.test.js new file mode 100644 index 00000000..43c52c31 --- /dev/null +++ b/packages/server/src/asyncJsonHandler.test.js @@ -0,0 +1,97 @@ +import asyncJsonHandler from "./asyncJsonHandler"; + +describe("asyncHandler()", () => { + it("handles fn response", async () => { + const body = { + hello: "World", + }; + const req = jest.fn(); + const res = jest.fn(); + res.status = jest.fn().mockReturnValue({ json: jest.fn() }); + const next = jest.fn(); + + const controller = async (req, res, next) => { + return Promise.resolve(body); + }; + + await asyncJsonHandler(controller)(req, res, next); + + expect(res.status.mock.calls[0][0]).toBe(200); + expect(res.status().json.mock.calls[0][0]).toEqual(body); + }); + + it("handles fn 400", async () => { + const req = jest.fn(); + const res = jest.fn(); + res.status = jest.fn().mockReturnValue({ json: jest.fn() }); + const next = jest.fn(); + + const controller = async (req, res, next) => { + throw new Error("Bad Request"); + }; + + await asyncJsonHandler(controller)(req, res, next); + + expect(res.status.mock.calls[0][0]).toBe(400); + }); + + it("handles fn 403", async () => { + const req = jest.fn(); + const res = jest.fn(); + res.status = jest.fn().mockReturnValue({ json: jest.fn() }); + const next = jest.fn(); + + const controller = async (req, res, next) => { + throw new Error("Not Authorized"); + }; + + await asyncJsonHandler(controller)(req, res, next); + + expect(res.status.mock.calls[0][0]).toBe(403); + }); + + it("handles fn 404", async () => { + const req = jest.fn(); + const res = jest.fn(); + res.status = jest.fn().mockReturnValue({ json: jest.fn() }); + const next = jest.fn(); + + const controller = async (req, res, next) => { + throw new Error("Not Found"); + }; + + await asyncJsonHandler(controller)(req, res, next); + + expect(res.status.mock.calls[0][0]).toBe(404); + }); + + it("handles fn 409", async () => { + const req = jest.fn(); + const res = jest.fn(); + res.status = jest.fn().mockReturnValue({ json: jest.fn() }); + const next = jest.fn(); + + const controller = async (req, res, next) => { + throw new Error("Already Exists"); + }; + + await asyncJsonHandler(controller)(req, res, next); + + expect(res.status.mock.calls[0][0]).toBe(409); + }); + + it("handles fn 500", async () => { + const req = jest.fn(); + const res = jest.fn(); + res.status = jest.fn().mockReturnValue({ json: jest.fn() }); + const next = jest.fn(); + + const controller = async (req, res, next) => { + throw new Error("Who knows!"); + }; + + await asyncJsonHandler(controller)(req, res, next); + + expect(res.status.mock.calls[0][0]).toBe(500); + }); +}); diff --git a/packages/server/src/schemaHandler.js b/packages/server/src/schemaHandler.js index 30a095d6..bad0d3f7 100644 --- a/packages/server/src/schemaHandler.js +++ b/packages/server/src/schemaHandler.js @@ -1,6 +1,8 @@ -import { ValidationError } from "joi"; +import Joi from "joi"; import { asyncJsonHandler } from "./asyncJsonHandler.js"; +const { ValidationError } = Joi; + /** * * @param {Joi.Schema} schema diff --git a/packages/server/src/schemaHandler.test.js b/packages/server/src/schemaHandler.test.js new file mode 100644 index 00000000..53ac9d7a --- /dev/null +++ b/packages/server/src/schemaHandler.test.js @@ -0,0 +1,59 @@ +import Joi from "joi"; +import schemaHandler from "./schemaHandler"; + +describe("schemaHandler()", () => { + it("validates schema against invalid response", async () => { + const req = jest.fn(); + req.body = { + foo: "bar", + email: "stan", + }; + const res = jest.fn(); + res.status = jest.fn().mockReturnValue({ json: jest.fn() }); + const next = jest.fn(); + + const person = Joi.object({ + fullName: Joi.string().required().label("Full Name").required(), + email: Joi.string().email().required().label("Email").required(), + }); + + const controller = jest.fn(); + + await schemaHandler(person, controller)(req, res, next); + + expect(res.status.mock.calls[0][0]).toBe(400); + expect(res.status().json.mock.calls[0][0]).toEqual({ + errors: { + fullName: '"Full Name" is required', + email: '"Email" must be a valid email address', + }, + }); + expect(controller).not.toHaveBeenCalled(); + }); + + it("validates schema against valid response", async () => { + const body = { + fullName: "Stan Lemon", + email: "stanlemon@users.noreply.github.com", + }; + const req = jest.fn(); + req.body = body; + const res = jest.fn(); + res.status = jest.fn().mockReturnValue({ json: jest.fn() }); + const next = jest.fn(); + + const person = Joi.object({ + fullName: Joi.string().required().label("Full Name").required(), + email: Joi.string().email().required().label("Email").required(), + }); + + const controller = async (req, res, next) => { + return Promise.resolve(body); + }; + + await schemaHandler(person, controller)(req, res, next); + + expect(res.status.mock.calls[0][0]).toBe(200); + expect(res.status().json.mock.calls[0][0]).toEqual(body); + }); +});