diff --git a/package-lock.json b/package-lock.json index 302f49e..fe84fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,15 @@ "jsonwebtoken": "^9.0.2", "mantine-datatable": "^7.12.4", "multer": "^1.4.5-lts.1", - "next-swagger-doc": "^0.4" + "next-swagger-doc": "^0.4", + "uuid": "^11.0.3" }, "devDependencies": { "@playwright/test": "^1.46", + "@tanstack/query-core": "^5.62.3", "@testing-library/react": "^16.0", "@types/jsonwebtoken": "^9.0.7", + "@types/mock-fs": "^4.13.4", "@types/react": "18.3", "@vanilla-extract/vite-plugin": "^4.0.13", "@vitejs/plugin-react": "^4.3.1", @@ -37,6 +40,8 @@ "eslint-config-next": "*", "eslint-config-prettier": "*", "happy-dom": ">=15.10.2", + "jsdom": "^25.0.1", + "mock-fs": "^5.4.1", "npm-run-all": "*", "prettier": "*", "vite-tsconfig-paths": "^5.0.1", @@ -1853,9 +1858,10 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.59.20", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.20.tgz", - "integrity": "sha512-e8vw0lf7KwfGe1if4uPFhvZRWULqHjFcz3K8AebtieXvnMOz5FSzlZe3mTLlPuUBcydCnBRqYs2YJ5ys68wwLg==", + "version": "5.62.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.3.tgz", + "integrity": "sha512-Jp/nYoz8cnO7kqhOlSv8ke/0MJRJVGuZ0P/JO9KQ+f45mpN90hrerzavyTKeSoT/pOzeoOUkv1Xd0wPsxAWXfg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -1878,6 +1884,16 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query/node_modules/@tanstack/query-core": { + "version": "5.59.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.20.tgz", + "integrity": "sha512-e8vw0lf7KwfGe1if4uPFhvZRWULqHjFcz3K8AebtieXvnMOz5FSzlZe3mTLlPuUBcydCnBRqYs2YJ5ys68wwLg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -2009,6 +2025,16 @@ "@types/node": "*" } }, + "node_modules/@types/mock-fs": { + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", + "integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.7.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", @@ -2747,6 +2773,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2997,6 +3033,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3293,6 +3336,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", @@ -3351,9 +3407,9 @@ "license": "MIT" }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -3395,6 +3451,19 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", + "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.7.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", @@ -3414,6 +3483,30 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -3485,6 +3578,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -3600,6 +3700,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4660,6 +4770,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5024,6 +5149,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5031,6 +5169,47 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5404,6 +5583,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "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" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -5708,6 +5894,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -6270,6 +6507,16 @@ "ufo": "^1.5.4" } }, + "node_modules/mock-fs": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.1.tgz", + "integrity": "sha512-sz/Q8K1gXXXHR+qr0GZg2ysxCRr323kuN10O7CtQjraJsFDJ4SJ+0I5MzALz7aRp9lHk8Cc/YdsT95h9Ka1aFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/modern-ahocorasick": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.0.1.tgz", @@ -6522,9 +6769,9 @@ "license": "MIT" }, "node_modules/npm-run-all/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "license": "MIT", "dependencies": { @@ -6627,6 +6874,13 @@ "which": "bin/which" } }, + "node_modules/nwsapi": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz", + "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6860,6 +7114,19 @@ "node": ">=4" } }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7556,6 +7823,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7623,6 +7897,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -8288,6 +8582,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "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" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -8502,6 +8803,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.66", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.66.tgz", + "integrity": "sha512-l3ciXsYFel/jSRfESbyKYud1nOw7WfhrBEF9I3UiarYk/qEaOOwu3qXNECHw4fHGHGTEOuhf/VdKgoDX5M/dhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.66" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.66", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.66.tgz", + "integrity": "sha512-s07jJruSwndD2X8bVjwioPfqpIc1pDTzszPe9pL1Skbh4bjytL85KNQ3tolqLbCvpQHawIsGfFi9dgerWjqW4g==", + "dev": true, + "license": "MIT" + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -8524,6 +8845,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", + "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -8900,6 +9247,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -9556,6 +9916,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", @@ -9661,6 +10034,19 @@ "node": ">=4.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -9671,6 +10057,20 @@ "node": ">=12" } }, + "node_modules/whatwg-url": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", + "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9904,6 +10304,45 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 0db7ef7..1f9782f 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ "license": "AGPL-3.0-or-later", "devDependencies": { "@playwright/test": "^1.46", + "@tanstack/query-core": "^5.62.3", "@testing-library/react": "^16.0", "@types/jsonwebtoken": "^9.0.7", + "@types/mock-fs": "^4.13.4", "@types/react": "18.3", "@vanilla-extract/vite-plugin": "^4.0.13", "@vitejs/plugin-react": "^4.3.1", @@ -35,6 +37,8 @@ "eslint-config-next": "*", "eslint-config-prettier": "*", "happy-dom": ">=15.10.2", + "jsdom": "^25.0.1", + "mock-fs": "^5.4.1", "npm-run-all": "*", "prettier": "*", "vite-tsconfig-paths": "^5.0.1", @@ -54,6 +58,7 @@ "jsonwebtoken": "^9.0.2", "mantine-datatable": "^7.12.4", "multer": "^1.4.5-lts.1", - "next-swagger-doc": "^0.4" + "next-swagger-doc": "^0.4", + "uuid": "^11.0.3" } } diff --git a/src/app/api/health/route.test.ts b/src/app/api/health/route.test.ts new file mode 100644 index 0000000..d5d355c --- /dev/null +++ b/src/app/api/health/route.test.ts @@ -0,0 +1,15 @@ +import { GET } from './route' +import { describe, it, expect } from 'vitest' + +describe('Healthcheck Endpoint', () => { + it('should return the correct healthcheck response', async () => { + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ + success: true, + message: { status: 'ok' }, + }) + }) +}) diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 695b4d0..71f5d00 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' export const GET = async () => { return NextResponse.json({ - success: false, + success: true, message: { status: 'ok' }, }) } diff --git a/src/app/api/run/[runId]/approve/route.test.ts b/src/app/api/run/[runId]/approve/route.test.ts new file mode 100644 index 0000000..8a3928d --- /dev/null +++ b/src/app/api/run/[runId]/approve/route.test.ts @@ -0,0 +1,75 @@ +import { POST } from '@/app/api/run/[runId]/approve/route' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { UPLOAD_DIR } from '@/app/utils' +import mockFs from 'mock-fs' +import { v4 } from 'uuid' + +describe('POST /api/run/:runId/approve', () => { + const mockFileContent = 'header1,header2\nvalue1,value2' + const runId = v4() + + beforeEach(() => { + // Mock the file system with the necessary file + mockFs({ + [UPLOAD_DIR]: { + [runId]: mockFileContent, + }, + }) + }) + + it('should return 400 if runId is missing', async () => { + const req = new Request('http://localhost', { method: 'POST' }) + // @ts-ignore + const res = await POST(req as any, { params: {} }) + + // Check status + expect(res.status).toBe(400) + }) + + it('should return 400 if the file does not exist', async () => { + // Simulate the file not existing by removing it from mockFs + mockFs({ + [UPLOAD_DIR]: {}, // Empty the directory to simulate no file for 'runId' + }) + + const req = new Request('http://localhost', { method: 'POST' }) + const res = await POST(req as any, { params: { runId: 'non-existent-run' } }) + + // Check status + expect(res.status).toBe(400) + + // Check JSON payload + const data = await res.json() + expect(data).toEqual({ error: 'No file exists to delete' }) + }) + + it('should return 500 if the external API request fails', async () => { + // Mock the fetch call to simulate failure + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false })) + + const req = new Request('http://localhost', { method: 'POST' }) + const res = await POST(req as any, { params: { runId } }) + + // Check status + expect(res.status).toBe(500) + + // Check JSON payload + const data = await res.json() + expect(data).toEqual({ error: 'Unable to post file' }) + }) + + it('should return 200 and success if the file exists and external API request succeeds', async () => { + // Mock the fetch call to simulate success + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: true })) + + const req = new Request('http://localhost', { method: 'POST' }) + const res = await POST(req as any, { params: { runId } }) + + // Check status + expect(res.status).toBe(200) + + // Check JSON payload + const data = await res.json() + expect(data).toEqual({ success: true }) + }) +}) diff --git a/src/app/api/run/[runId]/approve/route.ts b/src/app/api/run/[runId]/approve/route.ts index 569108c..5b1e262 100644 --- a/src/app/api/run/[runId]/approve/route.ts +++ b/src/app/api/run/[runId]/approve/route.ts @@ -3,11 +3,6 @@ import { deleteFile, generateAuthorizationHeaders, UPLOAD_DIR } from '@/app/util import path from 'path' import fs from 'fs' -// TODO This component will do two things -// 1. Post the file to the management API -// 2. If the management call is successful, we will delete the file from our filesystem -// 2.a If unsuccessful - show appropriate error message? -// To be done in https://openstax.atlassian.net/browse/SHRMP-21 export const POST = async (req: NextRequest, { params }: { params: { runId: string } }) => { const runId = params.runId diff --git a/src/app/api/run/[runId]/upload/route.test.ts b/src/app/api/run/[runId]/upload/route.test.ts index 3c4cf20..81514e8 100644 --- a/src/app/api/run/[runId]/upload/route.test.ts +++ b/src/app/api/run/[runId]/upload/route.test.ts @@ -1,34 +1,24 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { POST } from '@/app/api/run/[runId]/upload/route' +import { describe, expect, it, beforeEach } from 'vitest' +import { POST } from './route' import { NextRequest } from 'next/server' import path from 'path' import fs from 'fs' -import { rm } from 'fs/promises' -import { UPLOAD_DIR } from '@/app/utils' // for cleanup +import { UPLOAD_DIR } from '@/app/utils' +import mockFs from 'mock-fs' +import { v4 as uuidv4 } from 'uuid' -// Ensure the upload directory exists beforeEach(() => { - if (!fs.existsSync(UPLOAD_DIR)) { - fs.mkdirSync(UPLOAD_DIR, { recursive: true }) - } -}) - -// Clean up after each test -afterEach(async () => { - await rm(UPLOAD_DIR, { recursive: true, force: true }) + fs.rmSync(UPLOAD_DIR, { recursive: true, force: true }) }) describe('POST /api/run/[runId]/upload', () => { it('should upload a file successfully', async () => { - // Mock a CSV file as a Blob const mockFile = new Blob(['id,name\n1,John'], { type: 'text/csv' }) - const mockRunId = '123' + const mockRunId = uuidv4() - // Create a FormData object with the mock file const formData = new FormData() formData.append('file', new File([mockFile], mockRunId)) - // Mock the NextRequest const req = { formData: async () => formData, } as NextRequest @@ -48,15 +38,84 @@ describe('POST /api/run/[runId]/upload', () => { }) it('should return failure if no file is uploaded', async () => { - // Empty FormData object const formData = new FormData() // Mock the NextRequest const req = { formData: async () => formData, } as NextRequest - const params = { runId: '123' } + const params = { runId: uuidv4() } + const response = await POST(req, { params }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Form data does not include expected file key') + }) + + it('should return failure if unexpected form data is included', async () => { + const mockFile = new Blob(['id,name\n1,John'], { type: 'text/csv' }) + const mockRunId = uuidv4() + + const formData = new FormData() + formData.append('file', new File([mockFile], mockRunId)) + formData.append('file2', new File([mockFile], mockRunId)) + + const req = { + formData: async () => formData, + } as NextRequest + const params = { runId: uuidv4() } + const response = await POST(req, { params }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Form data includes unexpected data keys') + }) + + it('should return failure if runId is not a UUID', async () => { + const mockRunId = '123' + + const formData = new FormData() + + const req = { + formData: async () => formData, + } as NextRequest + + const params = { runId: mockRunId } + + const response = await POST(req, { params }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('runId is not a UUID') + }) + + it('should return an error if no runID is provided', async () => { + const formData = new FormData() + + // Mock the NextRequest + const req = { + formData: async () => formData, + } as NextRequest + const params = {} + // @ts-ignore + const response = await POST(req, { params }) + expect(response.status).toBe(400) + }) + + it('should return an error if the runID has results already', async () => { + const mockRunId = uuidv4() + + mockFs({ + [UPLOAD_DIR]: { + [mockRunId]: '', + }, + }) + + const formData = new FormData() + formData.append('file', '') + + const req = { + formData: async () => formData, + } as NextRequest + + const params = { runId: mockRunId } + const response = await POST(req, { params }) expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Data already exists for runId') }) }) diff --git a/src/app/api/run/[runId]/upload/route.ts b/src/app/api/run/[runId]/upload/route.ts index 74b5458..d13d3bb 100644 --- a/src/app/api/run/[runId]/upload/route.ts +++ b/src/app/api/run/[runId]/upload/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import { saveFile } from '@/app/utils' +import { saveFile, UPLOAD_DIR, isValidUUID } from '@/app/utils' +import path from 'path' +import fs from 'fs' function isFile(obj: any): obj is File { return obj instanceof File @@ -14,6 +16,23 @@ export const POST = async (req: NextRequest, { params }: { params: { runId: stri return NextResponse.json({ error: 'Missing runId' }, { status: 400 }) } + if (!isValidUUID(runId)) { + return NextResponse.json({ error: 'runId is not a UUID' }, { status: 400 }) + } + + if (!('file' in body)) { + return NextResponse.json({ error: 'Form data does not include expected file key' }, { status: 400 }) + } + + if (Object.keys(body).length !== 1) { + return NextResponse.json({ error: 'Form data includes unexpected data keys' }, { status: 400 }) + } + + const filePath = path.join(UPLOAD_DIR, runId) + if (fs.existsSync(filePath)) { + return NextResponse.json({ error: 'Data already exists for runId' }, { status: 400 }) + } + if ('file' in body && isFile(body.file)) { await saveFile(body.file, runId) return NextResponse.json({}, { status: 200 }) diff --git a/src/app/api/run/results/route.test.ts b/src/app/api/run/results/route.test.ts new file mode 100644 index 0000000..f44652a --- /dev/null +++ b/src/app/api/run/results/route.test.ts @@ -0,0 +1,42 @@ +import { GET } from './route' +import { beforeEach, describe, expect, it } from 'vitest' +import { UPLOAD_DIR } from '@/app/utils' +import mockFs from 'mock-fs' + +describe('GET /api/run/results', () => { + const mockFileContent = 'header1,header2\nvalue1,value2' + const mockFileName = 'test.csv' + + // Create a temporary test directory and file before each test + beforeEach(() => { + // Use mockFs to create the file in the mocked directory + mockFs({ + [UPLOAD_DIR]: { + [mockFileName]: mockFileContent, // Mock the file directly + }, + }) + }) + + it('should return parsed CSV data when files exist', async () => { + const response = GET() + const data = await response.json() + expect(response.status).toBe(200) + expect(data.runs).toEqual({ + [mockFileName]: [{ header1: 'value1', header2: 'value2' }], + }) + }) + + it('should return empty runs when the directory does not exist', async () => { + // Simulate the directory not existing by removing it from mockFs + mockFs({ + [UPLOAD_DIR]: {}, + }) + + const response = GET() + const data = await response.json() + + // Verify the response is an empty object + expect(response.status).toBe(200) + expect(data.runs).toEqual({}) + }) +}) diff --git a/src/app/api/run/results/route.ts b/src/app/api/run/results/route.ts index 1d01de3..c05e503 100644 --- a/src/app/api/run/results/route.ts +++ b/src/app/api/run/results/route.ts @@ -8,25 +8,21 @@ import { parse } from 'csv-parse/sync' export const revalidate = 0 export function GET() { - try { - const runs: Record = {} + const runs: Record = {} - if (fs.existsSync(UPLOAD_DIR)) { - const files = fs.readdirSync(UPLOAD_DIR) + if (fs.existsSync(UPLOAD_DIR)) { + const files = fs.readdirSync(UPLOAD_DIR) - for (const file of files) { - const filePath = path.join(UPLOAD_DIR, file) - const fileContent = fs.readFileSync(filePath, 'utf-8') + for (const file of files) { + const filePath = path.join(UPLOAD_DIR, file) + const fileContent = fs.readFileSync(filePath, 'utf-8') - runs[file] = parse(fileContent, { - columns: true, - skip_empty_lines: true, - }) - } + runs[file] = parse(fileContent, { + columns: true, + skip_empty_lines: true, + }) } - - return NextResponse.json({ runs: runs }) - } catch (error) { - return NextResponse.json({ error: 'Unable to read files' }, { status: 500 }) } + + return NextResponse.json({ runs: runs }) } diff --git a/src/app/api/runs/route.test.ts b/src/app/api/runs/route.test.ts index 7fe956c..049eb2f 100644 --- a/src/app/api/runs/route.test.ts +++ b/src/app/api/runs/route.test.ts @@ -1,39 +1,24 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import fs from 'fs' +import { beforeEach, describe, expect, it } from 'vitest' import path from 'path' -import { UPLOAD_DIR } from '@/app/utils' import { GET } from '@/app/api/runs/route' +import { UPLOAD_DIR } from '@/app/utils' +import mockFs from 'mock-fs' describe('GET /api/runs', () => { const testFiles = ['1', '2', '3', 'empty'] - // Create test files before each test beforeEach(() => { - // Ensure the upload directory exists - if (!fs.existsSync(UPLOAD_DIR)) { - fs.mkdirSync(UPLOAD_DIR, { recursive: true }) - } - - // Write test files to the upload directory + // Mock the file system with test files in the UPLOAD_DIR + const mockUploadDir: Record = {} testFiles.forEach((file) => { const filePath = path.join(UPLOAD_DIR, file) - fs.writeFileSync(filePath, '') + mockUploadDir[filePath] = '' // Mock an empty file }) - }) - // Clean up the test files after each test - afterEach(() => { - testFiles.forEach((file) => { - const filePath = path.join(UPLOAD_DIR, file) - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) - } + // Use mockFs to simulate the file system + mockFs({ + [UPLOAD_DIR]: mockUploadDir, }) - - // Optionally, remove the directory if it's empty - if (fs.existsSync(UPLOAD_DIR) && fs.readdirSync(UPLOAD_DIR).length === 0) { - fs.rmdirSync(UPLOAD_DIR) - } }) it('should return a list of run IDs from CSV files', async () => { @@ -42,15 +27,15 @@ describe('GET /api/runs', () => { expect(response.status).toBe(200) const json = await response.json() - // Only the .csv files should be listed, excluding non-CSV files expect(json).toEqual({ runs: [{ runId: '1' }, { runId: '2' }, { runId: '3' }, { runId: 'empty' }], }) }) it('should return an empty array if the directory doesnt exist', async () => { - // Remove the upload directory to simulate an error - fs.rmSync(UPLOAD_DIR, { recursive: true }) + // Simulate the absence of the directory by removing it from mockFs + mockFs({}) + const response = GET() expect(response.status).toBe(200) diff --git a/src/app/api/runs/route.ts b/src/app/api/runs/route.ts index 41c6a78..68e0fc2 100644 --- a/src/app/api/runs/route.ts +++ b/src/app/api/runs/route.ts @@ -11,18 +11,13 @@ interface Run { } export function GET() { - try { - let runs: Run[] = [] + let runs: Run[] = [] - if (fs.existsSync(UPLOAD_DIR)) { - const files = fs.readdirSync(UPLOAD_DIR) - runs = files.map((file) => { - return { runId: path.basename(file) } - }) - } - return NextResponse.json({ runs }) - } catch (error) { - // Handle error (e.g., directory not found, permission error, etc.) - return NextResponse.json({ error: 'Unable to read files' }, { status: 500 }) + if (fs.existsSync(UPLOAD_DIR)) { + const files = fs.readdirSync(UPLOAD_DIR) + runs = files.map((file) => { + return { runId: path.basename(file) } + }) } + return NextResponse.json({ runs }) } diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx deleted file mode 100644 index 90d3814..0000000 --- a/src/app/layout.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, describe, it } from 'vitest' -import { render, within } from '@testing-library/react' -import Layout from './layout' - -describe('Main Layout', () => { - it('redirects when not signed in', async () => { - const { getByText } = render( - -
Hello World
-
, - { container: document }, - ) - const main = within(getByText('Hello World')) - expect(main).toBeDefined() - }) -}) diff --git a/src/app/page.css.ts b/src/app/page.css.ts deleted file mode 100644 index a9c8f1d..0000000 --- a/src/app/page.css.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { style } from '@vanilla-extract/css' - -export const pageStyles = style({ - display: 'grid', - gridTemplateRows: '1fr 20px', - alignItems: 'center', - justifyItems: 'center', - minHeight: '100svh', - padding: 80, - gap: 64, - fontSynthesis: 'none', -}) - -export const mainStyles = style({ - display: 'flex', - flexDirection: 'column', - fontSize: 20, - fontWeight: 'bold', - gap: 32, -}) - -export const footerStyles = style({ - fontSize: '80%', - fontStyle: 'oblique', -}) diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx deleted file mode 100644 index 3b2f8f7..0000000 --- a/src/app/page.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, it } from 'vitest' - -describe('Main Page', () => { - it('redirects when not signed in', async () => { - // TODO Write test for this at some point - // Instead of redirecting, we use basic auth which just - // has the built in popup - }) -}) diff --git a/src/app/page.tsx b/src/app/page.tsx index 818f152..7cf365f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,8 @@ 'use client' import { FC } from 'react' -import { Alert, Button, Flex, LoadingOverlay, Modal, Paper, ScrollArea, Title } from '@mantine/core' +import { Alert, Button, LoadingOverlay, Modal, Paper, ScrollArea, Stack, Title } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { footerStyles, mainStyles, pageStyles } from './page.css' import { DataTable } from 'mantine-datatable' import { CSVRecord, useApproveRun, useRunResults } from '@/app/requests' @@ -18,51 +17,52 @@ export default function Home() { return } + if (Object.entries(runs).length === 0) { + return ( + + No Study result is available at this time. + + ) + } + return ( -
-
- {Object.entries(runs).length == 0 ? ( - - No Study result is available at this time. - - ) : ( - - - Run Results - + + + + + Run Results + - - { - return { - fileName: fileName, - } - })} - columns={[ - { accessor: 'fileName', title: '', textAlign: 'right' }, - { - accessor: 'results', - title: '', - render: (item) => ( - - ), - }, - { - accessor: 'approve', - title: '', - render: (item) => , - }, - ]} - /> - - - )} -
-
A SafeInsights production
-
+ + { + return { + fileName: fileName, + } + })} + columns={[ + { accessor: 'fileName', title: '', textAlign: 'right' }, + { + accessor: 'results', + title: '', + render: (item) => ( + + ), + }, + { + accessor: 'approve', + title: '', + render: (item) => , + }, + ]} + /> + + + + ) } diff --git a/src/app/requests.ts b/src/app/requests.ts index 1fe2a92..1bd2548 100644 --- a/src/app/requests.ts +++ b/src/app/requests.ts @@ -49,7 +49,7 @@ export const useApproveRun = () => { notifications.show({ color: 'red', title: 'Study Run Approval Failed', - message: `An error occured while approving the study run. ${error.message}. Please retry later.`, + message: `An error occurred while approving the study run. ${error.message}. Please retry later.`, autoClose: 5_000, position: 'top-right', }) diff --git a/src/app/utils.test.ts b/src/app/utils.test.ts new file mode 100644 index 0000000..1ceca3d --- /dev/null +++ b/src/app/utils.test.ts @@ -0,0 +1,110 @@ +import { + createUploadDirIfNotExists, + deleteFile, + generateAuthorizationHeaders, + isValidUUID, + saveFile, + UPLOAD_DIR, +} from './utils' +import mockFs from 'mock-fs' +import path from 'path' +import jwt from 'jsonwebtoken' +import { describe, expect, it, vi } from 'vitest' +import fs from 'fs' +import { v4 } from 'uuid' + +describe('Utils', () => { + describe('createUploadDirIfNotExists', () => { + it('should create the upload directory if it does not exist', async () => { + // Ensure the directory does not exist + mockFs({}) + expect(() => fs.statSync(UPLOAD_DIR)).toThrow() + + createUploadDirIfNotExists() + + // Verify the directory was created + expect(fs.statSync(UPLOAD_DIR).isDirectory()).toBe(true) + }) + }) + + describe('saveFile', () => { + it('should save the file to the correct path', async () => { + const file = new Blob(['test file content'], { type: 'text/plain' }) + const runId = v4() + + await saveFile(file, runId) + + // Verify the file is saved at the expected path + const filePath = path.resolve(UPLOAD_DIR, runId) + expect(fs.existsSync(filePath)).toBe(true) + + // Verify the content of the file + const content = fs.readFileSync(filePath, 'utf8') + expect(content).toBe('test file content') + }) + }) + + describe('deleteFile', () => { + it('should delete the file from the correct path', async () => { + const mockFileContent = 'header1,header2\nvalue1,value2' + const mockFileName = 'test.csv' + + // Mock the file system with the necessary file + mockFs({ + [UPLOAD_DIR]: { + [mockFileName]: mockFileContent, + }, + }) + + // Verify the file exists + const filePath = path.resolve(UPLOAD_DIR, mockFileName) + expect(fs.existsSync(filePath)).toBe(true) + + // Now delete the file + await deleteFile(mockFileName) + + // Verify the file was deleted + expect(fs.existsSync(filePath)).toBe(false) + }) + }) + + describe('generateAuthorizationHeaders', () => { + it('should generate a valid Authorization header with JWT', () => { + const privateKey = 'private-key' + const memberId = 'member-id' + process.env.MANAGEMENT_APP_PRIVATE_KEY = privateKey + process.env.MANAGEMENT_APP_MEMBER_ID = memberId + + // @ts-ignore + const tokenSpy = vi.spyOn(jwt, 'sign').mockReturnValueOnce('mock-jwt-token') + + const headers = generateAuthorizationHeaders() + + expect(headers).toEqual({ + Authorization: 'Bearer mock-jwt-token', + }) + expect(tokenSpy).toHaveBeenCalledWith({ iss: memberId }, privateKey, { algorithm: 'RS256' }) + }) + + it('should return an empty Authorization header if privateKey or memberId is missing', () => { + delete process.env.MANAGEMENT_APP_PRIVATE_KEY + delete process.env.MANAGEMENT_APP_MEMBER_ID + + const headers = generateAuthorizationHeaders() + + expect(headers).toEqual({ + Authorization: 'Bearer ', + }) + }) + }) + + describe('isValidUUID', () => { + it('should return false on an invalid UUID', () => { + expect(isValidUUID('123')).toBe(false) + }) + + it('should return true on a valid UUID', () => { + expect(isValidUUID('a9bcdef7-575e-4083-8fbf-e1a743f29f24')).toBe(true) + }) + }) +}) diff --git a/src/app/utils.ts b/src/app/utils.ts index f2570c8..59050f2 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -2,34 +2,31 @@ import path from 'path' import fs from 'fs' import os from 'os' import jwt from 'jsonwebtoken' +import { validate as uuidValidate } from 'uuid' export const UPLOAD_DIR = path.resolve(os.tmpdir(), 'public/uploads') -export const createUploadDirIfNotExists = async () => { +export const createUploadDirIfNotExists = () => { if (!fs.existsSync(UPLOAD_DIR)) { fs.mkdirSync(UPLOAD_DIR, { recursive: true }) } } export const saveFile = async (file: Blob, runId: string) => { - await createUploadDirIfNotExists() + createUploadDirIfNotExists() const buffer = Buffer.from(await file.arrayBuffer()) - try { - fs.writeFileSync(path.resolve(UPLOAD_DIR, runId), buffer) - } catch (err) { - console.error('Error writing file:', err) - } + fs.writeFileSync(path.resolve(UPLOAD_DIR, runId), buffer) } export const deleteFile = async (runId: string) => { - await createUploadDirIfNotExists() + createUploadDirIfNotExists() fs.unlinkSync(path.resolve(UPLOAD_DIR, runId)) } export const generateAuthorizationHeaders = () => { // Generate JWT token - const privateKey: string | undefined = process.env.MANAGEMENT_APP_PRIVATE_KEY - const memberId: string | undefined = process.env.MANAGEMENT_APP_MEMBER_ID + const privateKey = process.env.MANAGEMENT_APP_PRIVATE_KEY + const memberId = process.env.MANAGEMENT_APP_MEMBER_ID let token = '' if (privateKey && memberId) { token = jwt.sign( @@ -44,3 +41,7 @@ export const generateAuthorizationHeaders = () => { Authorization: `Bearer ${token}`, } } + +export const isValidUUID = (value: string): boolean => { + return uuidValidate(value) +} diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 07b5db0..5fb152f 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -1,4 +1,4 @@ -import { Group, AppShell, AppShellHeader, AppShellMain } from '@mantine/core' +import { Group, AppShell, AppShellHeader, AppShellMain, AppShellFooter } from '@mantine/core' import { SafeInsightsLogo } from './si-logo' import Link from 'next/link' import { Notifications } from '@mantine/notifications' @@ -23,6 +23,8 @@ export function AppLayout({ children }: Props) { {children} + + A SafeInsights production ) } diff --git a/src/components/providers.test.tsx b/src/components/providers.test.tsx new file mode 100644 index 0000000..4986c0d --- /dev/null +++ b/src/components/providers.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/react' +import { getQueryClient, Providers } from './providers' +import { QueryClient } from '@tanstack/react-query' + +// Create a mock for isServer +const mockIsServer = vi.fn(() => false) + +// Temporarily replace the isServer implementation +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + // @ts-ignore + ...actual, + isServer: () => mockIsServer(), + } +}) + +// Mock Mantine and Modals providers to avoid rendering actual components +vi.mock('@mantine/core', () => ({ + MantineProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@mantine/modals', () => ({ + ModalsProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +describe('Providers', () => { + // Reset the mock before each test + beforeEach(() => { + mockIsServer.mockReset() + mockIsServer.mockReturnValue(false) // Default to false + }) + + describe('getQueryClient', () => { + it('returns a new QueryClient when on server', () => { + // Set isServer to true for this test + mockIsServer.mockReturnValue(true) + + const queryClient1 = getQueryClient() + const queryClient2 = getQueryClient() + + expect(queryClient1).toBeInstanceOf(QueryClient) + expect(queryClient2).toBeInstanceOf(QueryClient) + + // Ensure different instances are created on server + expect(queryClient1).not.toEqual(queryClient2) + }) + + it('returns the same QueryClient instance when in browser', () => { + // Ensure isServer is false + mockIsServer.mockReturnValue(false) + + const queryClient1 = getQueryClient() + const queryClient2 = getQueryClient() + + // Should return the same instance + expect(queryClient1).toEqual(queryClient2) + }) + }) + + describe('Providers component', () => { + it('renders children within all providers', () => { + const TestChild = () =>
Test Content
+ + const { getByTestId, container } = render( + + + , + ) + + // Check if child is rendered + const child = getByTestId('test-child') + expect(child).toBeTruthy() + + // Verify provider hierarchy + const mantineProvider = container.querySelector('[data-testid="mantine-provider"]') + const modalsProvider = container.querySelector('[data-testid="modals-provider"]') + + expect(mantineProvider).toBeTruthy() + expect(modalsProvider).toBeTruthy() + + // Ensure child is within the providers + expect(modalsProvider?.textContent).toContain('Test Content') + }) + + it('passes children correctly through provider layers', () => { + const TestChild = () =>
Nested Content
+ + const { container } = render( + + + , + ) + + // Verify the content is present in the final rendered output + const childContent = container.textContent + expect(childContent).toContain('Nested Content') + }) + }) +}) diff --git a/src/components/providers.tsx b/src/components/providers.tsx index dc19447..822da18 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -3,8 +3,8 @@ import { MantineProvider } from '@mantine/core' import { theme } from '@/theme' import { ModalsProvider } from '@mantine/modals' -import { isServer, QueryClient } from '@tanstack/query-core' -import { QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider, QueryClient, isServer } from '@tanstack/react-query' +import { FC } from 'react' // if using Clerk for authentication: // * add the Clerk keys to the .env file, @@ -16,34 +16,30 @@ type Props = { children: React.ReactNode } -function makeQueryClient() { +export function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { - // With SSR, we usually want to set some default staleTime - // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, }, }, }) } -let browserQueryClient: QueryClient | undefined = undefined +let browserQueryClient: QueryClient -function getQueryClient() { +export function getQueryClient() { if (isServer) { - // Server: always make a new query client return makeQueryClient() } else { - // Browser: make a new query client if we don't already have one - // This is very important, so we don't re-make a new client if React - // suspends during the initial render. This may not be needed if we - // have a suspense boundary BELOW the creation of the query client - if (!browserQueryClient) browserQueryClient = makeQueryClient() + if (!browserQueryClient) { + browserQueryClient = makeQueryClient() + } return browserQueryClient } } -export const Providers: React.FC = ({ children }) => { + +export const Providers: FC = ({ children }) => { const queryClient = getQueryClient() return ( diff --git a/src/components/si-logo.test.tsx b/src/components/si-logo.test.tsx new file mode 100644 index 0000000..c57cbbe --- /dev/null +++ b/src/components/si-logo.test.tsx @@ -0,0 +1,23 @@ +import { render } from '@testing-library/react' +import { SafeInsightsLogo } from './si-logo' +import { describe, it, expect } from 'vitest' + +describe('SafeInsightsLogo', () => { + it('renders correctly', () => { + const { container } = render() + const svgElement = container.querySelector('svg') + expect(svgElement).not.toBeNull() // Ensure the SVG element exists + }) + + it('accepts and applies custom props', () => { + const { container } = render() + const svgElement = container.querySelector('svg') + expect(svgElement).not.toBeNull() // Ensure the SVG element exists + + // Check the custom props + if (svgElement) { + expect(svgElement.getAttribute('data-testid')).toBe('custom-logo') + expect(svgElement.classList.contains('test-class')).toBe(true) + } + }) +}) diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 0000000..c311e09 --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest, NextResponse } from 'next/server' +import { config, middleware } from '@/middleware' + +describe('Middleware', () => { + // Store original environment variables + const originalEnv = { ...process.env } + + beforeEach(() => { + // Reset environment variables before each test + process.env = { ...originalEnv } + + // Reset any mocks + vi.resetAllMocks() + }) + + describe('Authentication Scenarios', () => { + it('returns next response for valid authentication', () => { + // Set specific credentials + process.env.HTTP_BASIC_AUTH = 'testuser:testpass' + + const req = { + headers: { + get: vi.fn().mockReturnValue('Basic ' + Buffer.from('testuser:testpass').toString('base64')), + }, + } as unknown as NextRequest + + const response = middleware(req) + + // Important: Check that it returns NextResponse.next() + expect(response).toBeTruthy() + }) + + it('returns 401 response for missing authorization header', () => { + const req = { + headers: { + get: vi.fn().mockReturnValue(null), + }, + } as unknown as NextRequest + + const response = middleware(req) + + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBe('Basic') + }) + + it('returns 401 response for malformed authorization header', () => { + const req = { + headers: { + get: vi.fn().mockReturnValue('Basic malformed'), + }, + } as unknown as NextRequest + + const response = middleware(req) + + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBe('Basic') + }) + + it('returns 401 response with incorrect credentials', () => { + // Set specific credentials + process.env.HTTP_BASIC_AUTH = 'testuser:testpass' + + const req = { + headers: { + get: vi.fn().mockReturnValue('Basic ' + Buffer.from('wronguser:wrongpass').toString('base64')), + }, + } as unknown as NextRequest + + const response = middleware(req) + + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBe('Basic') + }) + + it('uses default credentials when HTTP_BASIC_AUTH is not set', () => { + // Unset the environment variable to use default + delete process.env.HTTP_BASIC_AUTH + + const req = { + headers: { + get: vi.fn().mockReturnValue('Basic ' + Buffer.from('admin:password').toString('base64')), + }, + } as unknown as NextRequest + + const response = middleware(req) + + expect(response).toEqual(NextResponse.next()) + }) + }) + + describe('Configuration', () => { + it('has a matcher that excludes specific routes', () => { + expect(config.matcher).toBe('/((?!favicon.ico|api/health).*)') + }) + }) +}) diff --git a/src/middleware.ts b/src/middleware.ts index 1325bf7..a388d03 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,7 +4,7 @@ import type { NextRequest } from 'next/server' const [AUTH_USER, AUTH_PASS] = (process.env.HTTP_BASIC_AUTH || 'admin:password').split(':') function isAuthenticated(req: NextRequest) { - const authHeader = req.headers.get('authorization') || req.headers.get('Authorization') + const authHeader = req.headers.get('authorization') if (!authHeader) { return false @@ -14,26 +14,23 @@ function isAuthenticated(req: NextRequest) { const user = auth[0] const pass = auth[1] - if (user == AUTH_USER && pass == AUTH_PASS) { - return true - } else { - return false - } + return user === AUTH_USER && pass === AUTH_PASS } -// This function can be marked `async` if using `await` inside export function middleware(request: NextRequest) { if (!isAuthenticated(request)) { - return new NextResponse('Authentication required', { - status: 401, - headers: { 'WWW-Authenticate': 'Basic' }, - }) + return NextResponse.json( + { message: 'Authentication required' }, + { + status: 401, + headers: { 'WWW-Authenticate': 'Basic' }, + }, + ) } return NextResponse.next() } -// See "Matching Paths" below to learn more export const config = { matcher: '/((?!favicon.ico|api/health).*)', } diff --git a/src/theme.test.ts b/src/theme.test.ts new file mode 100644 index 0000000..9abf829 --- /dev/null +++ b/src/theme.test.ts @@ -0,0 +1,8 @@ +import { theme } from './theme' +import { describe, it, expect } from 'vitest' + +describe('theme', () => { + it('should define a Mantine theme', () => { + expect(theme).toBeDefined() + }) +}) diff --git a/test-utils/index.ts b/test-utils/index.ts new file mode 100644 index 0000000..05e8625 --- /dev/null +++ b/test-utils/index.ts @@ -0,0 +1,2 @@ +export * from '@testing-library/react' +export { render } from './render' diff --git a/test-utils/render.tsx b/test-utils/render.tsx new file mode 100644 index 0000000..3b7b101 --- /dev/null +++ b/test-utils/render.tsx @@ -0,0 +1,12 @@ +import { render as testingLibraryRender } from '@testing-library/react' +import { MantineProvider } from '@mantine/core' +import { theme } from '@/theme' +import React from 'react' + +export function render(ui: React.ReactNode) { + return testingLibraryRender(<>{ui}, { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }) +} diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 1f21a40..bec4ba5 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -1,4 +1,21 @@ -import { beforeAll, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, vi } from 'vitest' +import { UPLOAD_DIR } from '@/app/utils' +import mockFs from 'mock-fs' + +const OLD_ENV = process.env + beforeAll(() => { vi.mock('next/navigation', () => require('next-router-mock')) }) + +beforeEach(() => { + mockFs({ + [UPLOAD_DIR]: {}, + }) + process.env = { ...OLD_ENV } // Make a copy +}) + +afterEach(() => { + mockFs.restore() + process.env = OLD_ENV // Restore old environment +}) diff --git a/tsconfig.json b/tsconfig.json index 83d7867..a6d741d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,8 +19,10 @@ } ], "paths": { - "@/*": ["./src/*"] - } + "@/*": ["./src/*"], + "@/test-utils": ["./test-utils"] + }, + "types": ["vitest/globals"] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "vitest.config.mjs"], "exclude": ["node_modules"] diff --git a/vitest.config.mjs b/vitest.config.mjs index a8a65aa..4ae6b2b 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -3,19 +3,24 @@ import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin' -// https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), tsconfigPaths(), vanillaExtractPlugin()], test: { + setupFiles: ['./tests/vitest.setup.ts'], mockReset: true, environment: 'happy-dom', include: ['src/**/*.(test).{js,jsx,ts,tsx}'], + exclude: ['src/components/providers.test.tsx'], coverage: { enabled: true, - // skipFull: true, - // FIXME: In the future, when we're ready, we should re-enable this threshold check + // Eventually, after discussion, add some global rules // thresholds: { 100: true }, + thresholds: { + lines: true, + }, include: ['src/**/*.{js,jsx,ts,tsx}'], + exclude: ['src/components/providers.tsx'], + reportOnFailure: true, }, }, })