From 76356762893aec111f45a1926aaeac27eec0ac16 Mon Sep 17 00:00:00 2001
From: Chamika J <75464293+chamikaJ@users.noreply.github.com>
Date: Thu, 31 Jul 2025 12:56:28 +0530
Subject: [PATCH] feat(trial-user-limits): implement trial member limit checks
in project and team controllers
- Added TRIAL_MEMBER_LIMIT constant to enforce a maximum number of trial users in project and team member controllers.
- Implemented logic to check current trial members against the limit during user addition, providing appropriate responses for exceeding limits.
- Updated relevant controllers to utilize the new trial member limit functionality, enhancing subscription management for trial users.
- Enhanced error messaging to guide users on upgrading their subscription for additional members.
---
.../controllers/project-members-controller.ts | 13 +-
.../controllers/team-members-controller.ts | 25 +-
worklenz-backend/src/shared/constants.ts | 3 +
worklenz-frontend/package-lock.json | 558 +++++++++++++-----
worklenz-frontend/package.json | 9 +-
.../__tests__/AuthenticatingPage.test.tsx | 228 +++++++
.../__tests__/ForgotPasswordPage.test.tsx | 286 +++++++++
.../auth/__tests__/LoggingOutPage.test.tsx | 241 ++++++++
.../pages/auth/__tests__/LoginPage.test.tsx | 317 ++++++++++
.../pages/auth/__tests__/SignupPage.test.tsx | 359 +++++++++++
.../__tests__/VerifyResetEmailPage.test.tsx | 337 +++++++++++
.../language-and-region-settings.test.ts | 0
worklenz-frontend/src/test/setup.ts | 83 +++
worklenz-frontend/vitest.config.ts | 38 ++
14 files changed, 2334 insertions(+), 163 deletions(-)
create mode 100644 worklenz-frontend/src/pages/auth/__tests__/AuthenticatingPage.test.tsx
create mode 100644 worklenz-frontend/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx
create mode 100644 worklenz-frontend/src/pages/auth/__tests__/LoggingOutPage.test.tsx
create mode 100644 worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
create mode 100644 worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
create mode 100644 worklenz-frontend/src/pages/auth/__tests__/VerifyResetEmailPage.test.tsx
delete mode 100644 worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.test.ts
create mode 100644 worklenz-frontend/src/test/setup.ts
create mode 100644 worklenz-frontend/vitest.config.ts
diff --git a/worklenz-backend/src/controllers/project-members-controller.ts b/worklenz-backend/src/controllers/project-members-controller.ts
index 4f20d124..ccbeb488 100644
--- a/worklenz-backend/src/controllers/project-members-controller.ts
+++ b/worklenz-backend/src/controllers/project-members-controller.ts
@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
import TeamMembersController from "./team-members-controller";
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
import {updateUsers} from "../shared/paddle-requests";
-import {statusExclude} from "../shared/constants";
+import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
import {NotificationsService} from "../services/notifications/notifications.service";
export default class ProjectMembersController extends WorklenzControllerBase {
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
}
+ /**
+ * Checks trial user team member limit
+ */
+ if (subscriptionData.subscription_status === "trialing") {
+ const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
+
+ if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
+ return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
+ }
+ }
+
// if (subscriptionData.status === "trialing") break;
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
// if (subscriptionData.subscription_status === "active") {
diff --git a/worklenz-backend/src/controllers/team-members-controller.ts b/worklenz-backend/src/controllers/team-members-controller.ts
index 33bde00d..a338cc73 100644
--- a/worklenz-backend/src/controllers/team-members-controller.ts
+++ b/worklenz-backend/src/controllers/team-members-controller.ts
@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, getColor } from "../shared/utils";
-import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
+import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
import { updateUsers } from "../shared/paddle-requests";
import { NotificationsService } from "../services/notifications/notifications.service";
@@ -141,6 +141,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
}
+ /**
+ * Checks trial user team member limit
+ */
+ if (subscriptionData.subscription_status === "trialing") {
+ const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
+
+ if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
+ return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
+ }
+ }
+
/**
* Checks subscription details and updates the user count if applicable.
* Sends a response if there is an issue with the subscription.
@@ -1081,6 +1092,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
+ /**
+ * Checks trial user team member limit
+ */
+ if (subscriptionData.subscription_status === "trialing") {
+ const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
+ const emailsToAdd = req.body.emails?.length || 1;
+
+ if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
+ return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
+ }
+ }
+
// if (subscriptionData.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active") {
diff --git a/worklenz-backend/src/shared/constants.ts b/worklenz-backend/src/shared/constants.ts
index ffda9e67..badc5343 100644
--- a/worklenz-backend/src/shared/constants.ts
+++ b/worklenz-backend/src/shared/constants.ts
@@ -160,6 +160,9 @@ export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercas
// paddle status to exclude
export const statusExclude = ["past_due", "paused", "deleted"];
+// Trial user team member limit
+export const TRIAL_MEMBER_LIMIT = 10;
+
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
export const UNMAPPED = "Unmapped";
diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json
index 721124f0..bf437005 100644
--- a/worklenz-frontend/package-lock.json
+++ b/worklenz-frontend/package-lock.json
@@ -73,7 +73,10 @@
"@types/react-dom": "19.0.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
+ "@vitest/coverage-v8": "^3.2.4",
+ "@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
+ "jsdom": "^26.1.0",
"postcss": "^8.5.2",
"prettier-plugin-tailwindcss": "^0.6.13",
"rollup": "^4.40.2",
@@ -729,8 +732,6 @@
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
@@ -744,9 +745,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
- "license": "ISC",
- "optional": true,
- "peer": true
+ "license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
@@ -1027,6 +1026,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@chenshuai2144/sketch-color": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@chenshuai2144/sketch-color/-/sketch-color-1.0.9.tgz",
@@ -1056,8 +1065,6 @@
}
],
"license": "MIT-0",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -1078,8 +1085,6 @@
}
],
"license": "MIT",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -1104,8 +1109,6 @@
}
],
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"@csstools/color-helpers": "^5.0.2",
"@csstools/css-calc": "^2.1.4"
@@ -1134,8 +1137,6 @@
}
],
"license": "MIT",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -1159,8 +1160,6 @@
}
],
"license": "MIT",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -1836,6 +1835,16 @@
"node": ">=12"
}
},
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@@ -1952,6 +1961,13 @@
"node": ">=14"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rc-component/async-validator": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz",
@@ -2697,6 +2713,16 @@
"@babel/types": "^7.20.7"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
+ "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*"
+ }
+ },
"node_modules/@types/chart.js": {
"version": "2.9.41",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz",
@@ -2713,6 +2739,13 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
@@ -2865,15 +2898,50 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
}
},
- "node_modules/@vitest/expect": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz",
- "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==",
+ "node_modules/@vitest/coverage-v8": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+ "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "3.1.4",
- "@vitest/utils": "3.1.4",
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^1.0.2",
+ "ast-v8-to-istanbul": "^0.3.3",
+ "debug": "^4.4.1",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.17",
+ "magicast": "^0.3.5",
+ "std-env": "^3.9.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "3.2.4",
+ "vitest": "3.2.4"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@@ -2882,13 +2950,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz",
- "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "3.1.4",
+ "@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@@ -2897,7 +2965,7 @@
},
"peerDependencies": {
"msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0"
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
@@ -2909,9 +2977,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz",
- "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2922,27 +2990,28 @@
}
},
"node_modules/@vitest/runner": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz",
- "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "3.1.4",
- "pathe": "^2.0.3"
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz",
- "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "3.1.4",
+ "@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@@ -2951,27 +3020,49 @@
}
},
"node_modules/@vitest/spy": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz",
- "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyspy": "^3.0.2"
+ "tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@vitest/utils": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz",
- "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==",
+ "node_modules/@vitest/ui": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz",
+ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "3.1.4",
- "loupe": "^3.1.3",
+ "@vitest/utils": "3.2.4",
+ "fflate": "^0.8.2",
+ "flatted": "^3.3.3",
+ "pathe": "^2.0.3",
+ "sirv": "^3.0.1",
+ "tinyglobby": "^0.2.14",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "3.2.4"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
@@ -3012,8 +3103,6 @@
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"engines": {
"node": ">= 14"
}
@@ -3172,6 +3261,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz",
+ "integrity": "sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^9.0.1"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async-validator": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-1.11.5.tgz",
@@ -3466,9 +3574,9 @@
}
},
"node_modules/chai": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
- "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz",
+ "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3479,7 +3587,7 @@
"pathval": "^2.0.0"
},
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/chalk": {
@@ -3755,8 +3863,6 @@
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
@@ -3777,8 +3883,6 @@
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
@@ -3793,8 +3897,6 @@
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"punycode": "^2.3.1"
},
@@ -3808,8 +3910,6 @@
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -3820,8 +3920,6 @@
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
@@ -3868,9 +3966,7 @@
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
@@ -4045,8 +4141,6 @@
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=0.12"
},
@@ -4259,6 +4353,13 @@
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
},
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
@@ -4530,8 +4631,6 @@
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
@@ -4539,6 +4638,13 @@
"node": ">=18"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -4567,8 +4673,6 @@
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
@@ -4583,8 +4687,6 @@
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
@@ -4655,8 +4757,6 @@
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -4777,9 +4877,7 @@
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
@@ -4787,6 +4885,60 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
+ "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -4868,8 +5020,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -4910,8 +5060,6 @@
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"punycode": "^2.3.1"
},
@@ -4925,8 +5073,6 @@
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -4937,8 +5083,6 @@
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
@@ -4953,8 +5097,6 @@
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -5335,9 +5477,9 @@
}
},
"node_modules/loupe": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
- "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz",
+ "integrity": "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==",
"dev": true,
"license": "MIT"
},
@@ -5372,6 +5514,47 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
+ "node_modules/magicast": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
+ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.4",
+ "@babel/types": "^7.25.4",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/matchmediaquery": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz",
@@ -5507,6 +5690,16 @@
"node": "*"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5593,9 +5786,7 @@
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz",
"integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
@@ -5657,8 +5848,6 @@
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"entities": "^6.0.0"
},
@@ -5729,9 +5918,9 @@
"license": "MIT"
},
"node_modules/pathval": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
- "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6124,8 +6313,6 @@
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=6"
}
@@ -7271,9 +7458,7 @@
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/rrweb-snapshot": {
"version": "2.0.0-alpha.18",
@@ -7321,9 +7506,7 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
@@ -7331,8 +7514,6 @@
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
- "optional": true,
- "peer": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
@@ -7420,6 +7601,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/sirv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
+ "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@@ -7654,6 +7850,26 @@
"node": ">=8"
}
},
+ "node_modules/strip-literal": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz",
+ "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -7735,9 +7951,7 @@
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.17",
@@ -7811,6 +8025,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/test-exclude": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
+ "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^9.0.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@@ -7922,9 +8151,9 @@
"license": "GPL-2.0-or-later"
},
"node_modules/tinypool": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
- "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7942,9 +8171,9 @@
}
},
"node_modules/tinyspy": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
- "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz",
+ "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7957,8 +8186,6 @@
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"tldts-core": "^6.1.86"
},
@@ -7971,9 +8198,7 @@
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
@@ -7993,14 +8218,22 @@
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
- "optional": true,
- "peer": true,
"dependencies": {
"tldts": "^6.1.32"
},
@@ -8208,17 +8441,17 @@
}
},
"node_modules/vite-node": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz",
- "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
- "debug": "^4.4.0",
+ "debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
- "vite": "^5.0.0 || ^6.0.0"
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
@@ -8279,32 +8512,34 @@
}
},
"node_modules/vitest": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz",
- "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "3.1.4",
- "@vitest/mocker": "3.1.4",
- "@vitest/pretty-format": "^3.1.4",
- "@vitest/runner": "3.1.4",
- "@vitest/snapshot": "3.1.4",
- "@vitest/spy": "3.1.4",
- "@vitest/utils": "3.1.4",
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
"chai": "^5.2.0",
- "debug": "^4.4.0",
+ "debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
- "tinyglobby": "^0.2.13",
- "tinypool": "^1.0.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
- "vite": "^5.0.0 || ^6.0.0",
- "vite-node": "3.1.4",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -8320,8 +8555,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "@vitest/browser": "3.1.4",
- "@vitest/ui": "3.1.4",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
@@ -8349,6 +8584,19 @@
}
}
},
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -8364,8 +8612,6 @@
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"xml-name-validator": "^5.0.0"
},
@@ -8400,8 +8646,6 @@
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"dependencies": {
"iconv-lite": "0.6.3"
},
@@ -8415,8 +8659,6 @@
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -8573,8 +8815,6 @@
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
- "optional": true,
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -8584,9 +8824,7 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
+ "license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json
index 7e25181c..bc90b52a 100644
--- a/worklenz-frontend/package.json
+++ b/worklenz-frontend/package.json
@@ -9,7 +9,11 @@
"build": "vite build",
"dev-build": "vite build",
"serve": "vite preview",
- "format": "prettier --write ."
+ "format": "prettier --write .",
+ "test": "vitest",
+ "test:run": "vitest run",
+ "test:coverage": "vitest run --coverage",
+ "test:ui": "vitest --ui"
},
"dependencies": {
"@ant-design/colors": "^7.1.0",
@@ -77,7 +81,10 @@
"@types/react-dom": "19.0.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
+ "@vitest/coverage-v8": "^3.2.4",
+ "@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
+ "jsdom": "^26.1.0",
"postcss": "^8.5.2",
"prettier-plugin-tailwindcss": "^0.6.13",
"rollup": "^4.40.2",
diff --git a/worklenz-frontend/src/pages/auth/__tests__/AuthenticatingPage.test.tsx b/worklenz-frontend/src/pages/auth/__tests__/AuthenticatingPage.test.tsx
new file mode 100644
index 00000000..3414f33e
--- /dev/null
+++ b/worklenz-frontend/src/pages/auth/__tests__/AuthenticatingPage.test.tsx
@@ -0,0 +1,228 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { BrowserRouter } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { I18nextProvider } from 'react-i18next';
+import i18n from 'i18next';
+
+import AuthenticatingPage from '../AuthenticatingPage';
+import { verifyAuthentication } from '@/features/auth/authSlice';
+import { setUser } from '@/features/user/userSlice';
+import { setSession } from '@/utils/session-helper';
+
+// Mock dependencies
+vi.mock('@/features/auth/authSlice', () => ({
+ verifyAuthentication: vi.fn(),
+}));
+
+vi.mock('@/features/user/userSlice', () => ({
+ setUser: vi.fn(),
+}));
+
+vi.mock('@/utils/session-helper', () => ({
+ setSession: vi.fn(),
+}));
+
+vi.mock('@/utils/errorLogger', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('@/shared/constants', () => ({
+ WORKLENZ_REDIRECT_PROJ_KEY: 'worklenz_redirect_proj',
+}));
+
+// Mock navigation
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Mock dispatch
+const mockDispatch = vi.fn();
+vi.mock('@/hooks/useAppDispatch', () => ({
+ useAppDispatch: () => mockDispatch,
+}));
+
+// Setup i18n for testing
+i18n.init({
+ lng: 'en',
+ resources: {
+ en: {
+ 'auth/auth-common': {
+ authenticating: 'Authenticating...',
+ gettingThingsReady: 'Getting things ready for you...',
+ },
+ },
+ },
+});
+
+// Create test store
+const createTestStore = () => {
+ return configureStore({
+ reducer: {
+ auth: (state = {}) => state,
+ user: (state = {}) => state,
+ },
+ });
+};
+
+const renderWithProviders = (component: React.ReactElement) => {
+ const store = createTestStore();
+ return render(
+
+
+
+ {component}
+
+
+
+ );
+};
+
+describe('AuthenticatingPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ localStorage.clear();
+ // Mock window.location
+ Object.defineProperty(window, 'location', {
+ value: { href: '' },
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders loading state correctly', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Authenticating...')).toBeInTheDocument();
+ expect(screen.getByText('Getting things ready for you...')).toBeInTheDocument();
+ expect(screen.getByRole('generic', { busy: true })).toBeInTheDocument();
+ });
+
+ it('redirects to login when authentication fails', async () => {
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({ authenticated: false }),
+ });
+
+ renderWithProviders();
+
+ // Run all pending timers
+ await vi.runAllTimersAsync();
+
+ expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
+ });
+
+ it('redirects to setup when user setup is not completed', async () => {
+ const mockUser = {
+ id: '1',
+ email: 'test@example.com',
+ setup_completed: false,
+ };
+
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({
+ authenticated: true,
+ user: mockUser,
+ }),
+ });
+
+ renderWithProviders();
+
+ // Run all pending timers
+ await vi.runAllTimersAsync();
+
+ expect(setSession).toHaveBeenCalledWith(mockUser);
+ expect(setUser).toHaveBeenCalledWith(mockUser);
+ expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
+ });
+
+ it('redirects to home after successful authentication', async () => {
+ const mockUser = {
+ id: '1',
+ email: 'test@example.com',
+ setup_completed: true,
+ };
+
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({
+ authenticated: true,
+ user: mockUser,
+ }),
+ });
+
+ renderWithProviders();
+
+ // Run all pending timers
+ await vi.runAllTimersAsync();
+
+ expect(setSession).toHaveBeenCalledWith(mockUser);
+ expect(setUser).toHaveBeenCalledWith(mockUser);
+ expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
+ });
+
+ it('redirects to project when redirect key is present in localStorage', async () => {
+ const projectId = 'test-project-123';
+ localStorage.setItem('worklenz_redirect_proj', projectId);
+
+ const mockUser = {
+ id: '1',
+ email: 'test@example.com',
+ setup_completed: true,
+ };
+
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({
+ authenticated: true,
+ user: mockUser,
+ }),
+ });
+
+ // Mock window.location with a proper setter
+ let hrefValue = '';
+ Object.defineProperty(window, 'location', {
+ value: {
+ get href() {
+ return hrefValue;
+ },
+ set href(value) {
+ hrefValue = value;
+ },
+ },
+ writable: true,
+ });
+
+ renderWithProviders();
+
+ // Run all pending timers
+ await vi.runAllTimersAsync();
+
+ expect(setSession).toHaveBeenCalledWith(mockUser);
+ expect(setUser).toHaveBeenCalledWith(mockUser);
+ expect(hrefValue).toBe(`/worklenz/projects/${projectId}?tab=tasks-list`);
+ expect(localStorage.getItem('worklenz_redirect_proj')).toBeNull();
+ });
+
+ it('handles authentication errors and redirects to login', async () => {
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockRejectedValue(new Error('Authentication failed')),
+ });
+
+ renderWithProviders();
+
+ // Run all pending timers
+ await vi.runAllTimersAsync();
+
+ expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
+ });
+});
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx b/worklenz-frontend/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx
new file mode 100644
index 00000000..b8101e9e
--- /dev/null
+++ b/worklenz-frontend/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx
@@ -0,0 +1,286 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { BrowserRouter } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { I18nextProvider } from 'react-i18next';
+import i18n from 'i18next';
+
+import ForgotPasswordPage from '../ForgotPasswordPage';
+import { resetPassword, verifyAuthentication } from '@/features/auth/authSlice';
+
+// Mock dependencies
+vi.mock('@/features/auth/authSlice', () => ({
+ resetPassword: vi.fn(),
+ verifyAuthentication: vi.fn(),
+}));
+
+vi.mock('@/features/user/userSlice', () => ({
+ setUser: vi.fn(),
+}));
+
+vi.mock('@/utils/session-helper', () => ({
+ setSession: vi.fn(),
+}));
+
+vi.mock('@/utils/errorLogger', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('@/hooks/useMixpanelTracking', () => ({
+ useMixpanelTracking: () => ({
+ trackMixpanelEvent: vi.fn(),
+ }),
+}));
+
+vi.mock('@/hooks/useDoumentTItle', () => ({
+ useDocumentTitle: vi.fn(),
+}));
+
+vi.mock('react-responsive', () => ({
+ useMediaQuery: () => false,
+}));
+
+// Mock navigation
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Mock dispatch
+const mockDispatch = vi.fn();
+vi.mock('@/hooks/useAppDispatch', () => ({
+ useAppDispatch: () => mockDispatch,
+}));
+
+// Setup i18n for testing
+i18n.init({
+ lng: 'en',
+ resources: {
+ en: {
+ 'auth/forgot-password': {
+ headerDescription: 'Enter your email to reset your password',
+ emailRequired: 'Please input your email!',
+ emailPlaceholder: 'Enter your email',
+ resetPasswordButton: 'Reset Password',
+ returnToLoginButton: 'Return to Login',
+ orText: 'or',
+ successTitle: 'Password Reset Email Sent',
+ successMessage: 'Please check your email for instructions to reset your password.',
+ },
+ },
+ },
+});
+
+// Create test store
+const createTestStore = () => {
+ return configureStore({
+ reducer: {
+ auth: (state = {}) => state,
+ user: (state = {}) => state,
+ },
+ });
+};
+
+const renderWithProviders = (component: React.ReactElement) => {
+ const store = createTestStore();
+ return render(
+
+
+
+ {component}
+
+
+
+ );
+};
+
+describe('ForgotPasswordPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Mock URL search params
+ Object.defineProperty(window, 'location', {
+ value: { search: '' },
+ writable: true,
+ });
+ });
+
+ it('renders forgot password form correctly', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Return to Login' })).toBeInTheDocument();
+ expect(screen.getByText('or')).toBeInTheDocument();
+ });
+
+ it('validates required email field', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Please input your email!')).toBeInTheDocument();
+ });
+ });
+
+ it('validates email format', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ await user.type(emailInput, 'invalid-email');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Please input your email!')).toBeInTheDocument();
+ });
+ });
+
+ it('submits form with valid email', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({ done: true }),
+ });
+
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ await user.type(emailInput, 'test@example.com');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(resetPassword).toHaveBeenCalledWith('test@example.com');
+ });
+ });
+
+ it('shows success message after successful submission', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({ done: true }),
+ });
+
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ await user.type(emailInput, 'test@example.com');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Password Reset Email Sent')).toBeInTheDocument();
+ expect(screen.getByText('Please check your email for instructions to reset your password.')).toBeInTheDocument();
+ });
+ });
+
+ it('shows loading state during submission', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
+ });
+
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ await user.type(emailInput, 'test@example.com');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
+ });
+ });
+
+ it('handles submission errors gracefully', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
+ });
+
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ await user.type(emailInput, 'test@example.com');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ // Should not show success message
+ expect(screen.queryByText('Password Reset Email Sent')).not.toBeInTheDocument();
+ // Should still show the form
+ expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
+ });
+ });
+
+ it('navigates to login page when return button is clicked', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const returnButton = screen.getByRole('button', { name: 'Return to Login' });
+ await user.click(returnButton);
+
+ expect(returnButton.closest('a')).toHaveAttribute('href', '/auth/login');
+ });
+
+ it('handles team parameter from URL', () => {
+ Object.defineProperty(window, 'location', {
+ value: { search: '?team=test-team-id' },
+ writable: true,
+ });
+
+ renderWithProviders();
+
+ // Component should render normally even with team parameter
+ expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
+ });
+
+ it('redirects authenticated users to home', async () => {
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({
+ authenticated: true,
+ user: { id: '1', email: 'test@example.com' },
+ }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
+ });
+ });
+
+ it('does not submit with empty email after trimming', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({ done: true }),
+ });
+
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ await user.type(emailInput, ' '); // Only whitespace
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ // Should not call resetPassword with empty string
+ expect(resetPassword).not.toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/auth/__tests__/LoggingOutPage.test.tsx b/worklenz-frontend/src/pages/auth/__tests__/LoggingOutPage.test.tsx
new file mode 100644
index 00000000..6d809ccb
--- /dev/null
+++ b/worklenz-frontend/src/pages/auth/__tests__/LoggingOutPage.test.tsx
@@ -0,0 +1,241 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { BrowserRouter } from 'react-router-dom';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { I18nextProvider } from 'react-i18next';
+import i18n from 'i18next';
+
+import LoggingOutPage from '../LoggingOutPage';
+import { authApiService } from '@/api/auth/auth.api.service';
+import CacheCleanup from '@/utils/cache-cleanup';
+
+// Mock dependencies
+const mockAuthService = {
+ signOut: vi.fn(),
+};
+
+vi.mock('@/hooks/useAuth', () => ({
+ useAuthService: () => mockAuthService,
+}));
+
+vi.mock('@/api/auth/auth.api.service', () => ({
+ authApiService: {
+ logout: vi.fn(),
+ },
+}));
+
+vi.mock('@/utils/cache-cleanup', () => ({
+ default: {
+ clearAllCaches: vi.fn(),
+ forceReload: vi.fn(),
+ },
+}));
+
+vi.mock('react-responsive', () => ({
+ useMediaQuery: () => false,
+}));
+
+// Mock navigation
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Setup i18n for testing
+i18n.init({
+ lng: 'en',
+ resources: {
+ en: {
+ 'auth/auth-common': {
+ loggingOut: 'Logging Out...',
+ },
+ },
+ },
+});
+
+const renderWithProviders = (component: React.ReactElement) => {
+ return render(
+
+
+ {component}
+
+
+ );
+};
+
+describe('LoggingOutPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+
+ // Mock console.error to avoid noise in tests
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ vi.mock('@/hooks/useAuth', () => ({
+ useAuthService: () => mockAuthService,
+ }));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('renders loading state correctly', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Logging Out...')).toBeInTheDocument();
+ expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
+ });
+
+ it('performs complete logout sequence successfully', async () => {
+ mockAuthService.signOut.mockResolvedValue(undefined);
+ (authApiService.logout as any).mockResolvedValue(undefined);
+ (CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockAuthService.signOut).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(authApiService.logout).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
+ });
+
+ // Fast-forward time to trigger the setTimeout
+ vi.advanceTimersByTime(1000);
+
+ await waitFor(() => {
+ expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
+ });
+ });
+
+ it('handles auth service signOut failure', async () => {
+ mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
+ (authApiService.logout as any).mockResolvedValue(undefined);
+ (CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockAuthService.signOut).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
+ expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
+ });
+ });
+
+ it('handles backend logout failure', async () => {
+ mockAuthService.signOut.mockResolvedValue(undefined);
+ (authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
+ (CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockAuthService.signOut).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(authApiService.logout).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
+ expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
+ });
+ });
+
+ it('handles cache cleanup failure', async () => {
+ mockAuthService.signOut.mockResolvedValue(undefined);
+ (authApiService.logout as any).mockResolvedValue(undefined);
+ (CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockAuthService.signOut).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(authApiService.logout).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
+ expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
+ });
+ });
+
+ it('triggers logout sequence immediately on mount', () => {
+ renderWithProviders();
+
+ expect(mockAuthService.signOut).toHaveBeenCalled();
+ });
+
+ it('shows consistent loading UI throughout logout process', async () => {
+ mockAuthService.signOut.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
+ (authApiService.logout as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
+ (CacheCleanup.clearAllCaches as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
+
+ renderWithProviders();
+
+ // Should show loading state immediately
+ expect(screen.getByText('Logging Out...')).toBeInTheDocument();
+ expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
+
+ // Should continue showing loading state during the process
+ vi.advanceTimersByTime(50);
+
+ expect(screen.getByText('Logging Out...')).toBeInTheDocument();
+ expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
+ });
+
+ it('calls forceReload with correct path after timeout', async () => {
+ mockAuthService.signOut.mockResolvedValue(undefined);
+ (authApiService.logout as any).mockResolvedValue(undefined);
+ (CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
+
+ renderWithProviders();
+
+ // Wait for all async operations to complete
+ await waitFor(() => {
+ expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
+ });
+
+ // Fast-forward exactly 1000ms
+ vi.advanceTimersByTime(1000);
+
+ await waitFor(() => {
+ expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
+ expect(CacheCleanup.forceReload).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('handles complete failure of all logout steps', async () => {
+ mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
+ (authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
+ (CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
+ expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
+ });
+ });
+});
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx b/worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
new file mode 100644
index 00000000..ee93c5ef
--- /dev/null
+++ b/worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
@@ -0,0 +1,317 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { BrowserRouter } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { I18nextProvider } from 'react-i18next';
+import i18n from 'i18next';
+
+import LoginPage from '../LoginPage';
+import { login, verifyAuthentication } from '@/features/auth/authSlice';
+
+// Mock dependencies
+vi.mock('@/features/auth/authSlice', () => ({
+ login: vi.fn(),
+ verifyAuthentication: vi.fn(),
+}));
+
+vi.mock('@/features/user/userSlice', () => ({
+ setUser: vi.fn(),
+}));
+
+vi.mock('@/utils/session-helper', () => ({
+ setSession: vi.fn(),
+}));
+
+vi.mock('@/utils/errorLogger', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('@/hooks/useMixpanelTracking', () => ({
+ useMixpanelTracking: () => ({
+ trackMixpanelEvent: vi.fn(),
+ }),
+}));
+
+vi.mock('@/hooks/useDoumentTItle', () => ({
+ useDocumentTitle: vi.fn(),
+}));
+
+vi.mock('@/hooks/useAuth', () => ({
+ useAuthService: () => ({
+ getCurrentSession: () => null,
+ }),
+}));
+
+vi.mock('@/services/alerts/alertService', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('react-responsive', () => ({
+ useMediaQuery: () => false,
+}));
+
+// Mock navigation
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Mock dispatch
+const mockDispatch = vi.fn();
+vi.mock('@/hooks/useAppDispatch', () => ({
+ useAppDispatch: () => mockDispatch,
+}));
+
+// Setup i18n for testing
+i18n.init({
+ lng: 'en',
+ resources: {
+ en: {
+ 'auth/login': {
+ headerDescription: 'Sign in to your account',
+ emailRequired: 'Please input your email!',
+ passwordRequired: 'Please input your password!',
+ emailPlaceholder: 'Email',
+ passwordPlaceholder: 'Password',
+ loginButton: 'Sign In',
+ rememberMe: 'Remember me',
+ forgotPasswordButton: 'Forgot password?',
+ signInWithGoogleButton: 'Sign in with Google',
+ orText: 'or',
+ dontHaveAccountText: "Don't have an account?",
+ signupButton: 'Sign up',
+ successMessage: 'Login successful!',
+ 'validationMessages.email': 'Please enter a valid email!',
+ 'validationMessages.password': 'Password must be at least 8 characters!',
+ 'errorMessages.loginErrorTitle': 'Login Failed',
+ 'errorMessages.loginErrorMessage': 'Invalid email or password',
+ },
+ },
+ },
+});
+
+// Create test store
+const createTestStore = (initialState: any = {}) => {
+ return configureStore({
+ reducer: {
+ auth: (state = { isLoading: false, ...initialState.auth }) => state,
+ user: (state = {}) => state,
+ },
+ });
+};
+
+const renderWithProviders = (component: React.ReactElement, initialState: any = {}) => {
+ const store = createTestStore(initialState);
+ return render(
+
+
+
+ {component}
+
+
+
+ );
+};
+
+describe('LoginPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Mock environment variables
+ vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
+ vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
+ });
+
+ it('renders login form correctly', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Sign in to your account')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
+ expect(screen.getByText('Remember me')).toBeInTheDocument();
+ expect(screen.getByText('Forgot password?')).toBeInTheDocument();
+ });
+
+ it('shows Google login button when enabled', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
+ });
+
+ it('validates required fields', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const submitButton = screen.getByRole('button', { name: 'Sign In' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Please input your email!')).toBeInTheDocument();
+ expect(screen.getByText('Please input your password!')).toBeInTheDocument();
+ });
+ });
+
+ it('validates email format', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Email');
+ await user.type(emailInput, 'invalid-email');
+
+ const submitButton = screen.getByRole('button', { name: 'Sign In' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Please enter a valid email!')).toBeInTheDocument();
+ });
+ });
+
+ it('validates password minimum length', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Email');
+ const passwordInput = screen.getByPlaceholderText('Password');
+
+ await user.type(emailInput, 'test@example.com');
+ await user.type(passwordInput, '123');
+
+ const submitButton = screen.getByRole('button', { name: 'Sign In' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
+ });
+ });
+
+ it('submits form with valid credentials', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({
+ authenticated: true,
+ user: { id: '1', email: 'test@example.com' },
+ }),
+ });
+
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Email');
+ const passwordInput = screen.getByPlaceholderText('Password');
+
+ await user.type(emailInput, 'test@example.com');
+ await user.type(passwordInput, 'password123');
+
+ const submitButton = screen.getByRole('button', { name: 'Sign In' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(login).toHaveBeenCalledWith({
+ email: 'test@example.com',
+ password: 'password123',
+ remember: true,
+ });
+ });
+ });
+
+ it('shows loading state during login', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, { auth: { isLoading: true } });
+
+ const submitButton = screen.getByRole('button', { name: 'Sign In' });
+ expect(submitButton).toBeDisabled();
+ expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
+ });
+
+ it('handles Google login click', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ // Mock window.location
+ Object.defineProperty(window, 'location', {
+ value: { href: '' },
+ writable: true,
+ });
+
+ const googleButton = screen.getByText('Sign in with Google');
+ await user.click(googleButton);
+
+ expect(window.location.href).toBe('http://localhost:3000/secure/google');
+ });
+
+ it('navigates to signup page', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const signupLink = screen.getByText('Sign up');
+ await user.click(signupLink);
+
+ // Link navigation is handled by React Router, so we just check the element exists
+ expect(signupLink.closest('a')).toHaveAttribute('href', '/auth/signup');
+ });
+
+ it('navigates to forgot password page', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const forgotPasswordLink = screen.getByText('Forgot password?');
+ await user.click(forgotPasswordLink);
+
+ expect(forgotPasswordLink.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
+ });
+
+ it('toggles remember me checkbox', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const rememberMeCheckbox = screen.getByRole('checkbox', { name: 'Remember me' });
+ expect(rememberMeCheckbox).toBeChecked(); // Default is true
+
+ await user.click(rememberMeCheckbox);
+ expect(rememberMeCheckbox).not.toBeChecked();
+ });
+
+ it('redirects already authenticated users', async () => {
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({
+ authenticated: true,
+ user: { id: '1', email: 'test@example.com', setup_completed: true },
+ }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
+ });
+ });
+
+ it('redirects to setup for users with incomplete setup', async () => {
+ const mockCurrentSession = {
+ id: '1',
+ email: 'test@example.com',
+ setup_completed: false,
+ };
+
+ vi.mock('@/hooks/useAuth', () => ({
+ useAuthService: () => ({
+ getCurrentSession: () => mockCurrentSession,
+ }),
+ }));
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
+ });
+ });
+});
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx b/worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
new file mode 100644
index 00000000..12a9a79e
--- /dev/null
+++ b/worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
@@ -0,0 +1,359 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { BrowserRouter } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { I18nextProvider } from 'react-i18next';
+import i18n from 'i18next';
+
+import SignupPage from '../SignupPage';
+import { signUp } from '@/features/auth/authSlice';
+import { authApiService } from '@/api/auth/auth.api.service';
+
+// Mock dependencies
+vi.mock('@/features/auth/authSlice', () => ({
+ signUp: vi.fn(),
+}));
+
+vi.mock('@/api/auth/auth.api.service', () => ({
+ authApiService: {
+ signUpCheck: vi.fn(),
+ verifyRecaptchaToken: vi.fn(),
+ },
+}));
+
+vi.mock('@/hooks/useMixpanelTracking', () => ({
+ useMixpanelTracking: () => ({
+ trackMixpanelEvent: vi.fn(),
+ }),
+}));
+
+vi.mock('@/hooks/useDoumentTItle', () => ({
+ useDocumentTitle: vi.fn(),
+}));
+
+vi.mock('@/utils/errorLogger', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('@/services/alerts/alertService', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('react-responsive', () => ({
+ useMediaQuery: () => false,
+}));
+
+// Mock navigation
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Mock dispatch
+const mockDispatch = vi.fn();
+vi.mock('@/hooks/useAppDispatch', () => ({
+ useAppDispatch: () => mockDispatch,
+}));
+
+// Setup i18n for testing
+i18n.init({
+ lng: 'en',
+ resources: {
+ en: {
+ 'auth/signup': {
+ headerDescription: 'Sign up to get started',
+ nameLabel: 'Full Name',
+ emailLabel: 'Email',
+ passwordLabel: 'Password',
+ nameRequired: 'Please input your name!',
+ emailRequired: 'Please input your email!',
+ passwordRequired: 'Please input your password!',
+ nameMinCharacterRequired: 'Name must be at least 4 characters!',
+ passwordMinCharacterRequired: 'Password must be at least 8 characters!',
+ passwordMaxCharacterRequired: 'Password must be no more than 32 characters!',
+ passwordPatternRequired: 'Password must contain uppercase, lowercase, number and special character!',
+ namePlaceholder: 'Enter your full name',
+ emailPlaceholder: 'Enter your email',
+ strongPasswordPlaceholder: 'Enter a strong password',
+ passwordGuideline: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.',
+ signupButton: 'Sign Up',
+ signInWithGoogleButton: 'Sign up with Google',
+ orText: 'or',
+ alreadyHaveAccountText: 'Already have an account?',
+ loginButton: 'Log in',
+ bySigningUpText: 'By signing up, you agree to our',
+ privacyPolicyLink: 'Privacy Policy',
+ andText: 'and',
+ termsOfUseLink: 'Terms of Use',
+ reCAPTCHAVerificationError: 'reCAPTCHA Verification Failed',
+ reCAPTCHAVerificationErrorMessage: 'Please try again',
+ 'passwordChecklist.minLength': 'At least 8 characters',
+ 'passwordChecklist.uppercase': 'One uppercase letter',
+ 'passwordChecklist.lowercase': 'One lowercase letter',
+ 'passwordChecklist.number': 'One number',
+ 'passwordChecklist.special': 'One special character',
+ },
+ },
+ },
+});
+
+// Create test store
+const createTestStore = () => {
+ return configureStore({
+ reducer: {
+ themeReducer: (state = { mode: 'light' }) => state,
+ },
+ });
+};
+
+const renderWithProviders = (component: React.ReactElement) => {
+ const store = createTestStore();
+ return render(
+
+
+
+ {component}
+
+
+
+ );
+};
+
+describe('SignupPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ // Mock environment variables
+ vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
+ vi.stubEnv('VITE_ENABLE_RECAPTCHA', 'false');
+ vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
+
+ // Mock URL search params
+ Object.defineProperty(window, 'location', {
+ value: { search: '' },
+ writable: true,
+ });
+ });
+
+ it('renders signup form correctly', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Sign up to get started')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Enter your full name')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Enter a strong password')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument();
+ });
+
+ it('shows Google signup button when enabled', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Sign up with Google')).toBeInTheDocument();
+ });
+
+ it('validates required fields', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const submitButton = screen.getByRole('button', { name: 'Sign Up' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Please input your name!')).toBeInTheDocument();
+ expect(screen.getByText('Please input your email!')).toBeInTheDocument();
+ expect(screen.getByText('Please input your password!')).toBeInTheDocument();
+ });
+ });
+
+ it('validates name minimum length', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const nameInput = screen.getByPlaceholderText('Enter your full name');
+ await user.type(nameInput, 'Jo');
+
+ const submitButton = screen.getByRole('button', { name: 'Sign Up' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Name must be at least 4 characters!')).toBeInTheDocument();
+ });
+ });
+
+ it('validates email format', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ await user.type(emailInput, 'invalid-email');
+
+ const submitButton = screen.getByRole('button', { name: 'Sign Up' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Please input your email!')).toBeInTheDocument();
+ });
+ });
+
+ it('validates password requirements', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter a strong password');
+ await user.type(passwordInput, 'weak');
+
+ const submitButton = screen.getByRole('button', { name: 'Sign Up' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
+ });
+ });
+
+ it('shows password checklist when password field is focused', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter a strong password');
+ await user.click(passwordInput);
+
+ await waitFor(() => {
+ expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
+ expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
+ expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
+ expect(screen.getByText('One number')).toBeInTheDocument();
+ expect(screen.getByText('One special character')).toBeInTheDocument();
+ });
+ });
+
+ it('updates password checklist based on input', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter a strong password');
+ await user.click(passwordInput);
+ await user.type(passwordInput, 'Password123!');
+
+ await waitFor(() => {
+ // All checklist items should be visible and some should be checked
+ expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
+ expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
+ expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
+ expect(screen.getByText('One number')).toBeInTheDocument();
+ expect(screen.getByText('One special character')).toBeInTheDocument();
+ });
+ });
+
+ it('submits form with valid data', async () => {
+ const user = userEvent.setup();
+ (authApiService.signUpCheck as any).mockResolvedValue({ done: true });
+
+ renderWithProviders();
+
+ const nameInput = screen.getByPlaceholderText('Enter your full name');
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ const passwordInput = screen.getByPlaceholderText('Enter a strong password');
+
+ await user.type(nameInput, 'John Doe');
+ await user.type(emailInput, 'john@example.com');
+ await user.type(passwordInput, 'Password123!');
+
+ const submitButton = screen.getByRole('button', { name: 'Sign Up' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(authApiService.signUpCheck).toHaveBeenCalledWith({
+ name: 'John Doe',
+ email: 'john@example.com',
+ password: 'Password123!',
+ });
+ });
+ });
+
+ it('handles Google signup click', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ // Mock window.location
+ Object.defineProperty(window, 'location', {
+ value: { href: '' },
+ writable: true,
+ });
+
+ const googleButton = screen.getByText('Sign up with Google');
+ await user.click(googleButton);
+
+ expect(window.location.href).toBe('http://localhost:3000/secure/google?');
+ });
+
+ it('pre-fills form from URL parameters', () => {
+ // Mock URLSearchParams
+ Object.defineProperty(window, 'location', {
+ value: { search: '?email=test@example.com&name=Test User' },
+ writable: true,
+ });
+
+ renderWithProviders();
+
+ const nameInput = screen.getByPlaceholderText('Enter your full name') as HTMLInputElement;
+ const emailInput = screen.getByPlaceholderText('Enter your email') as HTMLInputElement;
+
+ expect(nameInput.value).toBe('Test User');
+ expect(emailInput.value).toBe('test@example.com');
+ });
+
+ it('shows terms of use and privacy policy links', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Privacy Policy')).toBeInTheDocument();
+ expect(screen.getByText('Terms of Use')).toBeInTheDocument();
+
+ const privacyLink = screen.getByText('Privacy Policy').closest('a');
+ const termsLink = screen.getByText('Terms of Use').closest('a');
+
+ expect(privacyLink).toHaveAttribute('href', 'https://worklenz.com/privacy/');
+ expect(termsLink).toHaveAttribute('href', 'https://worklenz.com/terms/');
+ });
+
+ it('navigates to login page', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const loginLink = screen.getByText('Log in');
+ await user.click(loginLink);
+
+ expect(loginLink.closest('a')).toHaveAttribute('href', '/auth/login');
+ });
+
+ it('shows loading state during signup', async () => {
+ const user = userEvent.setup();
+ (authApiService.signUpCheck as any).mockResolvedValue({ done: true });
+
+ renderWithProviders();
+
+ const nameInput = screen.getByPlaceholderText('Enter your full name');
+ const emailInput = screen.getByPlaceholderText('Enter your email');
+ const passwordInput = screen.getByPlaceholderText('Enter a strong password');
+
+ await user.type(nameInput, 'John Doe');
+ await user.type(emailInput, 'john@example.com');
+ await user.type(passwordInput, 'Password123!');
+
+ const submitButton = screen.getByRole('button', { name: 'Sign Up' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/auth/__tests__/VerifyResetEmailPage.test.tsx b/worklenz-frontend/src/pages/auth/__tests__/VerifyResetEmailPage.test.tsx
new file mode 100644
index 00000000..99b47d7e
--- /dev/null
+++ b/worklenz-frontend/src/pages/auth/__tests__/VerifyResetEmailPage.test.tsx
@@ -0,0 +1,337 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { BrowserRouter, MemoryRouter } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { I18nextProvider } from 'react-i18next';
+import i18n from 'i18next';
+
+import VerifyResetEmailPage from '../VerifyResetEmailPage';
+import { updatePassword } from '@/features/auth/authSlice';
+
+// Mock dependencies
+vi.mock('@/features/auth/authSlice', () => ({
+ updatePassword: vi.fn(),
+}));
+
+vi.mock('@/utils/errorLogger', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('@/hooks/useMixpanelTracking', () => ({
+ useMixpanelTracking: () => ({
+ trackMixpanelEvent: vi.fn(),
+ }),
+}));
+
+vi.mock('@/hooks/useDoumentTItle', () => ({
+ useDocumentTitle: vi.fn(),
+}));
+
+vi.mock('react-responsive', () => ({
+ useMediaQuery: () => false,
+}));
+
+// Mock navigation
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Mock dispatch
+const mockDispatch = vi.fn();
+vi.mock('@/hooks/useAppDispatch', () => ({
+ useAppDispatch: () => mockDispatch,
+}));
+
+// Setup i18n for testing
+i18n.init({
+ lng: 'en',
+ resources: {
+ en: {
+ 'auth/verify-reset-email': {
+ description: 'Enter your new password',
+ passwordRequired: 'Please input your password!',
+ confirmPasswordRequired: 'Please confirm your password!',
+ placeholder: 'Enter new password',
+ confirmPasswordPlaceholder: 'Confirm new password',
+ resetPasswordButton: 'Reset Password',
+ resendResetEmail: 'Resend Reset Email',
+ orText: 'or',
+ passwordMismatch: 'The two passwords do not match!',
+ successTitle: 'Password Reset Successful',
+ successMessage: 'Your password has been reset successfully.',
+ 'passwordChecklist.minLength': 'At least 8 characters',
+ 'passwordChecklist.uppercase': 'One uppercase letter',
+ 'passwordChecklist.lowercase': 'One lowercase letter',
+ 'passwordChecklist.number': 'One number',
+ 'passwordChecklist.special': 'One special character',
+ },
+ },
+ },
+});
+
+// Create test store
+const createTestStore = () => {
+ return configureStore({
+ reducer: {
+ themeReducer: (state = { mode: 'light' }) => state,
+ },
+ });
+};
+
+const renderWithProviders = (component: React.ReactElement, route = '/verify-reset/test-hash/test-user') => {
+ const store = createTestStore();
+ return render(
+
+
+
+ {component}
+
+
+
+ );
+};
+
+describe('VerifyResetEmailPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders password reset form correctly', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('Enter your new password')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Confirm new password')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Resend Reset Email' })).toBeInTheDocument();
+ });
+
+ it('shows password checklist immediately', () => {
+ renderWithProviders();
+
+ expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
+ expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
+ expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
+ expect(screen.getByText('One number')).toBeInTheDocument();
+ expect(screen.getByText('One special character')).toBeInTheDocument();
+ });
+
+ it('validates required password fields', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Please input your password!')).toBeInTheDocument();
+ expect(screen.getByText('Please confirm your password!')).toBeInTheDocument();
+ });
+ });
+
+ it('validates password confirmation match', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter new password');
+ const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
+
+ await user.type(passwordInput, 'Password123!');
+ await user.type(confirmPasswordInput, 'DifferentPassword123!');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('The two passwords do not match!')).toBeInTheDocument();
+ });
+ });
+
+ it('updates password checklist based on input', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter new password');
+ await user.type(passwordInput, 'Password123!');
+
+ // All checklist items should be visible (this component shows them by default)
+ expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
+ expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
+ expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
+ expect(screen.getByText('One number')).toBeInTheDocument();
+ expect(screen.getByText('One special character')).toBeInTheDocument();
+ });
+
+ it('submits form with valid matching passwords', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({ done: true }),
+ });
+
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter new password');
+ const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
+
+ await user.type(passwordInput, 'Password123!');
+ await user.type(confirmPasswordInput, 'Password123!');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(updatePassword).toHaveBeenCalledWith({
+ hash: 'test-hash',
+ user: 'test-user',
+ password: 'Password123!',
+ confirmPassword: 'Password123!',
+ });
+ });
+ });
+
+ it('shows success message after successful password reset', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({ done: true }),
+ });
+
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter new password');
+ const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
+
+ await user.type(passwordInput, 'Password123!');
+ await user.type(confirmPasswordInput, 'Password123!');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Password Reset Successful')).toBeInTheDocument();
+ expect(screen.getByText('Your password has been reset successfully.')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
+ });
+ });
+
+ it('shows loading state during submission', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
+ });
+
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter new password');
+ const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
+
+ await user.type(passwordInput, 'Password123!');
+ await user.type(confirmPasswordInput, 'Password123!');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
+ });
+ });
+
+ it('handles submission errors gracefully', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
+ });
+
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter new password');
+ const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
+
+ await user.type(passwordInput, 'Password123!');
+ await user.type(confirmPasswordInput, 'Password123!');
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ // Should not show success message
+ expect(screen.queryByText('Password Reset Successful')).not.toBeInTheDocument();
+ // Should still show the form
+ expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
+ });
+ });
+
+ it('navigates to forgot password page when resend button is clicked', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const resendButton = screen.getByRole('button', { name: 'Resend Reset Email' });
+ await user.click(resendButton);
+
+ expect(resendButton.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
+ });
+
+ it('prevents pasting in confirm password field', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
+
+ await user.click(confirmPasswordInput);
+
+ // Try to paste - should be prevented
+ const pasteEvent = new ClipboardEvent('paste', {
+ clipboardData: new DataTransfer(),
+ });
+
+ fireEvent(confirmPasswordInput, pasteEvent);
+
+ // The preventDefault should be called (we can't easily test this directly,
+ // but we can ensure the input behavior remains consistent)
+ expect(confirmPasswordInput).toBeInTheDocument();
+ });
+
+ it('does not submit with empty passwords after trimming', async () => {
+ const user = userEvent.setup();
+ mockDispatch.mockReturnValue({
+ unwrap: vi.fn().mockResolvedValue({ done: true }),
+ });
+
+ renderWithProviders();
+
+ const passwordInput = screen.getByPlaceholderText('Enter new password');
+ const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
+
+ await user.type(passwordInput, ' '); // Only whitespace
+ await user.type(confirmPasswordInput, ' '); // Only whitespace
+
+ const submitButton = screen.getByRole('button', { name: 'Reset Password' });
+ await user.click(submitButton);
+
+ // Should not call updatePassword with empty strings
+ expect(updatePassword).not.toHaveBeenCalled();
+ });
+
+ it('extracts hash and user from URL params', () => {
+ renderWithProviders(, '/verify-reset/my-hash/my-user');
+
+ // Component should render normally, indicating it received the params
+ expect(screen.getByText('Enter your new password')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.test.ts b/worklenz-frontend/src/pages/settings/language-and-region/language-and-region-settings.test.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/worklenz-frontend/src/test/setup.ts b/worklenz-frontend/src/test/setup.ts
new file mode 100644
index 00000000..5746881e
--- /dev/null
+++ b/worklenz-frontend/src/test/setup.ts
@@ -0,0 +1,83 @@
+import '@testing-library/jest-dom';
+import { vi } from 'vitest';
+
+// Mock environment variables
+Object.defineProperty(process, 'env', {
+ value: {
+ NODE_ENV: 'test',
+ VITE_API_URL: 'http://localhost:3000',
+ VITE_ENABLE_GOOGLE_LOGIN: 'true',
+ VITE_ENABLE_RECAPTCHA: 'false',
+ VITE_RECAPTCHA_SITE_KEY: 'test-site-key',
+ },
+});
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(), // deprecated
+ removeListener: vi.fn(), // deprecated
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+// Mock IntersectionObserver
+global.IntersectionObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+
+// Mock ResizeObserver
+global.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+
+// Mock window.getSelection
+Object.defineProperty(window, 'getSelection', {
+ writable: true,
+ value: vi.fn().mockImplementation(() => ({
+ rangeCount: 0,
+ getRangeAt: vi.fn(),
+ removeAllRanges: vi.fn(),
+ })),
+});
+
+// Mock localStorage
+const localStorageMock = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+};
+vi.stubGlobal('localStorage', localStorageMock);
+
+// Mock sessionStorage
+const sessionStorageMock = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+};
+vi.stubGlobal('sessionStorage', sessionStorageMock);
+
+// Suppress console warnings during tests
+const originalConsoleWarn = console.warn;
+console.warn = (...args) => {
+ // Suppress specific warnings that are not relevant for tests
+ if (
+ args[0]?.includes?.('React Router Future Flag Warning') ||
+ args[0]?.includes?.('validateDOMNesting')
+ ) {
+ return;
+ }
+ originalConsoleWarn(...args);
+};
\ No newline at end of file
diff --git a/worklenz-frontend/vitest.config.ts b/worklenz-frontend/vitest.config.ts
new file mode 100644
index 00000000..43e4e0a3
--- /dev/null
+++ b/worklenz-frontend/vitest.config.ts
@@ -0,0 +1,38 @@
+///
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ css: true,
+ reporters: ['verbose'],
+ coverage: {
+ reporter: ['text', 'json', 'html'],
+ exclude: [
+ 'node_modules/',
+ 'src/test/',
+ ],
+ },
+ },
+ resolve: {
+ alias: [
+ { find: '@', replacement: path.resolve(__dirname, './src') },
+ { find: '@components', replacement: path.resolve(__dirname, './src/components') },
+ { find: '@features', replacement: path.resolve(__dirname, './src/features') },
+ { find: '@assets', replacement: path.resolve(__dirname, './src/assets') },
+ { find: '@utils', replacement: path.resolve(__dirname, './src/utils') },
+ { find: '@hooks', replacement: path.resolve(__dirname, './src/hooks') },
+ { find: '@pages', replacement: path.resolve(__dirname, './src/pages') },
+ { find: '@api', replacement: path.resolve(__dirname, './src/api') },
+ { find: '@types', replacement: path.resolve(__dirname, './src/types') },
+ { find: '@shared', replacement: path.resolve(__dirname, './src/shared') },
+ { find: '@layouts', replacement: path.resolve(__dirname, './src/layouts') },
+ { find: '@services', replacement: path.resolve(__dirname, './src/services') },
+ ],
+ },
+});
\ No newline at end of file