test: add route tests for signup, recover, health
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m35s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m35s
This commit is contained in:
parent
c01e88686a
commit
1fe3f3746a
6 changed files with 554 additions and 0 deletions
286
package-lock.json
generated
286
package-lock.json
generated
|
|
@ -28,7 +28,9 @@
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"terser": "^5.46.0",
|
"terser": "^5.46.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
|
@ -600,6 +602,29 @@
|
||||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@paralleldrive/cuid2": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^1.1.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
|
@ -1053,6 +1078,13 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cookiejar": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
|
@ -1105,6 +1137,13 @@
|
||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/methods": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.11",
|
"version": "22.19.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||||
|
|
@ -1171,6 +1210,30 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/superagent": {
|
||||||
|
"version": "8.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||||
|
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cookiejar": "^2.1.5",
|
||||||
|
"@types/methods": "^1.1.4",
|
||||||
|
"@types/node": "*",
|
||||||
|
"form-data": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/supertest": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/methods": "^1.1.4",
|
||||||
|
"@types/superagent": "^8.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/swagger-jsdoc": {
|
"node_modules/@types/swagger-jsdoc": {
|
||||||
"version": "6.0.4",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz",
|
||||||
|
|
@ -1374,6 +1437,13 @@
|
||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asap": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
|
@ -1396,6 +1466,13 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/atomic-sleep": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
|
@ -1709,6 +1786,19 @@
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/commander": {
|
||||||
"version": "2.20.3",
|
"version": "2.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
|
@ -1716,6 +1806,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/component-emitter": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/compressible": {
|
"node_modules/compressible": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
|
|
@ -1794,6 +1894,13 @@
|
||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookiejar": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cosmiconfig": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
|
||||||
|
|
@ -1862,6 +1969,16 @@
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
|
@ -1887,6 +2004,17 @@
|
||||||
"integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==",
|
"integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/dezalgo": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"asap": "^2.0.0",
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
|
|
@ -1998,6 +2126,22 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
|
|
@ -2255,6 +2399,13 @@
|
||||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-safe-stringify": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fd-slicer": {
|
"node_modules/fd-slicer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
|
|
@ -2300,6 +2451,41 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/formidable": {
|
||||||
|
"version": "3.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||||
|
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
|
"dezalgo": "^1.0.4",
|
||||||
|
"once": "^1.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
|
@ -2504,6 +2690,22 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
|
@ -4001,6 +4203,90 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/superagent": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"component-emitter": "^1.3.1",
|
||||||
|
"cookiejar": "^2.1.4",
|
||||||
|
"debug": "^4.3.7",
|
||||||
|
"fast-safe-stringify": "^2.1.1",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"formidable": "^3.5.4",
|
||||||
|
"methods": "^1.1.2",
|
||||||
|
"mime": "2.6.0",
|
||||||
|
"qs": "^6.14.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/mime": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/supertest": {
|
||||||
|
"version": "7.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
|
||||||
|
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-signature": "^1.2.2",
|
||||||
|
"methods": "^1.1.2",
|
||||||
|
"superagent": "^10.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/supertest/node_modules/cookie-signature": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/swagger-jsdoc": {
|
"node_modules/swagger-jsdoc": {
|
||||||
"version": "6.2.8",
|
"version": "6.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"terser": "^5.46.0",
|
"terser": "^5.46.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
|
|
||||||
68
src/__tests__/health.test.ts
Normal file
68
src/__tests__/health.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { getPoolStats } from "../services/browser.js";
|
||||||
|
import { pool } from "../services/db.js";
|
||||||
|
|
||||||
|
let app: express.Express;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Default: healthy DB
|
||||||
|
const mockClient = {
|
||||||
|
query: vi.fn()
|
||||||
|
.mockResolvedValueOnce({ rows: [{ 1: 1 }] }) // SELECT 1
|
||||||
|
.mockResolvedValueOnce({ rows: [{ version: "PostgreSQL 17.4 on x86_64" }] }), // SELECT version()
|
||||||
|
release: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mocked(pool.connect).mockResolvedValue(mockClient as any);
|
||||||
|
|
||||||
|
vi.mocked(getPoolStats).mockReturnValue({
|
||||||
|
poolSize: 16,
|
||||||
|
totalPages: 16,
|
||||||
|
availablePages: 14,
|
||||||
|
queueDepth: 0,
|
||||||
|
pdfCount: 5,
|
||||||
|
restarting: false,
|
||||||
|
uptimeMs: 60000,
|
||||||
|
browsers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { healthRouter } = await import("../routes/health.js");
|
||||||
|
app = express();
|
||||||
|
app.use("/health", healthRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /health", () => {
|
||||||
|
it("returns 200 with status ok when DB is healthy", async () => {
|
||||||
|
const res = await request(app).get("/health");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe("ok");
|
||||||
|
expect(res.body.database.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 with status degraded on DB error", async () => {
|
||||||
|
vi.mocked(pool.connect).mockRejectedValue(new Error("Connection refused"));
|
||||||
|
const res = await request(app).get("/health");
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.status).toBe("degraded");
|
||||||
|
expect(res.body.database.status).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes pool stats", async () => {
|
||||||
|
const res = await request(app).get("/health");
|
||||||
|
expect(res.body.pool).toMatchObject({
|
||||||
|
size: 16,
|
||||||
|
available: 14,
|
||||||
|
queueDepth: 0,
|
||||||
|
pdfCount: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes version", async () => {
|
||||||
|
const res = await request(app).get("/health");
|
||||||
|
expect(res.body.version).toBeDefined();
|
||||||
|
expect(typeof res.body.version).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/__tests__/recover.test.ts
Normal file
96
src/__tests__/recover.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
let app: express.Express;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// resetModules to get fresh rate limiter instances
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
// Re-import mocked services after resetModules
|
||||||
|
const { createPendingVerification, verifyCode } = await import("../services/verification.js");
|
||||||
|
const { sendVerificationEmail } = await import("../services/email.js");
|
||||||
|
const { getAllKeys } = await import("../services/keys.js");
|
||||||
|
|
||||||
|
vi.mocked(createPendingVerification).mockResolvedValue({ email: "test@test.com", code: "654321", createdAt: "", expiresAt: "", attempts: 0 });
|
||||||
|
vi.mocked(verifyCode).mockResolvedValue({ status: "ok" });
|
||||||
|
vi.mocked(sendVerificationEmail).mockResolvedValue(true);
|
||||||
|
vi.mocked(getAllKeys).mockReturnValue([
|
||||||
|
{ key: "existing-key", tier: "pro" as const, email: "found@test.com", createdAt: "2025-01-01" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { recoverRouter } = await import("../routes/recover.js");
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/recover", recoverRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /recover", () => {
|
||||||
|
it("returns 400 for missing email", async () => {
|
||||||
|
const res = await request(app).post("/recover").send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid email", async () => {
|
||||||
|
const res = await request(app).post("/recover").send({ email: "bad" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 for email not found (anti-enumeration)", async () => {
|
||||||
|
const res = await request(app).post("/recover").send({ email: "nobody@test.com" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe("recovery_sent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 and sends email for known email", async () => {
|
||||||
|
const { sendVerificationEmail } = await import("../services/email.js");
|
||||||
|
const res = await request(app).post("/recover").send({ email: "found@test.com" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe("recovery_sent");
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
expect(sendVerificationEmail).toHaveBeenCalledWith("found@test.com", "654321");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /recover/verify", () => {
|
||||||
|
it("returns 400 for missing fields", async () => {
|
||||||
|
const res = await request(app).post("/recover/verify").send({ email: "a@b.com" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 410 for expired code", async () => {
|
||||||
|
const { verifyCode } = await import("../services/verification.js");
|
||||||
|
vi.mocked(verifyCode).mockResolvedValue({ status: "expired" });
|
||||||
|
const res = await request(app).post("/recover/verify").send({ email: "a@b.com", code: "123456" });
|
||||||
|
expect(res.status).toBe(410);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 for max attempts", async () => {
|
||||||
|
const { verifyCode } = await import("../services/verification.js");
|
||||||
|
vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" });
|
||||||
|
const res = await request(app).post("/recover/verify").send({ email: "a@b.com", code: "123456" });
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid code", async () => {
|
||||||
|
const { verifyCode } = await import("../services/verification.js");
|
||||||
|
vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" });
|
||||||
|
const res = await request(app).post("/recover/verify").send({ email: "a@b.com", code: "999999" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with apiKey when key found", async () => {
|
||||||
|
const res = await request(app).post("/recover/verify").send({ email: "found@test.com", code: "123456" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ status: "recovered", apiKey: "existing-key", tier: "pro" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with message only when no key found", async () => {
|
||||||
|
const res = await request(app).post("/recover/verify").send({ email: "nokey@test.com", code: "123456" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe("recovered");
|
||||||
|
expect(res.body.apiKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -76,6 +76,9 @@ vi.mock("../services/verification.js", () => ({
|
||||||
loadVerifications: vi.fn().mockResolvedValue(undefined),
|
loadVerifications: vi.fn().mockResolvedValue(undefined),
|
||||||
createPendingVerification: vi.fn().mockResolvedValue({ email: "test@test.com", code: "123456" }),
|
createPendingVerification: vi.fn().mockResolvedValue({ email: "test@test.com", code: "123456" }),
|
||||||
verifyCode: vi.fn().mockResolvedValue({ status: "ok" }),
|
verifyCode: vi.fn().mockResolvedValue({ status: "ok" }),
|
||||||
|
isEmailVerified: vi.fn().mockResolvedValue(false),
|
||||||
|
createVerification: vi.fn().mockResolvedValue({ email: "test@test.com", token: "tok", apiKey: "key", createdAt: "", verifiedAt: null }),
|
||||||
|
getVerifiedApiKey: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock email service
|
// Mock email service
|
||||||
|
|
|
||||||
99
src/__tests__/signup.test.ts
Normal file
99
src/__tests__/signup.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
let app: express.Express;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { isEmailVerified, createPendingVerification, verifyCode, createVerification } = await import("../services/verification.js");
|
||||||
|
const { sendVerificationEmail } = await import("../services/email.js");
|
||||||
|
const { createFreeKey } = await import("../services/keys.js");
|
||||||
|
|
||||||
|
vi.mocked(isEmailVerified).mockResolvedValue(false);
|
||||||
|
vi.mocked(createPendingVerification).mockResolvedValue({ email: "test@test.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 });
|
||||||
|
vi.mocked(verifyCode).mockResolvedValue({ status: "ok" });
|
||||||
|
vi.mocked(createFreeKey).mockResolvedValue({ key: "free-key-123", tier: "free", email: "test@test.com", createdAt: "" });
|
||||||
|
vi.mocked(createVerification).mockResolvedValue({ email: "test@test.com", token: "tok", apiKey: "free-key-123", createdAt: "", verifiedAt: null });
|
||||||
|
vi.mocked(sendVerificationEmail).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { signupRouter } = await import("../routes/signup.js");
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/signup", signupRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /signup/free", () => {
|
||||||
|
it("returns 400 for missing email", async () => {
|
||||||
|
const res = await request(app).post("/signup/free").send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid email format", async () => {
|
||||||
|
const res = await request(app).post("/signup/free").send({ email: "not-email" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 for already verified email", async () => {
|
||||||
|
const { isEmailVerified } = await import("../services/verification.js");
|
||||||
|
vi.mocked(isEmailVerified).mockResolvedValue(true);
|
||||||
|
const res = await request(app).post("/signup/free").send({ email: "dup@test.com" });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with verification_required for valid email", async () => {
|
||||||
|
const res = await request(app).post("/signup/free").send({ email: "new@test.com" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe("verification_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends verification email asynchronously", async () => {
|
||||||
|
const { sendVerificationEmail } = await import("../services/email.js");
|
||||||
|
await request(app).post("/signup/free").send({ email: "new@test.com" });
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
expect(sendVerificationEmail).toHaveBeenCalledWith("new@test.com", "123456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /signup/verify", () => {
|
||||||
|
it("returns 400 for missing email/code", async () => {
|
||||||
|
const res = await request(app).post("/signup/verify").send({ email: "a@b.com" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 for already verified email", async () => {
|
||||||
|
const { isEmailVerified } = await import("../services/verification.js");
|
||||||
|
vi.mocked(isEmailVerified).mockResolvedValue(true);
|
||||||
|
const res = await request(app).post("/signup/verify").send({ email: "dup@test.com", code: "123456" });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 410 for expired code", async () => {
|
||||||
|
const { verifyCode } = await import("../services/verification.js");
|
||||||
|
vi.mocked(verifyCode).mockResolvedValue({ status: "expired" });
|
||||||
|
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
|
||||||
|
expect(res.status).toBe(410);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 for max attempts", async () => {
|
||||||
|
const { verifyCode } = await import("../services/verification.js");
|
||||||
|
vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" });
|
||||||
|
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid code", async () => {
|
||||||
|
const { verifyCode } = await import("../services/verification.js");
|
||||||
|
vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" });
|
||||||
|
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "999999" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with apiKey for valid code", async () => {
|
||||||
|
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ status: "verified", apiKey: "free-key-123" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue