diff --git a/test_sort_fix.sql b/test_sort_fix.sql deleted file mode 100644 index ceb0b0a0..00000000 --- a/test_sort_fix.sql +++ /dev/null @@ -1,41 +0,0 @@ --- Test script to verify the sort order constraint fix - --- Test the helper function -SELECT get_sort_column_name('status'); -- Should return 'status_sort_order' -SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order' -SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order' -SELECT get_sort_column_name('members'); -- Should return 'member_sort_order' -SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default) - --- Test bulk update function (example - would need real project_id and task_ids) -/* -SELECT update_task_sort_orders_bulk( - '[ - {"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"}, - {"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"} - ]'::json, - 'status' -); -*/ - --- Verify that sort_order constraint still exists and works -SELECT - tc.constraint_name, - tc.table_name, - kcu.column_name -FROM information_schema.table_constraints tc -JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name -WHERE tc.constraint_name = 'tasks_sort_order_unique'; - --- Check that new sort order columns don't have unique constraints (which is correct) -SELECT - tc.constraint_name, - tc.table_name, - kcu.column_name -FROM information_schema.table_constraints tc -JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name -WHERE kcu.table_name = 'tasks' - AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order') - AND tc.constraint_type = 'UNIQUE'; \ No newline at end of file diff --git a/test_sort_orders.sql b/test_sort_orders.sql deleted file mode 100644 index 6a45de84..00000000 --- a/test_sort_orders.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Test script to validate the separate sort order implementation - --- Check if new columns exist -SELECT column_name, data_type, is_nullable, column_default -FROM information_schema.columns -WHERE table_name = 'tasks' - AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order') -ORDER BY column_name; - --- Check if helper function exists -SELECT routine_name, routine_type -FROM information_schema.routines -WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change'); - --- Sample test data to verify different sort orders work --- (This would be run after the migrations) -/* --- Test: Tasks should have different orders for different groupings -SELECT - id, - name, - sort_order, - status_sort_order, - priority_sort_order, - phase_sort_order, - member_sort_order -FROM tasks -WHERE project_id = '' -ORDER BY status_sort_order; -*/ \ No newline at end of file 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/public/js/hubspot.js b/worklenz-frontend/public/js/hubspot.js index 08d083de..cc0a282b 100644 --- a/worklenz-frontend/public/js/hubspot.js +++ b/worklenz-frontend/public/js/hubspot.js @@ -15,7 +15,7 @@ class HubSpotManager { * Load HubSpot script with dark mode support */ init() { - if (!this.isProduction) return; + // if (!this.isProduction) return; const loadHubSpot = () => { const script = document.createElement('script'); @@ -51,7 +51,8 @@ class HubSpotManager { if (existingStyle) { existingStyle.remove(); } - + + // Apply dark mode CSS if dark theme is active if (isDark) { this.injectDarkModeCSS(); } @@ -122,3 +123,10 @@ document.addEventListener('DOMContentLoaded', () => { // Make available globally for potential cleanup window.HubSpotManager = hubspot; }); + +// Add this style to ensure the chat widget uses the light color scheme +(function() { + var style = document.createElement('style'); + style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }'; + document.head.appendChild(style); +})(); diff --git a/worklenz-frontend/public/locales/alb/common.json b/worklenz-frontend/public/locales/alb/common.json index 8b005314..66eacc6f 100644 --- a/worklenz-frontend/public/locales/alb/common.json +++ b/worklenz-frontend/public/locales/alb/common.json @@ -6,5 +6,12 @@ "reconnecting": "Jeni shkëputur nga serveri.", "connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.", "connection-restored": "U lidhët me serverin me sukses", - "cancel": "Anulo" + "cancel": "Anulo", + "update-available": "Worklenz u përditesua!", + "update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.", + "update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.", + "update-whats-new": "💡 <1>Çfarë ka të re: Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit", + "update-now": "Përditeso tani", + "update-later": "Më vonë", + "updating": "Duke u përditesuar..." } diff --git a/worklenz-frontend/public/locales/de/common.json b/worklenz-frontend/public/locales/de/common.json index 10ad2bc1..8e880202 100644 --- a/worklenz-frontend/public/locales/de/common.json +++ b/worklenz-frontend/public/locales/de/common.json @@ -6,5 +6,12 @@ "reconnecting": "Vom Server getrennt.", "connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.", "connection-restored": "Erfolgreich mit dem Server verbunden", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "update-available": "Worklenz aktualisiert!", + "update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.", + "update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.", + "update-whats-new": "💡 <1>Was ist neu: Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung", + "update-now": "Jetzt aktualisieren", + "update-later": "Später", + "updating": "Wird aktualisiert..." } diff --git a/worklenz-frontend/public/locales/en/common.json b/worklenz-frontend/public/locales/en/common.json index 641bd765..192b47b1 100644 --- a/worklenz-frontend/public/locales/en/common.json +++ b/worklenz-frontend/public/locales/en/common.json @@ -6,5 +6,12 @@ "reconnecting": "Disconnected from server.", "connection-lost": "Failed to connect to server. Please check your internet connection.", "connection-restored": "Connected to server successfully", - "cancel": "Cancel" + "cancel": "Cancel", + "update-available": "Worklenz Updated!", + "update-description": "A new version of Worklenz is available with the latest features and improvements.", + "update-instruction": "To get the best experience, please reload the page to apply the new changes.", + "update-whats-new": "💡 <1>What's new: Enhanced performance, bug fixes, and improved user experience", + "update-now": "Update Now", + "update-later": "Later", + "updating": "Updating..." } diff --git a/worklenz-frontend/public/locales/es/common.json b/worklenz-frontend/public/locales/es/common.json index 2f04e775..46753312 100644 --- a/worklenz-frontend/public/locales/es/common.json +++ b/worklenz-frontend/public/locales/es/common.json @@ -6,5 +6,12 @@ "reconnecting": "Reconectando al servidor...", "connection-lost": "Conexión perdida. Intentando reconectarse...", "connection-restored": "Conexión restaurada. Reconectando al servidor...", - "cancel": "Cancelar" + "cancel": "Cancelar", + "update-available": "¡Worklenz actualizado!", + "update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.", + "update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.", + "update-whats-new": "💡 <1>Qué hay de nuevo: Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada", + "update-now": "Actualizar ahora", + "update-later": "Más tarde", + "updating": "Actualizando..." } diff --git a/worklenz-frontend/public/locales/pt/common.json b/worklenz-frontend/public/locales/pt/common.json index 0e192458..9f452de7 100644 --- a/worklenz-frontend/public/locales/pt/common.json +++ b/worklenz-frontend/public/locales/pt/common.json @@ -6,5 +6,12 @@ "reconnecting": "Reconectando ao servidor...", "connection-lost": "Conexão perdida. Tentando reconectar...", "connection-restored": "Conexão restaurada. Reconectando ao servidor...", - "cancel": "Cancelar" + "cancel": "Cancelar", + "update-available": "Worklenz atualizado!", + "update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.", + "update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.", + "update-whats-new": "💡 <1>O que há de novo: Performance aprimorada, correções de bugs e experiência do usuário melhorada", + "update-now": "Atualizar agora", + "update-later": "Mais tarde", + "updating": "Atualizando..." } diff --git a/worklenz-frontend/public/locales/zh/common.json b/worklenz-frontend/public/locales/zh/common.json index 9b63a4fd..2ba5b63c 100644 --- a/worklenz-frontend/public/locales/zh/common.json +++ b/worklenz-frontend/public/locales/zh/common.json @@ -6,5 +6,12 @@ "reconnecting": "与服务器断开连接。", "connection-lost": "无法连接到服务器。请检查您的互联网连接。", "connection-restored": "成功连接到服务器", - "cancel": "取消" + "cancel": "取消", + "update-available": "Worklenz 已更新!", + "update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。", + "update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。", + "update-whats-new": "💡 <1>新增内容:性能增强、错误修复和用户体验改善", + "update-now": "立即更新", + "update-later": "稍后", + "updating": "正在更新..." } diff --git a/worklenz-frontend/public/sw.js b/worklenz-frontend/public/sw.js index e5120800..61737ca7 100644 --- a/worklenz-frontend/public/sw.js +++ b/worklenz-frontend/public/sw.js @@ -327,7 +327,13 @@ self.addEventListener('message', event => { case 'GET_VERSION': event.ports[0].postMessage({ version: CACHE_VERSION }); break; - + + case 'CHECK_FOR_UPDATES': + checkForUpdates().then((hasUpdates) => { + event.ports[0].postMessage({ hasUpdates }); + }); + break; + case 'CLEAR_CACHE': clearAllCaches().then(() => { event.ports[0].postMessage({ success: true }); @@ -352,6 +358,44 @@ async function clearAllCaches() { console.log('Service Worker: All caches cleared'); } +async function checkForUpdates() { + try { + // Check if there's a new service worker available + const registration = await self.registration.update(); + const hasNewWorker = registration.installing || registration.waiting; + + if (hasNewWorker) { + console.log('Service Worker: New version detected'); + return true; + } + + // Also check if the main app files have been updated by trying to fetch index.html + // and comparing it with the cached version + try { + const cache = await caches.open(CACHE_NAMES.STATIC); + const cachedResponse = await cache.match('/'); + const networkResponse = await fetch('/', { cache: 'no-cache' }); + + if (cachedResponse && networkResponse.ok) { + const cachedContent = await cachedResponse.text(); + const networkContent = await networkResponse.text(); + + if (cachedContent !== networkContent) { + console.log('Service Worker: App content has changed'); + return true; + } + } + } catch (error) { + console.log('Service Worker: Could not check for content updates', error); + } + + return false; + } catch (error) { + console.error('Service Worker: Error checking for updates', error); + return false; + } +} + async function handleLogout() { try { // Clear all caches diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 31344a40..51e6bdd7 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -6,6 +6,7 @@ import i18next from 'i18next'; // Components import ThemeWrapper from './features/theme/ThemeWrapper'; import ModuleErrorBoundary from './components/ModuleErrorBoundary'; +import { UpdateNotificationProvider } from './components/update-notification'; // Routes import router from './app/routes'; @@ -208,14 +209,16 @@ const App: React.FC = memo(() => { return ( }> - - - + + + + + ); diff --git a/worklenz-frontend/src/components/index.ts b/worklenz-frontend/src/components/index.ts index 57cd8bcd..65c4efa9 100644 --- a/worklenz-frontend/src/components/index.ts +++ b/worklenz-frontend/src/components/index.ts @@ -10,3 +10,6 @@ export { default as LabelsSelector } from './LabelsSelector'; export { default as Progress } from './Progress'; export { default as Tag } from './Tag'; export { default as Tooltip } from './Tooltip'; + +// Update Notification Components +export * from './update-notification'; diff --git a/worklenz-frontend/src/components/update-notification/UpdateNotification.tsx b/worklenz-frontend/src/components/update-notification/UpdateNotification.tsx new file mode 100644 index 00000000..8c377223 --- /dev/null +++ b/worklenz-frontend/src/components/update-notification/UpdateNotification.tsx @@ -0,0 +1,121 @@ +// Update Notification Component +// Shows a notification when new build is available and provides update options + +import React from 'react'; +import { Modal, Button, Space, Typography } from '@/shared/antd-imports'; +import { ReloadOutlined, CloseOutlined, DownloadOutlined } from '@/shared/antd-imports'; +import { useTranslation } from 'react-i18next'; +import { useServiceWorker } from '../../utils/serviceWorkerRegistration'; + +const { Text, Title } = Typography; + +interface UpdateNotificationProps { + visible: boolean; + onClose: () => void; + onUpdate: () => void; +} + +const UpdateNotification: React.FC = ({ + visible, + onClose, + onUpdate +}) => { + const { t } = useTranslation('common'); + const [isUpdating, setIsUpdating] = React.useState(false); + const { hardReload } = useServiceWorker(); + + const handleUpdate = async () => { + setIsUpdating(true); + try { + if (hardReload) { + await hardReload(); + } else { + // Fallback to regular reload + window.location.reload(); + } + onUpdate(); + } catch (error) { + console.error('Error during update:', error); + // Fallback to regular reload + window.location.reload(); + } + }; + + const handleLater = () => { + onClose(); + }; + + return ( + + + + {t('update-available')} + + + } + open={visible} + onCancel={handleLater} + footer={null} + centered + closable={false} + maskClosable={false} + width={460} + styles={{ + body: { padding: '20px 24px' } + }} + > +
+ + {t('update-description')} + +
+
+ + {t('update-instruction')} + +
+ +
+ + {t('update-whats-new', { + interpolation: { escapeValue: false } + })} + +
+ + + + + +
+ ); +}; + +export default UpdateNotification; \ No newline at end of file diff --git a/worklenz-frontend/src/components/update-notification/UpdateNotificationProvider.tsx b/worklenz-frontend/src/components/update-notification/UpdateNotificationProvider.tsx new file mode 100644 index 00000000..180286c9 --- /dev/null +++ b/worklenz-frontend/src/components/update-notification/UpdateNotificationProvider.tsx @@ -0,0 +1,50 @@ +// Update Notification Provider +// Provides global update notification management + +import React from 'react'; +import { useUpdateChecker } from '../../hooks/useUpdateChecker'; +import UpdateNotification from './UpdateNotification'; + +interface UpdateNotificationProviderProps { + children: React.ReactNode; + checkInterval?: number; + enableAutoCheck?: boolean; +} + +const UpdateNotificationProvider: React.FC = ({ + children, + checkInterval = 5 * 60 * 1000, // 5 minutes + enableAutoCheck = true +}) => { + const { + showUpdateNotification, + setShowUpdateNotification, + dismissUpdate + } = useUpdateChecker({ + checkInterval, + enableAutoCheck, + showNotificationOnUpdate: true + }); + + const handleClose = () => { + dismissUpdate(); + }; + + const handleUpdate = () => { + // The hardReload function in UpdateNotification will handle the actual update + setShowUpdateNotification(false); + }; + + return ( + <> + {children} + + + ); +}; + +export default UpdateNotificationProvider; \ No newline at end of file diff --git a/worklenz-frontend/src/components/update-notification/index.ts b/worklenz-frontend/src/components/update-notification/index.ts new file mode 100644 index 00000000..47ec8d9b --- /dev/null +++ b/worklenz-frontend/src/components/update-notification/index.ts @@ -0,0 +1,2 @@ +export { default as UpdateNotification } from './UpdateNotification'; +export { default as UpdateNotificationProvider } from './UpdateNotificationProvider'; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useUpdateChecker.ts b/worklenz-frontend/src/hooks/useUpdateChecker.ts new file mode 100644 index 00000000..2ecaf1ce --- /dev/null +++ b/worklenz-frontend/src/hooks/useUpdateChecker.ts @@ -0,0 +1,141 @@ +// Update Checker Hook +// Periodically checks for app updates and manages update notifications + +import React from 'react'; +import { useServiceWorker } from '../utils/serviceWorkerRegistration'; + +interface UseUpdateCheckerOptions { + checkInterval?: number; // Check interval in milliseconds (default: 5 minutes) + enableAutoCheck?: boolean; // Enable automatic checking (default: true) + showNotificationOnUpdate?: boolean; // Show notification when update is found (default: true) +} + +interface UseUpdateCheckerReturn { + hasUpdate: boolean; + isChecking: boolean; + lastChecked: Date | null; + checkForUpdates: () => Promise; + dismissUpdate: () => void; + showUpdateNotification: boolean; + setShowUpdateNotification: (show: boolean) => void; +} + +export function useUpdateChecker(options: UseUpdateCheckerOptions = {}): UseUpdateCheckerReturn { + const { + checkInterval = 5 * 60 * 1000, // 5 minutes + enableAutoCheck = true, + showNotificationOnUpdate = true + } = options; + + const { checkForUpdates: serviceWorkerCheckUpdates, swManager } = useServiceWorker(); + + const [hasUpdate, setHasUpdate] = React.useState(false); + const [isChecking, setIsChecking] = React.useState(false); + const [lastChecked, setLastChecked] = React.useState(null); + const [showUpdateNotification, setShowUpdateNotification] = React.useState(false); + const [updateDismissed, setUpdateDismissed] = React.useState(false); + + // Check for updates function + const checkForUpdates = React.useCallback(async () => { + if (!serviceWorkerCheckUpdates || isChecking) return; + + setIsChecking(true); + try { + const hasUpdates = await serviceWorkerCheckUpdates(); + setHasUpdate(hasUpdates); + setLastChecked(new Date()); + + // Show notification if update found and user hasn't dismissed it + if (hasUpdates && showNotificationOnUpdate && !updateDismissed) { + setShowUpdateNotification(true); + } + + console.log('Update check completed:', { hasUpdates }); + } catch (error) { + console.error('Error checking for updates:', error); + } finally { + setIsChecking(false); + } + }, [serviceWorkerCheckUpdates, isChecking, showNotificationOnUpdate, updateDismissed]); + + // Dismiss update notification + const dismissUpdate = React.useCallback(() => { + setUpdateDismissed(true); + setShowUpdateNotification(false); + }, []); + + // Set up automatic checking interval + React.useEffect(() => { + if (!enableAutoCheck || !swManager) return; + + // Initial check after a short delay + const initialTimeout = setTimeout(() => { + checkForUpdates(); + }, 10000); // 10 seconds after component mount + + // Set up interval for periodic checks + const intervalId = setInterval(() => { + checkForUpdates(); + }, checkInterval); + + return () => { + clearTimeout(initialTimeout); + clearInterval(intervalId); + }; + }, [enableAutoCheck, swManager, checkInterval, checkForUpdates]); + + // Listen for visibility change to check for updates when user returns to tab + React.useEffect(() => { + if (!enableAutoCheck) return; + + const handleVisibilityChange = () => { + if (!document.hidden && swManager) { + // Check for updates when user returns to the tab + setTimeout(() => { + checkForUpdates(); + }, 2000); // 2 second delay + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enableAutoCheck, swManager, checkForUpdates]); + + // Listen for focus events to check for updates + React.useEffect(() => { + if (!enableAutoCheck) return; + + const handleFocus = () => { + if (swManager && !isChecking) { + // Check for updates when window regains focus + setTimeout(() => { + checkForUpdates(); + }, 1000); // 1 second delay + } + }; + + window.addEventListener('focus', handleFocus); + return () => { + window.removeEventListener('focus', handleFocus); + }; + }, [enableAutoCheck, swManager, isChecking, checkForUpdates]); + + // Reset dismissed state when new update is found + React.useEffect(() => { + if (hasUpdate && updateDismissed) { + setUpdateDismissed(false); + } + }, [hasUpdate, updateDismissed]); + + return { + hasUpdate, + isChecking, + lastChecked, + checkForUpdates, + dismissUpdate, + showUpdateNotification, + setShowUpdateNotification + }; +} \ No newline at end of file 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/src/utils/serviceWorkerRegistration.ts b/worklenz-frontend/src/utils/serviceWorkerRegistration.ts index 10360a30..c720e48e 100644 --- a/worklenz-frontend/src/utils/serviceWorkerRegistration.ts +++ b/worklenz-frontend/src/utils/serviceWorkerRegistration.ts @@ -193,6 +193,17 @@ export class ServiceWorkerManager { } } + // Check for updates + async checkForUpdates(): Promise { + try { + const response = await this.sendMessage('CHECK_FOR_UPDATES'); + return response.hasUpdates; + } catch (error) { + console.error('Failed to check for updates:', error); + return false; + } + } + // Force update service worker async forceUpdate(): Promise { if (!this.registration) return; @@ -207,6 +218,27 @@ export class ServiceWorkerManager { } } + // Perform hard reload (clear cache and reload) + async hardReload(): Promise { + try { + // Clear all caches first + await this.clearCache(); + + // Force update the service worker + if (this.registration) { + await this.registration.update(); + await this.sendMessage('SKIP_WAITING'); + } + + // Perform hard reload by clearing browser cache + window.location.reload(); + } catch (error) { + console.error('Failed to perform hard reload:', error); + // Fallback to regular reload + window.location.reload(); + } + } + // Check if app is running offline isOffline(): boolean { return !navigator.onLine; @@ -263,6 +295,8 @@ export function useServiceWorker() { swManager, clearCache: () => swManager?.clearCache(), forceUpdate: () => swManager?.forceUpdate(), + hardReload: () => swManager?.hardReload(), + checkForUpdates: () => swManager?.checkForUpdates(), getVersion: () => swManager?.getVersion(), }; } 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