diff --git a/.gitignore b/.gitignore index f1b1d71..b6240af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules prod +coverage \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e69de29..2beb504 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": ["typescript"] +} diff --git a/dev.env b/dev.env new file mode 100644 index 0000000..88deb25 --- /dev/null +++ b/dev.env @@ -0,0 +1,3 @@ +DISCORD_TOKEN="op://Environment Variables - Development/Naomi Dev Bot/token" +DISCORD_DEBUG_WEBHOOK="op://Environment Variables - Development/Naomi Dev Bot/webhook" +MONGO_URI="op://Environment Variables - Development/Development Database/dev" \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 5fdbc56..c272049 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,4 +2,14 @@ import NaomisConfig from "@nhcarrigan/eslint-config"; export default [ ...NaomisConfig, + { + files: ["**/*.spec.ts"], + "rules": { + "max-nested-callbacks": "off", + "@typescript-eslint/consistent-type-assertions": "off", + "max-lines-per-function": "off", + "max-statements": "off", + "max-lines": "off" + } + } ]; \ No newline at end of file diff --git a/package.json b/package.json index f53e79c..491fe91 100644 --- a/package.json +++ b/package.json @@ -6,21 +6,32 @@ "type": "module", "scripts": { "build": "tsc", + "dev": "op run --env-file='./dev.env' --no-masking -- node prod/index.js", "format": "eslint src test --fix --max-warnings 0", "lint": "eslint src test --max-warnings 0", "start": "op run --env-file='./prod.env' -- node prod/index.js", - "test": "vitest run --coverage" + "test": "rm -rf prod && vitest run --coverage" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { + "@fastify/pre-commit": "2.1.0", "@nhcarrigan/eslint-config": "5.0.0-rc2", "@nhcarrigan/typescript-config": "4.0.0", "@types/node": "22.7.4", "@vitest/coverage-istanbul": "2.1.1", "eslint": "9.11.1", + "prisma": "5.20.0", "typescript": "5.6.2", "vitest": "2.1.1" - } + }, + "dependencies": { + "@prisma/client": "5.20.0", + "discord.js": "14.16.2" + }, + "pre-commit": [ + "lint", + "test" + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68ec531..a226845 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,17 @@ settings: importers: .: + dependencies: + '@prisma/client': + specifier: 5.20.0 + version: 5.20.0(prisma@5.20.0) + discord.js: + specifier: 14.16.2 + version: 14.16.2 devDependencies: + '@fastify/pre-commit': + specifier: 2.1.0 + version: 2.1.0 '@nhcarrigan/eslint-config': specifier: 5.0.0-rc2 version: 5.0.0-rc2(@typescript-eslint/utils@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint@9.11.1)(playwright@1.47.2)(prettier@3.3.3)(react@18.3.1)(typescript@5.6.2)(vitest@2.1.1(@types/node@22.7.4)) @@ -23,6 +33,9 @@ importers: eslint: specifier: 9.11.1 version: 9.11.1 + prisma: + specifier: 5.20.0 + version: 5.20.0 typescript: specifier: 5.6.2 version: 5.6.2 @@ -107,6 +120,34 @@ packages: resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} engines: {node: '>=6.9.0'} + '@discordjs/builders@1.9.0': + resolution: {integrity: sha512-0zx8DePNVvQibh5ly5kCEei5wtPBIUbSoE9n+91Rlladz4tgtFbJ36PZMxxZrTEOQ7AHMZ/b0crT/0fCy6FTKg==} + engines: {node: '>=18'} + + '@discordjs/collection@1.5.3': + resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@2.1.1': + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} + engines: {node: '>=18'} + + '@discordjs/formatters@0.5.0': + resolution: {integrity: sha512-98b3i+Y19RFq1Xke4NkVY46x8KjJQjldHUuEbCqMvp1F5Iq9HgnGpu91jOi/Ufazhty32eRsKnnzS8n4c+L93g==} + engines: {node: '>=18'} + + '@discordjs/rest@2.4.0': + resolution: {integrity: sha512-Xb2irDqNcq+O8F0/k/NaDp7+t091p+acb51iA4bCKfIn+WFWd6HrNvcsSbMMxIR9NjcMZS6NReTKygqiQN+ntw==} + engines: {node: '>=18'} + + '@discordjs/util@1.1.1': + resolution: {integrity: sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==} + engines: {node: '>=18'} + + '@discordjs/ws@1.1.1': + resolution: {integrity: sha512-PZ+vLpxGCRtmr2RMkqh8Zp+BenUaJqlS6xhgWKEZcgC/vfHLEzpHtKkB0sl3nZWpwtcKk6YWy+pU3okL2I97FA==} + engines: {node: '>=16.11.0'} + '@es-joy/jsdoccomment@0.48.0': resolution: {integrity: sha512-G6QUWIcC+KvSwXNsJyDTHvqUdNoAVJPPgkc3+Uk4WBKqZvoXhlvazOgm9aL0HwihJLQf0l+tOE2UFzXBqCqgDw==} engines: {node: '>=16'} @@ -287,6 +328,9 @@ packages: resolution: {integrity: sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/pre-commit@2.1.0': + resolution: {integrity: sha512-UM1Fv4NJ+SErNJzCaR6LBk+4GE4wYtv+/R4ePY7Utx+0PktLVxHsLYnnJWgOfyHqtKCjSchZrn3H8tOPuhCfYA==} + '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} @@ -357,6 +401,30 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@prisma/client@5.20.0': + resolution: {integrity: sha512-CLv55ZuMuUawMsxoqxGtLT3bEZoa2W8L3Qnp6rDIFWy+ZBrUcOFKdoeGPSnbBqxc3SkdxJrF+D1veN/WNynZYA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.20.0': + resolution: {integrity: sha512-oCx79MJ4HSujokA8S1g0xgZUGybD4SyIOydoHMngFYiwEwYDQ5tBQkK5XoEHuwOYDKUOKRn/J0MEymckc4IgsQ==} + + '@prisma/engines-version@5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284': + resolution: {integrity: sha512-Lg8AS5lpi0auZe2Mn4gjuCg081UZf88k3cn0RCwHgR+6cyHHpttPZBElJTHf83ZGsRNAmVCZCfUGA57WB4u4JA==} + + '@prisma/engines@5.20.0': + resolution: {integrity: sha512-DtqkP+hcZvPEbj8t8dK5df2b7d3B8GNauKqaddRRqQBBlgkbdhJkxhoJTrOowlS3vaRt2iMCkU0+CSNn0KhqAQ==} + + '@prisma/fetch-engine@5.20.0': + resolution: {integrity: sha512-JVcaPXC940wOGpCOwuqQRTz6I9SaBK0c1BAyC1pcz9xBi+dzFgUu3G/p9GV1FhFs9OKpfSpIhQfUJE9y00zhqw==} + + '@prisma/get-platform@5.20.0': + resolution: {integrity: sha512-8/+CehTZZNzJlvuryRgc77hZCWrUDYd/PmlZ7p2yNXtmf2Una4BWnTbak3us6WVdqoz5wmptk6IhsXdG2v5fmA==} + '@rollup/rollup-android-arm-eabi@4.22.5': resolution: {integrity: sha512-SU5cvamg0Eyu/F+kLeMXS7GoahL+OoizlclVFX3l5Ql6yNlywJJ0OuqTzUx0v+aHhPHEB/56CT06GQrRrGNYww==} cpu: [arm] @@ -440,6 +508,18 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sapphire/async-queue@1.5.3': + resolution: {integrity: sha512-x7zadcfJGxFka1Q3f8gCts1F0xMwCKbZweM85xECGI0hBTeIZJGGCrHgLggihBoprlQ/hBmDR5LKfIPqnmHM3w==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/shapeshift@4.0.0': + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + + '@sapphire/snowflake@3.5.3': + resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@stylistic/eslint-plugin@2.8.0': resolution: {integrity: sha512-Ufvk7hP+bf+pD35R/QfunF793XlSRIC7USr3/EdgduK9j13i2JjmsM0LUz3/foS+jDYp2fzyWZA9N44CPur0Ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -461,6 +541,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/ws@8.5.12': + resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==} + '@typescript-eslint/eslint-plugin@8.7.0': resolution: {integrity: sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -595,6 +678,10 @@ packages: '@vitest/utils@2.1.1': resolution: {integrity: sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==} + '@vladfrangu/async_event_emitter@2.4.6': + resolution: {integrity: sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -823,6 +910,16 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + discord-api-types@0.37.83: + resolution: {integrity: sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==} + + discord-api-types@0.37.97: + resolution: {integrity: sha512-No1BXPcVkyVD4ZVmbNgDKaBoqgeQ+FJpzZ8wqHkfmBnTZig1FcH3iPPersiK1TUIAzgClh2IvOuVUYfcWLQAOA==} + + discord.js@14.16.2: + resolution: {integrity: sha512-VGNi9WE2dZIxYM8/r/iatQQ+3LT8STW4hhczJOwm+DBeHq66vsKDCk8trChNCB01sMO9crslYuEMeZl2d7r3xw==} + engines: {node: '>=18'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1338,6 +1435,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1435,6 +1536,12 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1448,6 +1555,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-bytes.js@1.10.0: + resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==} + magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} @@ -1644,6 +1754,11 @@ packages: engines: {node: '>=14'} hasBin: true + prisma@5.20.0: + resolution: {integrity: sha512-6obb3ucKgAnsGS9x9gLOe8qa51XxvJ3vLQtmyf52CTey1Qcez3A6W6ROH5HIz5Q5bW+0VpmZb8WBohieMFGpig==} + engines: {node: '>=16.13'} + hasBin: true + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1894,6 +2009,9 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -1943,6 +2061,10 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici@6.19.8: + resolution: {integrity: sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==} + engines: {node: '>=18.17'} + update-browserslist-db@1.1.1: resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true @@ -2036,6 +2158,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -2053,6 +2180,18 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + 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 + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2179,6 +2318,53 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 + '@discordjs/builders@1.9.0': + dependencies: + '@discordjs/formatters': 0.5.0 + '@discordjs/util': 1.1.1 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.37.97 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.7.0 + + '@discordjs/collection@1.5.3': {} + + '@discordjs/collection@2.1.1': {} + + '@discordjs/formatters@0.5.0': + dependencies: + discord-api-types: 0.37.97 + + '@discordjs/rest@2.4.0': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.1.1 + '@sapphire/async-queue': 1.5.3 + '@sapphire/snowflake': 3.5.3 + '@vladfrangu/async_event_emitter': 2.4.6 + discord-api-types: 0.37.97 + magic-bytes.js: 1.10.0 + tslib: 2.7.0 + undici: 6.19.8 + + '@discordjs/util@1.1.1': {} + + '@discordjs/ws@1.1.1': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.4.0 + '@discordjs/util': 1.1.1 + '@sapphire/async-queue': 1.5.3 + '@types/ws': 8.5.12 + '@vladfrangu/async_event_emitter': 2.4.6 + discord-api-types: 0.37.83 + tslib: 2.7.0 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@es-joy/jsdoccomment@0.48.0': dependencies: comment-parser: 1.4.1 @@ -2295,6 +2481,11 @@ snapshots: dependencies: levn: 0.4.1 + '@fastify/pre-commit@2.1.0': + dependencies: + cross-spawn: 7.0.3 + which: 4.0.0 + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.3.0': {} @@ -2381,6 +2572,31 @@ snapshots: '@pkgr/core@0.1.1': {} + '@prisma/client@5.20.0(prisma@5.20.0)': + optionalDependencies: + prisma: 5.20.0 + + '@prisma/debug@5.20.0': {} + + '@prisma/engines-version@5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284': {} + + '@prisma/engines@5.20.0': + dependencies: + '@prisma/debug': 5.20.0 + '@prisma/engines-version': 5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284 + '@prisma/fetch-engine': 5.20.0 + '@prisma/get-platform': 5.20.0 + + '@prisma/fetch-engine@5.20.0': + dependencies: + '@prisma/debug': 5.20.0 + '@prisma/engines-version': 5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284 + '@prisma/get-platform': 5.20.0 + + '@prisma/get-platform@5.20.0': + dependencies: + '@prisma/debug': 5.20.0 + '@rollup/rollup-android-arm-eabi@4.22.5': optional: true @@ -2431,6 +2647,15 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@sapphire/async-queue@1.5.3': {} + + '@sapphire/shapeshift@4.0.0': + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.17.21 + + '@sapphire/snowflake@3.5.3': {} + '@stylistic/eslint-plugin@2.8.0(eslint@9.11.1)(typescript@5.6.2)': dependencies: '@typescript-eslint/utils': 8.7.0(eslint@9.11.1)(typescript@5.6.2) @@ -2455,6 +2680,10 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/ws@8.5.12': + dependencies: + '@types/node': 22.7.4 + '@typescript-eslint/eslint-plugin@8.7.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint@9.11.1)(typescript@5.6.2)': dependencies: '@eslint-community/regexpp': 4.11.1 @@ -2638,6 +2867,8 @@ snapshots: loupe: 3.1.1 tinyrainbow: 1.2.0 + '@vladfrangu/async_event_emitter@2.4.6': {} + acorn-jsx@5.3.2(acorn@7.4.1): dependencies: acorn: 7.4.1 @@ -2887,6 +3118,28 @@ snapshots: dependencies: path-type: 4.0.0 + discord-api-types@0.37.83: {} + + discord-api-types@0.37.97: {} + + discord.js@14.16.2: + dependencies: + '@discordjs/builders': 1.9.0 + '@discordjs/collection': 1.5.3 + '@discordjs/formatters': 0.5.0 + '@discordjs/rest': 2.4.0 + '@discordjs/util': 1.1.1 + '@discordjs/ws': 1.1.1 + '@sapphire/snowflake': 3.5.3 + discord-api-types: 0.37.97 + fast-deep-equal: 3.1.3 + lodash.snakecase: 4.1.1 + tslib: 2.7.0 + undici: 6.19.8 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3533,6 +3786,8 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.1: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@6.0.3: @@ -3634,6 +3889,10 @@ snapshots: lodash.merge@4.6.2: {} + lodash.snakecase@4.1.1: {} + + lodash@4.17.21: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -3648,6 +3907,8 @@ snapshots: dependencies: yallist: 3.1.1 + magic-bytes.js@1.10.0: {} + magic-string@0.30.11: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -3830,6 +4091,12 @@ snapshots: prettier@3.3.3: {} + prisma@5.20.0: + dependencies: + '@prisma/engines': 5.20.0 + optionalDependencies: + fsevents: 2.3.3 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4117,6 +4384,8 @@ snapshots: dependencies: typescript: 5.6.2 + ts-mixer@6.0.4: {} + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -4179,6 +4448,8 @@ snapshots: undici-types@6.19.8: {} + undici@6.19.8: {} + update-browserslist-db@1.1.1(browserslist@4.24.0): dependencies: browserslist: 4.24.0 @@ -4296,6 +4567,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -4315,6 +4590,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + ws@8.18.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..82eba16 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,22 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mongodb" + url = env("MONGO_URI") +} + +model Tasks { + id String @id @default(auto()) @map("_id") @db.ObjectId + numericalId Int @unique + title String + description String + completed Boolean @default(false) + deleted Boolean @default(false) + createdAt DateTime @default(now()) + dueAt DateTime + assignees String[] @default([]) + priority String @default("none") + tags String[] @default([]) +} \ No newline at end of file diff --git a/prod.env b/prod.env index e69de29..c5fd5fa 100644 --- a/prod.env +++ b/prod.env @@ -0,0 +1,3 @@ +DISCORD_TOKEN="op://Environment Variables - Naomi/Tasks Bot/token" +DISCORD_DEBUG_HOOK="op://Environment Variables - Naomi/Tasks Bot/mongo" +MONGO_URI="op://Environment Variables - Naomi/Tasks Bot/webhook" \ No newline at end of file diff --git a/src/commands/assign.ts b/src/commands/assign.ts new file mode 100644 index 0000000..d191ad0 --- /dev/null +++ b/src/commands/assign.ts @@ -0,0 +1,73 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { InteractionContextType, SlashCommandBuilder } from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +export const assign: Command = { + data: new SlashCommandBuilder(). + setName("assign"). + setDescription("Add or remove someone as a task assignee."). + setContexts(InteractionContextType.Guild). + addIntegerOption((option) => { + return option. + setName("task"). + setDescription("The task number."). + setMinValue(1). + setRequired(true); + }). + addUserOption((option) => { + return option. + setName("assignee"). + setDescription("The user to (un)assign."). + setRequired(true); + }), + run: async(bot, interaction) => { + try { + await interaction.deferReply({ ephemeral: true }); + const numericalId = interaction.options.getInteger("task", true); + const user = interaction.options.getUser("assignee", true).id; + const task = await bot.database.tasks.findFirst({ + where: { + numericalId, + }, + }); + if (!task) { + await interaction.editReply({ + content: `Task ${String(numericalId)} not found.`, + }); + return; + } + const shouldRemove = task.assignees.includes(user); + await bot.database.tasks.update({ + data: shouldRemove + ? { + assignees: task.assignees.filter((u) => { + return u !== user; + }), + } + : { + assignees: { + push: user, + }, + }, + where: { + numericalId, + }, + }); + await interaction.editReply({ + content: `User <@${user}> ${shouldRemove + ? "unassigned from" + : "assigned to"} task ${String(numericalId)}.`, + }); + } catch (error) { + await errorHandler(bot, "assign command", error, interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction)); + } + }, +}; diff --git a/src/commands/complete.ts b/src/commands/complete.ts new file mode 100644 index 0000000..b6a4d8d --- /dev/null +++ b/src/commands/complete.ts @@ -0,0 +1,44 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { InteractionContextType, SlashCommandBuilder } from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +export const complete: Command = { + data: new SlashCommandBuilder(). + setName("complete"). + setDescription("Mark a task as completed."). + setContexts(InteractionContextType.Guild). + addIntegerOption((option) => { + return option. + setName("id"). + setDescription("The ID of the task to complete."). + setMinValue(1). + setRequired(true); + }), + run: async(bot, interaction) => { + try { + await interaction.deferReply({ ephemeral: true }); + const numericalId = interaction.options.getInteger("id", true); + const task = await bot.database.tasks.update({ + data: { completed: true }, + where: { numericalId }, + }).catch(() => { + return null; + }); + await interaction.editReply({ + content: task + ? `Task ${String(numericalId)} has been marked as complete.` + : `Task ${String(numericalId)} does not exist.`, + }); + } catch (error) { + await errorHandler(bot, "view command", error, interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction)); + } + }, +}; diff --git a/src/commands/create.ts b/src/commands/create.ts new file mode 100644 index 0000000..31f4e6f --- /dev/null +++ b/src/commands/create.ts @@ -0,0 +1,60 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ActionRowBuilder, + InteractionContextType, + ModalBuilder, + SlashCommandBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +export const create: Command = { + data: new SlashCommandBuilder(). + setName("create"). + setDescription("Create a new task."). + setContexts(InteractionContextType.Guild), + run: async(bot, interaction) => { + try { + const title = new TextInputBuilder(). + setLabel("Title"). + setRequired(true). + setStyle(TextInputStyle.Short). + setCustomId("title"); + const description = new TextInputBuilder(). + setLabel("Description"). + setRequired(true). + setStyle(TextInputStyle.Paragraph). + setCustomId("description"); + const due = new TextInputBuilder(). + setLabel("Due Date"). + setRequired(true). + setStyle(TextInputStyle.Short). + setCustomId("dueDate"); + const rowOne = new ActionRowBuilder().addComponents( + title, + ); + const rowTwo = new ActionRowBuilder().addComponents( + description, + ); + const rowThree = new ActionRowBuilder().addComponents( + due, + ); + const modal = new ModalBuilder(). + setCustomId("create-task"). + setTitle("New Task"). + addComponents(rowOne, rowTwo, rowThree); + await interaction.showModal(modal); + } catch (error) { + await errorHandler(bot, "create command", error, interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction)); + } + }, +}; diff --git a/src/commands/delete.ts b/src/commands/delete.ts new file mode 100644 index 0000000..e341e41 --- /dev/null +++ b/src/commands/delete.ts @@ -0,0 +1,62 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { InteractionContextType, SlashCommandBuilder } from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +export const deleteCommand: Command = { + data: new SlashCommandBuilder(). + setName("delete"). + setDescription( + // eslint-disable-next-line stylistic/max-len + "Mark a task as deleted. WARNING: This will scrub all PII from the task and CANNOT be undone.", + ). + setContexts(InteractionContextType.Guild). + addIntegerOption((option) => { + return option. + setName("id"). + setDescription("The ID of the task to delete."). + setMinValue(1). + setRequired(true); + }), + run: async(bot, interaction) => { + try { + await interaction.deferReply({ ephemeral: true }); + const numericalId = interaction.options.getInteger("id", true); + const task = await bot.database.tasks. + update({ + data: { + assignees: [], + deleted: true, + description: "This task has been deleted.", + dueAt: new Date(), + priority: "deleted", + tags: [], + title: "Deleted Task", + }, + where: { numericalId }, + }). + catch(() => { + return null; + }); + await interaction.editReply({ + content: task + ? `Task ${String(numericalId)} has been marked as deleted.` + : `Task ${String(numericalId)} does not exist.`, + }); + } catch (error) { + await errorHandler( + bot, + "view command", + error, + interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction), + ); + } + }, +}; diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000..d5d56de --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,87 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { InteractionContextType, SlashCommandBuilder } from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; +import type { Prisma } from "@prisma/client"; + +export const list: Command = { + data: new SlashCommandBuilder(). + setName("list"). + setDescription("List all tasks, with optional filters."). + setContexts(InteractionContextType.Guild). + addStringOption((option) => { + return option. + setName("priority"). + setDescription("List tasks under this priority."). + setRequired(false). + addChoices( + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Critical", value: "critical" }, + { name: "None", value: "none" }, + ); + }). + addStringOption((option) => { + return option. + setName("tag"). + setDescription("List tasks with this tag."). + setRequired(false); + }). + addUserOption((option) => { + return option. + setName("assignee"). + setDescription("List tasks assigned to this user."). + setRequired(false); + }). + addBooleanOption((option) => { + return option. + setName("completed"). + setDescription("List completed tasks."). + setRequired(false); + }), + run: async(bot, interaction) => { + try { + await interaction.deferReply({ ephemeral: true }); + const priority = interaction.options.getString("priority"); + const tag = interaction.options.getString("tag"); + const assignee = interaction.options.getUser("assignee"); + const completed = interaction.options.getBoolean("completed") ?? false; + const query: Prisma.TasksWhereInput = { + completed: completed, + deleted: false, + }; + if (priority !== null) { + query.priority = priority; + } + if (tag !== null) { + query.tags = { has: tag }; + } + if (assignee !== null) { + query.assignees = { has: assignee.id }; + } + const tasks = await bot.database.tasks.findMany({ + where: query, + }); + const taskList = tasks.sort((a, b) => { + return a.dueAt.getTime() - b.dueAt.getTime(); + }).map((task) => { + return `- Task ${String(task.numericalId)}: ${task.title}`; + }); + await interaction.editReply({ + content: taskList.length > 0 + ? taskList.join("\n") + : "No tasks found with this current filter.", + }); + } catch (error) { + await errorHandler(bot, "list command", error, interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction)); + } + }, +}; diff --git a/src/commands/priority.ts b/src/commands/priority.ts new file mode 100644 index 0000000..2006d6c --- /dev/null +++ b/src/commands/priority.ts @@ -0,0 +1,69 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { InteractionContextType, SlashCommandBuilder } from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +export const priority: Command = { + data: new SlashCommandBuilder(). + setName("priority"). + setDescription("Set the priority of a task."). + setContexts(InteractionContextType.Guild). + addIntegerOption((option) => { + return option. + setName("task"). + setDescription("The task number."). + setMinValue(1). + setRequired(true); + }). + addStringOption((option) => { + return option. + setName("priority"). + setDescription("The priority level."). + setRequired(true). + addChoices( + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Critical", value: "critical" }, + { name: "None", value: "none" }, + ); + }), + run: async(bot, interaction) => { + try { + await interaction.deferReply({ ephemeral: true }); + const numericalId = interaction.options.getInteger("task", true); + const priorityValue = interaction.options.getString("priority", true); + const task = await bot.database.tasks.findFirst({ + where: { + numericalId, + }, + }); + if (!task) { + await interaction.editReply({ + content: `Task ${String(numericalId)} not found.`, + }); + return; + } + await bot.database.tasks.update({ + data: { + priority: priorityValue, + }, + where: { + numericalId, + }, + }); + await interaction.editReply({ + content: `Task ${String(numericalId)} priority set to ${priorityValue}.`, + }); + } catch (error) { + await errorHandler(bot, "priority command", error, interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction)); + } + }, +}; diff --git a/src/commands/tag.ts b/src/commands/tag.ts new file mode 100644 index 0000000..1698529 --- /dev/null +++ b/src/commands/tag.ts @@ -0,0 +1,73 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { InteractionContextType, SlashCommandBuilder } from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +export const tag: Command = { + data: new SlashCommandBuilder(). + setName("tag"). + setDescription("Add or remove a tag from a task."). + setContexts(InteractionContextType.Guild). + addIntegerOption((option) => { + return option. + setName("task"). + setDescription("The task number."). + setMinValue(1). + setRequired(true); + }). + addStringOption((option) => { + return option. + setName("tag"). + setDescription("The tag."). + setRequired(true); + }), + run: async(bot, interaction) => { + try { + await interaction.deferReply({ ephemeral: true }); + const numericalId = interaction.options.getInteger("task", true); + const tagName = interaction.options.getString("tag", true); + const task = await bot.database.tasks.findFirst({ + where: { + numericalId, + }, + }); + if (!task) { + await interaction.editReply({ + content: `Task ${String(numericalId)} not found.`, + }); + return; + } + const shouldRemove = task.tags.includes(tagName); + await bot.database.tasks.update({ + data: shouldRemove + ? { + tags: task.tags.filter((t) => { + return t !== tagName; + }), + } + : { + tags: { + push: tagName, + }, + }, + where: { + numericalId, + }, + }); + await interaction.editReply({ + content: `Tag ${tagName} ${shouldRemove + ? "removed from" + : "added to"} task ${String(numericalId)}.`, + }); + } catch (error) { + await errorHandler(bot, "tag command", error, interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction)); + } + }, +}; diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..4d562ef --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,68 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ActionRowBuilder, + InteractionContextType, + ModalBuilder, + SlashCommandBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +export const update: Command = { + data: new SlashCommandBuilder(). + setName("update"). + setDescription("Update a task."). + setContexts(InteractionContextType.Guild), + run: async(bot, interaction) => { + try { + const number = new TextInputBuilder(). + setLabel("Task Number"). + setRequired(true). + setStyle(TextInputStyle.Short). + setCustomId("taskNumber"); + const title = new TextInputBuilder(). + setLabel("Title"). + setRequired(false). + setStyle(TextInputStyle.Short). + setCustomId("title"); + const description = new TextInputBuilder(). + setLabel("Description"). + setRequired(false). + setStyle(TextInputStyle.Paragraph). + setCustomId("description"); + const due = new TextInputBuilder(). + setLabel("Due Date"). + setRequired(false). + setStyle(TextInputStyle.Short). + setCustomId("dueDate"); + const rowZero = new ActionRowBuilder().addComponents( + number, + ); + const rowOne = new ActionRowBuilder().addComponents( + title, + ); + const rowTwo = new ActionRowBuilder().addComponents( + description, + ); + const rowThree = new ActionRowBuilder().addComponents( + due, + ); + const modal = new ModalBuilder(). + setCustomId("update-task"). + setTitle("Update Task"). + addComponents(rowZero, rowOne, rowTwo, rowThree); + await interaction.showModal(modal); + } catch (error) { + await errorHandler(bot, "update command", error, interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction)); + } + }, +}; diff --git a/src/commands/view.ts b/src/commands/view.ts new file mode 100644 index 0000000..03fb337 --- /dev/null +++ b/src/commands/view.ts @@ -0,0 +1,61 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { InteractionContextType, SlashCommandBuilder } from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +export const view: Command = { + data: new SlashCommandBuilder(). + setName("view"). + setDescription("View a task by its ID."). + setContexts(InteractionContextType.Guild). + addIntegerOption((option) => { + return option. + setName("id"). + setDescription("The ID of the task to view."). + setMinValue(1). + setRequired(true); + }), + run: async(bot, interaction) => { + try { + await interaction.deferReply({ ephemeral: true }); + const numericalId = interaction.options.getInteger("id", true); + const task = await bot.database.tasks.findUnique({ + where: { numericalId }, + }); + if (!task) { + await interaction.editReply({ content: `Task ${String(numericalId)} not found.` }); + return; + } + if (task.deleted) { + await interaction.editReply({ content: `Task ${String(numericalId)} has been deleted.` }); + return; + } + await interaction.editReply({ embeds: [ + { + description: task.description, + fields: [ + { inline: true, name: "Priority", value: task.priority }, + { inline: true, name: "Completed", value: task.completed + ? "Yes" + : "No" }, + { name: "Tag", value: task.tags.join(", ") }, + { name: "Assignee", value: task.assignees.map((id) => { + return `<@${id}>`; + }).join(", ") }, + + ], + title: `Task ${String(task.numericalId)}`, + }, + ] }); + } catch (error) { + await errorHandler(bot, "view command", error, interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction)); + } + }, +}; diff --git a/src/config/intents.ts b/src/config/intents.ts new file mode 100644 index 0000000..3be693f --- /dev/null +++ b/src/config/intents.ts @@ -0,0 +1,12 @@ + +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { GatewayIntentBits } from "discord.js"; + +export const intents: Array = [ + GatewayIntentBits.Guilds, +]; diff --git a/src/events/onInteractionCreate.ts b/src/events/onInteractionCreate.ts new file mode 100644 index 0000000..6841bd9 --- /dev/null +++ b/src/events/onInteractionCreate.ts @@ -0,0 +1,99 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { assign } from "../commands/assign.js"; +import { complete } from "../commands/complete.js"; +import { create } from "../commands/create.js"; +import { deleteCommand } from "../commands/delete.js"; +import { list } from "../commands/list.js"; +import { priority } from "../commands/priority.js"; +import { tag } from "../commands/tag.js"; +import { update } from "../commands/update.js"; +import { view } from "../commands/view.js"; +import { defaultCommand } from "../modules/defaultCommand.js"; +import { + createModal, + defaultModal, + updateModal, + type ModalHandler, +} from "../modules/modalHandlers.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Bot } from "../interfaces/bot.js"; +import type { Command } from "../interfaces/command.js"; +import type { Interaction } from "discord.js"; + +const commandMap: Record = { + assign: assign, + complete: complete, + create: create, + delete: deleteCommand, + list: list, + priority: priority, + tag: tag, + update: update, + view: view, +}; + +const modalMap: Record = { + // eslint-disable-next-line @typescript-eslint/naming-convention + "create-task": createModal, + // eslint-disable-next-line @typescript-eslint/naming-convention + "update-task": updateModal, +}; + +/** + * Handles all of the logic for interactions. + * If it's a slash command and we have the guild, try to run it. + * @param bot - The bot object, which contains the database and Discord client. + * @param interaction - The interaction payload from Discord. + */ +export const onInteractionCreate + // eslint-disable-next-line complexity + = async(bot: Bot, interaction: Interaction): Promise => { + try { + if (interaction.isChatInputCommand()) { + if (!interaction.inCachedGuild()) { + await interaction.reply({ + content: "How did you run this out of a guild?", + ephemeral: true, + }); + return; + } + const command + = commandMap[interaction.commandName]?.run ?? defaultCommand; + await command(bot, interaction); + return; + } + + if (interaction.isModalSubmit()) { + if (!interaction.inCachedGuild()) { + await interaction.reply({ + content: "How did you get a modal outside of a guild?", + ephemeral: true, + }); + return; + } + const handler = modalMap[interaction.customId] ?? defaultModal; + await handler(bot, interaction); + return; + } + + throw new Error("Unknown interaction type."); + } catch (error) { + if (interaction.isAutocomplete()) { + await errorHandler(bot, "discord on interaction event", error); + return; + } + await errorHandler( + bot, + "discord on interaction event", + error, + interaction.replied + ? interaction.editReply.bind(interaction) + : interaction.reply.bind(interaction), + ); + } + }; diff --git a/src/events/onReady.ts b/src/events/onReady.ts new file mode 100644 index 0000000..029157b --- /dev/null +++ b/src/events/onReady.ts @@ -0,0 +1,29 @@ + +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { displayCommandCurl } from "../utils/displayCommandCurl.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import { sendDebugLog } from "../utils/sendDebugLog.js"; +import type { Bot } from "../interfaces/bot.js"; + +/** + * To be mounted on the ClientReady gateway event. Sends + * a message to the debug webhook to confirm the bot has + * authenticated to Discord. + * @param bot -- The bot object, containing the webhook client. + */ +export const onReady = async(bot: Bot): Promise => { + try { + await sendDebugLog(bot, { content: "Bot has authenticated to Discord." }); + await sendDebugLog(bot, { files: [ { + attachment: Buffer.from(displayCommandCurl(bot)), + name: "curl.sh", + } ] }); + } catch (error) { + await errorHandler(bot, "discord on ready event", error); + } +}; diff --git a/src/index.ts b/src/index.ts index 8d1f4ed..6f7dacf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,48 @@ * @license Naomi's Public License * @author Naomi Carrigan */ + +import { PrismaClient } from "@prisma/client"; +import { Client, Events, WebhookClient } from "discord.js"; +import { intents } from "./config/intents.js"; +import { onInteractionCreate } from "./events/onInteractionCreate.js"; +import { onReady } from "./events/onReady.js"; +import { validateEnvironmentVariables } + from "./utils/validateEnvironmentVariables.js"; + +/** + * The entry point file. Handles starting up the application + * process and mounting the necessary event listeners. + */ +const boot = async(): Promise => { + try { + const bot = { + database: new PrismaClient(), + discord: new Client({ intents }), + env: validateEnvironmentVariables(), + }; + + await bot.database.$connect(); + + bot.discord.on(Events.ClientReady, () => { + void onReady(bot); + }); + + bot.discord.on(Events.InteractionCreate, (interaction) => { + void onInteractionCreate(bot, interaction); + }); + + await bot.discord.login(bot.env.discordToken); + } catch (error) { + const hook = new WebhookClient({ + url: process.env.DISCORD_DEBUG_WEBHOOK ?? "", + }); + await hook.send({ + content: `Error: ${JSON.stringify(error, null, 2)}`, + }); + } +}; + +void boot(); + +export { boot }; diff --git a/src/interfaces/bot.ts b/src/interfaces/bot.ts new file mode 100644 index 0000000..cb22ca0 --- /dev/null +++ b/src/interfaces/bot.ts @@ -0,0 +1,18 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { PrismaClient } from "@prisma/client"; +import type { Client, WebhookClient } from "discord.js"; + +export interface Bot { + env: { + discordToken: string; + discordDebugWebhook: WebhookClient; + mongoUri: string; + }; + discord: Client; + database: PrismaClient; +} diff --git a/src/interfaces/command.ts b/src/interfaces/command.ts new file mode 100644 index 0000000..443ccc9 --- /dev/null +++ b/src/interfaces/command.ts @@ -0,0 +1,15 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { Bot } from "./bot.js"; +import type { ChatInputCommandInteraction, SlashCommandOptionsOnlyBuilder } + from "discord.js"; + +export interface Command { + data: SlashCommandOptionsOnlyBuilder; + run: (bot: Bot, interaction: + ChatInputCommandInteraction<"cached">)=> Promise; +} diff --git a/src/modules/defaultCommand.ts b/src/modules/defaultCommand.ts new file mode 100644 index 0000000..072fd0f --- /dev/null +++ b/src/modules/defaultCommand.ts @@ -0,0 +1,21 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { Command } from "../interfaces/command.js"; + +/** + * Default handler to fall back to in the event a command + * is not found. This can happen if we delete command logic in the code + * but do not register the updates. + * @param _bot - The bot instance, unused here but necessary to match the type signatures. + * @param interaction - The interaction payload from Discord. + */ +export const defaultCommand: Command["run"] = async(_bot, interaction) => { + await interaction.reply({ + content: `Interaction ${interaction.commandName} not found.`, + ephemeral: true, + }); +}; diff --git a/src/modules/modalHandlers.ts b/src/modules/modalHandlers.ts new file mode 100644 index 0000000..3bc41a2 --- /dev/null +++ b/src/modules/modalHandlers.ts @@ -0,0 +1,139 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { errorHandler } from "../utils/errorHandler.js"; +import type { Bot } from "../interfaces/bot.js"; +import type { Tasks } from "@prisma/client"; +import type { ModalSubmitInteraction } from "discord.js"; + +type ModalHandler = (bot: Bot, modal: ModalSubmitInteraction)=> Promise; + +/** + * Fallback logic for the modal map. Reports the custom ID + * to be debugged. + * @param _bot - The bot object. Unused, but necessary to match the type signature. + * @param modal - The modal interaction payload from Discord. + */ +const defaultModal: ModalHandler = async( + _bot, + modal, +): Promise => { + await modal.reply({ + content: `Modal ${modal.customId} has no handler.`, + ephemeral: true, + }); +}; + +/** + * Processes the logic for the modal to create a new + * task. + * @param bot - The bot object, which contains the database and Discord client. + * @param modal - The modal interaction payload from Discord. + */ +const createModal: ModalHandler = async( + bot, + modal, +): Promise => { + try { + await modal.deferReply({ + ephemeral: true, + }); + const title = modal.fields.getTextInputValue("title"); + const description = modal.fields.getTextInputValue("description"); + const date = modal.fields.getTextInputValue("dueDate"); + const parsedDate = new Date(date); + const dueDate = Number.isNaN(parsedDate.valueOf()) + ? new Date() + : parsedDate; + + const id = await bot.database.tasks.count() + 1; + const task = await bot.database.tasks.create({ + data: { + description: description, + dueAt: dueDate, + numericalId: id, + title: title, + }, + }); + await modal.editReply({ + content: `Task ${String(task.numericalId)} created.`, + }); + } catch (error) { + await errorHandler(bot, "create modal handler", error, modal.replied + ? modal.editReply.bind(modal) + : modal.reply.bind(modal)); + } +}; + +/** + * Processes the logic for the modal to update a + * task. + * @param bot - The bot object, which contains the database and Discord client. + * @param modal - The modal interaction payload from Discord. + */ +// eslint-disable-next-line max-statements, max-lines-per-function +const updateModal: ModalHandler = async( + bot, + modal, +): Promise => { + try { + await modal.deferReply({ + ephemeral: true, + }); + const taskNumber = modal.fields.getTextInputValue("taskNumber"); + const number = Number(taskNumber); + if (Number.isNaN(number)) { + await modal.editReply({ + content: "Invalid task number.", + }); + return; + } + const task = await bot.database.tasks.findFirst({ + where: { + numericalId: number, + }, + }); + if (!task) { + await modal.editReply({ + content: `Task ${taskNumber} not found.`, + }); + return; + } + const title = modal.fields.getTextInputValue("title"); + const description = modal.fields.getTextInputValue("description"); + const date = modal.fields.getTextInputValue("dueDate"); + + const updateObject: Partial = {}; + if (title !== "") { + updateObject.title = title; + } + if (description !== "") { + updateObject.description = description; + } + if (date !== "") { + const parsedDate = new Date(date); + const dueDate = Number.isNaN(parsedDate.valueOf()) + ? task.dueAt + : parsedDate; + updateObject.dueAt = dueDate; + } + await bot.database.tasks.update({ + data: updateObject, + where: { + numericalId: number, + }, + }); + await modal.editReply({ + content: `Task ${String(task.numericalId)} updated.`, + }); + } catch (error) { + await errorHandler(bot, "update modal handler", error, modal.replied + ? modal.editReply.bind(modal) + : modal.reply.bind(modal)); + } +}; + +export { type ModalHandler, createModal, defaultModal, updateModal }; diff --git a/src/utils/displayCommandCurl.ts b/src/utils/displayCommandCurl.ts new file mode 100644 index 0000000..83b88a0 --- /dev/null +++ b/src/utils/displayCommandCurl.ts @@ -0,0 +1,54 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { assign } from "../commands/assign.js"; +import { complete } from "../commands/complete.js"; +import { create } from "../commands/create.js"; +import { deleteCommand } from "../commands/delete.js"; +import { list } from "../commands/list.js"; +import { priority } from "../commands/priority.js"; +import { tag } from "../commands/tag.js"; +import { update } from "../commands/update.js"; +import { view } from "../commands/view.js"; +import type { Bot } from "../interfaces/bot.js"; + +/** + * Generates a CURL string which can be used to register the bot's commands. + * This is done manually - you should never register commands on something like the ready event. + * Normally, we would register in response to an owner command, but this bot will not be using + * the messgae content intent and we have no desire to parse a ping-prefixed message. + * @param bot - The bot object, needed only to dynamically load the ID. The token is NOT loaded, for security. + * @returns A CURL string which can be used in a terminal to register the bot's commands. + */ +export const displayCommandCurl = (bot: Bot): string => { + const commands = [ + create, + update, + priority, + tag, + assign, + list, + view, + complete, + deleteCommand, + ].map((c) => { + return c.data.toJSON(); + }); + const url = `https://discord.com/api/v10/applications/${bot.discord.user?.id ?? "{ID}"}/commands`; + const method = "PUT"; + const headers = { + // eslint-disable-next-line @typescript-eslint/naming-convention + "Authorization": `Bot {TOKEN}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + "Content-Type": "application/json", + }; + const body = JSON.stringify(commands); + return `curl -X ${method} -H ${Object.entries(headers). + map(([ k, v ]) => { + return `"${k}: ${v}"`; + }). + join(" -H ")} --data '${body}' ${url}`; +}; diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts new file mode 100644 index 0000000..4c41582 --- /dev/null +++ b/src/utils/errorHandler.ts @@ -0,0 +1,52 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { + EmbedBuilder, + SnowflakeUtil, + type ChatInputCommandInteraction, +} from "discord.js"; +import { sendDebugLog } from "./sendDebugLog.js"; +import type { Bot } from "../interfaces/bot.js"; + +/** + * Parses an error event (re: from a catch block). If it + * is a proper Error object, extrapolates data. Sends information + * to the debug webhook, and assigns the error a Snowflake ID. + * Optionally forwards the ID to the user through a reply function. + * @param bot -- The bot object, containing the webhook client. + * @param context -- A brief description of the code module that threw the error. + * @param error -- The error payload, typed as unknown to comply with TypeScript's typedef. + * @param reply - OPTIONAL: A function to use to send the ID back to the user. + * @returns The Snowflake ID assigned to the error. + */ +export const errorHandler = async( + bot: Bot, + context: string, + error: unknown, + reply?: + | ChatInputCommandInteraction["reply"] + | ChatInputCommandInteraction["editReply"], +// eslint-disable-next-line @typescript-eslint/max-params +): Promise => { + const id = SnowflakeUtil.generate(); + const embed = new EmbedBuilder(); + embed.setFooter({ text: `Error ID: ${id.toString()}` }); + embed.setTitle(`Error: ${context}`); + if (error instanceof Error) { + embed.setDescription(error.message); + embed.addFields([ + { name: "Stack", value: `\`\`\`\n${String(error.stack).slice(0, 1000)}` }, + ]); + } else { + embed.setDescription(String(error).slice(0, 2000)); + } + await sendDebugLog(bot, { embeds: [ embed ] }); + if (reply) { + const content = `Oops! Something went wrong! Please reach out to us in our [support server](https://chat.nhcarrigan.com) and bring this error ID: ${id.toString()}`; + await reply({ content: content, ephemeral: true }); + } + return id.toString(); +}; diff --git a/src/utils/sendDebugLog.ts b/src/utils/sendDebugLog.ts new file mode 100644 index 0000000..62ccd74 --- /dev/null +++ b/src/utils/sendDebugLog.ts @@ -0,0 +1,27 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { Bot } from "../interfaces/bot.js"; +import type { MessageCreateOptions } from "discord.js"; + +/** + * Quick wrapper to send a debug message to the webhook. + * Modularised for future expansion if needed. + * @param bot -- The bot object, containing the webhook client. + * @param message -- The message payload, compatible with Discord's API. + */ +export const sendDebugLog = async( + bot: Bot, + message: MessageCreateOptions, +): Promise => { + await bot.env.discordDebugWebhook.send({ + ...message, + avatarURL: + bot.discord.user?.displayAvatarURL() + ?? "https://cdn.nhcarrigan.com/profile.png", + username: bot.discord.user?.username ?? "RIG Task Bot", + }); +}; diff --git a/src/utils/validateEnvironmentVariables.ts b/src/utils/validateEnvironmentVariables.ts new file mode 100644 index 0000000..f93fcb5 --- /dev/null +++ b/src/utils/validateEnvironmentVariables.ts @@ -0,0 +1,39 @@ + +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { WebhookClient } from "discord.js"; +import type { Bot } from "../interfaces/bot.js"; + +/** + * Confirms that environment variables are not undefined. If + * they are, throw an error to halt the program. + * @returns The environment variables, structured for the top-level bot to cache. + * @throws A ReferenceError for any missing variables, with a message indicating the missing variable. + */ +export const validateEnvironmentVariables = (): Bot["env"] => { + if (process.env.DISCORD_TOKEN === undefined + || process.env.DISCORD_TOKEN === "") { + throw new ReferenceError("DISCORD_TOKEN cannot be undefined."); + } + + if (process.env.DISCORD_DEBUG_WEBHOOK === undefined + || process.env.DISCORD_DEBUG_WEBHOOK === "") { + throw new ReferenceError("DISCORD_DEBUG_WEBHOOK cannot be undefined."); + } + + if (process.env.MONGO_URI === undefined + || process.env.MONGO_URI === "") { + throw new ReferenceError("MONGO_URI cannot be undefined."); + } + + return { + discordDebugWebhook: + new WebhookClient({ url: process.env.DISCORD_DEBUG_WEBHOOK }), + discordToken: process.env.DISCORD_TOKEN, + mongoUri: process.env.MONGO_URI, + }; +}; diff --git a/test/commands/assign.spec.ts b/test/commands/assign.spec.ts new file mode 100644 index 0000000..38f2ddc --- /dev/null +++ b/test/commands/assign.spec.ts @@ -0,0 +1,251 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ApplicationCommandOptionType, + InteractionContextType, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { assign } from "../../src/commands/assign.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("assign command", () => { + it("should have the correct data", () => { + expect.assertions(16); + expect(assign.data.name, "did not have the correct name").toBe("assign"); + expect(assign.data.name.length, "name is too long").toBeLessThanOrEqual(32); + expect(assign.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect( + assign.data.description, + "did not have the correct description", + ).toBe("Add or remove someone as a task assignee."); + expect( + assign.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect( + assign.data.contexts, + "did not have the correct context", + ).toStrictEqual([ InteractionContextType.Guild ]); + expect(assign.data.options, "should have 2 options").toHaveLength(2); + expect( + assign.data.options[0].toJSON().name, + "should have the correct name", + ).toBe("task"); + expect( + assign.data.options[0].toJSON().description, + "should have the correct description", + ).toBe("The task number."); + expect( + assign.data.options[0].toJSON().required, + "should be required", + ).toBeTruthy(); + expect( + assign.data.options[0].toJSON()["min_value"], + "should have a min value of 1", + ).toBe(1); + expect( + assign.data.options[0].toJSON().type, + "should be a number option", + ).toBe(ApplicationCommandOptionType.Integer); + + expect( + assign.data.options[1].toJSON().name, + "should have the correct name", + ).toBe("assignee"); + expect( + assign.data.options[1].toJSON().description, + "should have the correct description", + ).toBe("The user to (un)assign."); + expect( + assign.data.options[1].toJSON().required, + "should be required", + ).toBeTruthy(); + expect( + assign.data.options[1].toJSON().type, + "should be a user option", + ).toBe(ApplicationCommandOptionType.User); + }); + + it("should execute correctly when adding assign", async() => { + expect.assertions(4); + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockResolvedValue({ + assignees: [], + numericalId: 1, + }), + update: vi.fn().mockResolvedValue({}), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + getUser: vi.fn().mockReturnValue({ id: "123" }), + }, + } as never; + await assign.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should call findFirst", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockBot.database.tasks.update, + "should call update", + ).toHaveBeenCalledWith({ + data: { + assignees: { + push: "123", + }, + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "User <@123> assigned to task 1.", + }); + }); + + it("should execute correctly when removing assign", async() => { + expect.assertions(4); + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockResolvedValue({ + assignees: [ "123", "456" ], + numericalId: 1, + }), + update: vi.fn().mockResolvedValue({}), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + getUser: vi.fn().mockReturnValue({ id: "123" }), + }, + } as never; + await assign.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should call findFirst", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockBot.database.tasks.update, + "should call update", + ).toHaveBeenCalledWith({ + data: { + assignees: [ "456" ], + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "User <@123> unassigned from task 1.", + }); + }); + + it("should execute correctly when task not found", async() => { + expect.assertions(4); + vi.resetAllMocks(); + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({}), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + getUser: vi.fn().mockReturnValue({ id: "123" }), + }, + } as never; + await assign.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should call findFirst", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockBot.database.tasks.update, + "should not call update", + ).not.toHaveBeenCalled(); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 not found.", + }); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await assign.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await assign.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commands/complete.spec.ts b/test/commands/complete.spec.ts new file mode 100644 index 0000000..b5e9d55 --- /dev/null +++ b/test/commands/complete.spec.ts @@ -0,0 +1,166 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ApplicationCommandOptionType, + InteractionContextType, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { complete } from "../../src/commands/complete.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("complete command", () => { + it("should have the correct data", () => { + expect.assertions(11); + expect(complete.data.name, "did not have the correct name").toBe( + "complete", + ); + expect(complete.data.name.length, "name is too long").toBeLessThanOrEqual( + 32, + ); + expect(complete.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect( + complete.data.description, + "did not have the correct description", + ).toBe("Mark a task as completed."); + expect( + complete.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect( + complete.data.contexts, + "did not have the correct context", + ).toStrictEqual([ InteractionContextType.Guild ]); + expect(complete.data.options, "should have 1 option").toHaveLength(1); + expect( + complete.data.options[0].toJSON().name, + "did not have the correct option name", + ).toBe("id"); + expect( + complete.data.options[0].toJSON().description, + "did not have the correct option description", + ).toBe("The ID of the task to complete."); + expect( + complete.data.options[0].toJSON().required, + "did not have the correct option required value", + ).toBeTruthy(); + expect( + complete.data.options[0].toJSON().type, + "did not have the correct option type", + ).toBe(ApplicationCommandOptionType.Integer); + }); + + it("should execute correctly", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + update: vi.fn().mockReturnValue({ + catch: vi.fn().mockReturnValue([ { assignees: [ "123" ] } ]), + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { getInteger: vi.fn().mockReturnValue(1) }, + } as never; + await complete.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.update, + "should update database", + ).toHaveBeenCalledWith({ + data: { + completed: true, + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 has been marked as complete.", + }); + }); + + it("should execute correctly when task not found", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + update: vi.fn().mockReturnValue({ + catch: vi.fn().mockImplementation((callback) => { + return callback(); + }), + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + }, + } as never; + await complete.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.update, + "should update database", + ).toHaveBeenCalledWith({ + data: { + completed: true, + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 does not exist.", + }); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await complete.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await complete.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commands/create.spec.ts b/test/commands/create.spec.ts new file mode 100644 index 0000000..d306f08 --- /dev/null +++ b/test/commands/create.spec.ts @@ -0,0 +1,196 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ActionRowBuilder, + InteractionContextType, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { create } from "../../src/commands/create.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("discord.js", async() => { + const actual = await vi.importActual("discord.js"); + return { + ...actual, + ActionRowBuilder: vi.fn(() => { + return { + addComponents: vi.fn().mockReturnThis(), + }; + }), + ModalBuilder: vi.fn(() => { + return { + addComponents: vi.fn().mockReturnThis(), + setCustomId: vi.fn().mockReturnThis(), + setTitle: vi.fn().mockReturnThis(), + }; + }), + TextInputBuilder: vi.fn(() => { + return { + setCustomId: vi.fn().mockReturnThis(), + setLabel: vi.fn().mockReturnThis(), + setRequired: vi.fn().mockReturnThis(), + setStyle: vi.fn().mockReturnThis(), + }; + }), + TextInputStyle: { Paragraph: "Paragraph", Short: "Short" }, + }; +}); +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("create command", () => { + it("should have the correct data", () => { + expect.assertions(7); + expect(create.data.name, "did not have the correct name").toBe("create"); + expect(create.data.name.length, "name is too long").toBeLessThanOrEqual(32); + expect(create.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect( + create.data.description, + "did not have the correct description", + ).toBe("Create a new task."); + expect( + create.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect( + create.data.contexts, + "did not have the correct context", + ).toStrictEqual([ InteractionContextType.Guild ]); + expect(create.data.options, "should not have options").toHaveLength(0); + }); + + it("should execute correctly", async() => { + expect.assertions(23); + const mockBot = {} as never; + const mockInteraction = { + showModal: vi.fn(), + } as never; + await create.run(mockBot, mockInteraction); + expect(TextInputBuilder, "should create text inputs").toHaveBeenCalledTimes( + 3, + ); + const [ titleCall, descriptionCall, dueCall ] + = vi.mocked(TextInputBuilder).mock.results; + expect( + titleCall.value.setLabel, + "should set title label", + ).toHaveBeenCalledWith("Title"); + expect( + titleCall.value.setRequired, + "should set title required", + ).toHaveBeenCalledWith(true); + expect( + titleCall.value.setStyle, + "should set title style", + ).toHaveBeenCalledWith(TextInputStyle.Short); + expect( + titleCall.value.setCustomId, + "should set title custom id", + ).toHaveBeenCalledWith("title"); + expect( + descriptionCall.value.setLabel, + "should set description label", + ).toHaveBeenCalledWith("Description"); + expect( + descriptionCall.value.setRequired, + "should set description required", + ).toHaveBeenCalledWith(true); + expect( + descriptionCall.value.setStyle, + "should set description style", + ).toHaveBeenCalledWith(TextInputStyle.Paragraph); + expect( + descriptionCall.value.setCustomId, + "should set description custom id", + ).toHaveBeenCalledWith("description"); + expect(dueCall.value.setLabel, "should set due label").toHaveBeenCalledWith( + "Due Date", + ); + expect( + dueCall.value.setRequired, + "should set due required", + ).toHaveBeenCalledWith(true); + expect(dueCall.value.setStyle, "should set due style").toHaveBeenCalledWith( + TextInputStyle.Short, + ); + expect( + dueCall.value.setCustomId, + "should set due custom id", + ).toHaveBeenCalledWith("dueDate"); + expect(ActionRowBuilder, "should create action rows").toHaveBeenCalledTimes( + 3, + ); + const [ rowOneCall, rowTwoCall, rowThreeCall ] + = vi.mocked(ActionRowBuilder).mock.results; + expect( + rowOneCall.value.addComponents, + "should add title to row", + ).toHaveBeenCalledWith(titleCall.value); + expect( + rowTwoCall.value.addComponents, + "should add description to row", + ).toHaveBeenCalledWith(descriptionCall.value); + expect( + rowThreeCall.value.addComponents, + "should add due to row", + ).toHaveBeenCalledWith(dueCall.value); + expect(ModalBuilder, "should create modal").toHaveBeenCalledTimes(1); + expect( + vi.mocked(ModalBuilder).mock.results[0].value.setTitle, + "should set modal title", + ).toHaveBeenCalledWith("New Task"); + expect( + vi.mocked(ModalBuilder).mock.results[0].value.addComponents, + "should add components to modal", + ).toHaveBeenCalledTimes(1); + + expect( + vi.mocked(ModalBuilder).mock.results[0].value.addComponents, + "should add components to modal", + ).toHaveBeenCalledWith( + rowOneCall.value, + rowTwoCall.value, + rowThreeCall.value, + ); + expect( + vi.mocked(ModalBuilder).mock.results[0].value.setCustomId, + "should set modal custom id", + ).toHaveBeenCalledWith("create-task"); + expect( + mockInteraction.showModal, + "should display the modal", + ).toHaveBeenCalledWith(ModalBuilder.mock.results[0].value); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await create.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await create.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commands/delete.spec.ts b/test/commands/delete.spec.ts new file mode 100644 index 0000000..e4d73c0 --- /dev/null +++ b/test/commands/delete.spec.ts @@ -0,0 +1,182 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ApplicationCommandOptionType, + InteractionContextType, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { deleteCommand } from "../../src/commands/delete.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("delete command", () => { + it("should have the correct data", () => { + expect.assertions(11); + expect(deleteCommand.data.name, "did not have the correct name").toBe( + "delete", + ); + expect( + deleteCommand.data.name.length, + "name is too long", + ).toBeLessThanOrEqual(32); + expect(deleteCommand.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect( + deleteCommand.data.description, + "did not have the correct description", + ).toBe( + // eslint-disable-next-line stylistic/max-len + "Mark a task as deleted. WARNING: This will scrub all PII from the task and CANNOT be undone.", + ); + expect( + deleteCommand.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect( + deleteCommand.data.contexts, + "did not have the correct context", + ).toStrictEqual([ InteractionContextType.Guild ]); + expect(deleteCommand.data.options, "should have 1 option").toHaveLength(1); + expect( + deleteCommand.data.options[0].toJSON().name, + "did not have the correct option name", + ).toBe("id"); + expect( + deleteCommand.data.options[0].toJSON().description, + "did not have the correct option description", + ).toBe("The ID of the task to delete."); + expect( + deleteCommand.data.options[0].toJSON().required, + "did not have the correct option required value", + ).toBeTruthy(); + expect( + deleteCommand.data.options[0].toJSON().type, + "did not have the correct option type", + ).toBe(ApplicationCommandOptionType.Integer); + }); + + it("should execute correctly", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + update: vi.fn().mockReturnValue({ + catch: vi.fn().mockReturnValue([ { assignees: [ "123" ] } ]), + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { getInteger: vi.fn().mockReturnValue(1) }, + } as never; + await deleteCommand.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.update, + "should update database", + ).toHaveBeenCalledWith({ + data: { + assignees: [], + deleted: true, + description: "This task has been deleted.", + dueAt: expect.any(Date), + priority: "deleted", + tags: [], + title: "Deleted Task", + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 has been marked as deleted.", + }); + }); + + it("should execute correctly when task not found", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + update: vi.fn().mockReturnValue({ + catch: vi.fn().mockImplementation((callback) => { + return callback(); + }), + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + }, + } as never; + await deleteCommand.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.update, + "should update database", + ).toHaveBeenCalledWith({ + data: { + assignees: [], + deleted: true, + description: "This task has been deleted.", + dueAt: expect.any(Date), + priority: "deleted", + tags: [], + title: "Deleted Task", + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 does not exist.", + }); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await deleteCommand.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await deleteCommand.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commands/list.spec.ts b/test/commands/list.spec.ts new file mode 100644 index 0000000..2adadf8 --- /dev/null +++ b/test/commands/list.spec.ts @@ -0,0 +1,381 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ApplicationCommandOptionType, + InteractionContextType, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { list } from "../../src/commands/list.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("list command", () => { + it("should have the correct data", () => { + expect.assertions(34); + expect(list.data.name, "did not have the correct name").toBe("list"); + expect(list.data.name.length, "name is too long").toBeLessThanOrEqual(32); + expect(list.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect(list.data.description, "did not have the correct description").toBe( + "List all tasks, with optional filters.", + ); + expect( + list.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect( + list.data.contexts, + "did not have the correct context", + ).toStrictEqual([ InteractionContextType.Guild ]); + expect(list.data.options, "should have 4 options").toHaveLength(4); + expect( + list.data.options[0].toJSON().name, + "did not have the correct option name", + ).toBe("priority"); + expect( + list.data.options[0].toJSON().description, + "did not have the correct option description", + ).toBe("List tasks under this priority."); + expect( + list.data.options[0].toJSON().required, + "did not have the correct option required value", + ).toBeFalsy(); + expect( + list.data.options[0].toJSON().type, + "did not have the correct option type", + ).toBe(ApplicationCommandOptionType.String); + expect( + list.data.options[0].toJSON().choices, + "should have 5 choices", + ).toHaveLength(5); + expect( + list.data.options[0].toJSON().choices[0].name, + "should have the correct name", + ).toBe("Low"); + expect( + list.data.options[0].toJSON().choices[0].value, + "should have the correct value", + ).toBe("low"); + expect( + list.data.options[0].toJSON().choices[1].name, + "should have the correct name", + ).toBe("Medium"); + expect( + list.data.options[0].toJSON().choices[1].value, + "should have the correct value", + ).toBe("medium"); + expect( + list.data.options[0].toJSON().choices[2].name, + "should have the correct name", + ).toBe("High"); + expect( + list.data.options[0].toJSON().choices[2].value, + "should have the correct value", + ).toBe("high"); + expect( + list.data.options[0].toJSON().choices[3].name, + "should have the correct name", + ).toBe("Critical"); + expect( + list.data.options[0].toJSON().choices[3].value, + "should have the correct value", + ).toBe("critical"); + expect( + list.data.options[0].toJSON().choices[4].name, + "should have the correct name", + ).toBe("None"); + expect( + list.data.options[0].toJSON().choices[4].value, + "should have the correct value", + ).toBe("none"); + expect( + list.data.options[1].toJSON().name, + "did not have the correct option name", + ).toBe("tag"); + expect( + list.data.options[1].toJSON().description, + "did not have the correct option description", + ).toBe("List tasks with this tag."); + expect( + list.data.options[1].toJSON().required, + "did not have the correct option required value", + ).toBeFalsy(); + expect( + list.data.options[1].toJSON().type, + "did not have the correct option type", + ).toBe(ApplicationCommandOptionType.String); + expect( + list.data.options[2].toJSON().name, + "did not have the correct option name", + ).toBe("assignee"); + expect( + list.data.options[2].toJSON().description, + "did not have the correct option description", + ).toBe("List tasks assigned to this user."); + expect( + list.data.options[2].toJSON().required, + "did not have the correct option required value", + ).toBeFalsy(); + expect( + list.data.options[2].toJSON().type, + "did not have the correct option type", + ).toBe(ApplicationCommandOptionType.User); + expect( + list.data.options[3].toJSON().name, + "did not have the correct option name", + ).toBe("completed"); + expect( + list.data.options[3].toJSON().description, + "did not have the correct option description", + ).toBe("List completed tasks."); + expect( + list.data.options[3].toJSON().required, + "did not have the correct option required value", + ).toBeFalsy(); + expect( + list.data.options[3].toJSON().type, + "did not have the correct option type", + ).toBe(ApplicationCommandOptionType.Boolean); + }); + + it("should execute correctly with no filters", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + findMany: vi.fn().mockImplementation((data) => { + return [ + { + assignees: [], + completed: false, + deleted: false, + description: "Task 1 description", + dueAt: new Date("September 4, 2000"), + numericalId: 1, + priority: "none", + tags: [], + title: "Task 1", + }, + { + assignees: [ "123", "456" ], + completed: true, + deleted: true, + description: "Task 2 description", + dueAt: new Date(), + numericalId: 2, + priority: "low", + tags: [ "tag1", "tag2" ], + title: "Task 2", + }, + { + assignees: [ "789" ], + completed: false, + deleted: false, + description: "Task 3 description", + dueAt: new Date("October 8, 2001"), + numericalId: 3, + priority: "medium", + tags: [ "tag1" ], + title: "Task 3", + }, + ].filter((task) => { + return Object.entries(data.where).every(([ key, value ]) => { + return task[key] === value; + }); + }); + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getBoolean: vi.fn().mockReturnValue(null), + getString: vi.fn().mockReturnValue(null), + getUser: vi.fn().mockReturnValue(null), + }, + } as never; + await list.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findMany, + "should query database", + ).toHaveBeenCalledWith({ + where: { + completed: false, + deleted: false, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "- Task 1: Task 1\n- Task 3: Task 3", + }); + }); + + it("should execute correctly with no data", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + findMany: vi.fn().mockImplementation(() => { + return []; + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getBoolean: vi.fn().mockReturnValue(false), + getString: vi.fn().mockReturnValue(null), + getUser: vi.fn().mockReturnValue(null), + }, + } as never; + await list.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findMany, + "should query database", + ).toHaveBeenCalledWith({ + where: { + completed: false, + deleted: false, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "No tasks found with this current filter.", + }); + }); + + it("should execute correctly with filters", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + findMany: vi.fn().mockImplementation((data) => { + return [ + { + assignees: [], + completed: false, + deleted: false, + description: "Task 1 description", + dueAt: new Date(), + numericalId: 1, + priority: "none", + tags: [], + title: "Task 1", + }, + { + assignees: [ "123", "456" ], + completed: true, + deleted: false, + description: "Task 2 description", + dueAt: new Date(), + numericalId: 2, + priority: "low", + tags: [ "tag1", "tag2" ], + title: "Task 2 title", + }, + { + assignees: [ "789" ], + completed: false, + deleted: true, + description: "Task 3 description", + dueAt: new Date(), + numericalId: 3, + priority: "medium", + tags: [ "tag1" ], + title: "Task 3", + }, + ].filter((task) => { + return Object.entries(data.where).every(([ key, value ]) => { + if (typeof value === "object") { + return task[key].includes(value.has); + } + return task[key] === value; + }); + }); + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getBoolean: vi.fn().mockReturnValue(true), + getString: vi.fn().mockImplementation((name) => { + return name === "priority" + ? "low" + : "tag1"; + }), + getUser: vi.fn().mockReturnValue({ id: "123" }), + }, + } as never; + await list.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findMany, + "should query database", + ).toHaveBeenCalledWith({ + where: { + assignees: { has: "123" }, + completed: true, + deleted: false, + priority: "low", + tags: { has: "tag1" }, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "- Task 2: Task 2 title", + }); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await list.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await list.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commands/priority.spec.ts b/test/commands/priority.spec.ts new file mode 100644 index 0000000..e3cea2a --- /dev/null +++ b/test/commands/priority.spec.ts @@ -0,0 +1,243 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ApplicationCommandOptionType, + InteractionContextType, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { priority } from "../../src/commands/priority.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("priority command", () => { + it("should have the correct data", () => { + expect.assertions(27); + expect(priority.data.name, "did not have the correct name").toBe( + "priority", + ); + expect(priority.data.name.length, "name is too long").toBeLessThanOrEqual( + 32, + ); + expect(priority.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect( + priority.data.description, + "did not have the correct description", + ).toBe("Set the priority of a task."); + expect( + priority.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect( + priority.data.contexts, + "did not have the correct context", + ).toStrictEqual([ InteractionContextType.Guild ]); + expect(priority.data.options, "should have 2 options").toHaveLength(2); + expect( + priority.data.options[0].toJSON().name, + "should have the correct name", + ).toBe("task"); + expect( + priority.data.options[0].toJSON().description, + "should have the correct description", + ).toBe("The task number."); + expect( + priority.data.options[0].toJSON().required, + "should be required", + ).toBeTruthy(); + expect( + priority.data.options[0].toJSON()["min_value"], + "should have a min value of 1", + ).toBe(1); + expect( + priority.data.options[0].toJSON().type, + "should be a number option", + ).toBe(ApplicationCommandOptionType.Integer); + + expect( + priority.data.options[1].toJSON().name, + "should have the correct name", + ).toBe("priority"); + expect( + priority.data.options[1].toJSON().description, + "should have the correct description", + ).toBe("The priority level."); + expect( + priority.data.options[1].toJSON().required, + "should be required", + ).toBeTruthy(); + expect( + priority.data.options[1].toJSON().choices, + "should have choices", + ).toHaveLength(5); + expect( + priority.data.options[1].toJSON().choices[0].name, + "should have the correct name", + ).toBe("Low"); + expect( + priority.data.options[1].toJSON().choices[0].value, + "should have the correct value", + ).toBe("low"); + expect( + priority.data.options[1].toJSON().choices[1].name, + "should have the correct name", + ).toBe("Medium"); + expect( + priority.data.options[1].toJSON().choices[1].value, + "should have the correct value", + ).toBe("medium"); + expect( + priority.data.options[1].toJSON().choices[2].name, + "should have the correct name", + ).toBe("High"); + expect( + priority.data.options[1].toJSON().choices[2].value, + "should have the correct value", + ).toBe("high"); + expect( + priority.data.options[1].toJSON().choices[3].name, + "should have the correct name", + ).toBe("Critical"); + expect( + priority.data.options[1].toJSON().choices[3].value, + "should have the correct value", + ).toBe("critical"); + expect( + priority.data.options[1].toJSON().choices[4].name, + "should have the correct name", + ).toBe("None"); + expect( + priority.data.options[1].toJSON().choices[4].value, + "should have the correct value", + ).toBe("none"); + expect( + priority.data.options[1].toJSON().type, + "should be a string option", + ).toBe(ApplicationCommandOptionType.String); + }); + + it("should execute correctly", async() => { + expect.assertions(4); + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockResolvedValue({ + numericalId: 1, + }), + update: vi.fn().mockResolvedValue({}), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + getString: vi.fn().mockReturnValue("low"), + }, + } as never; + await priority.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should call findFirst", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockBot.database.tasks.update, + "should call update", + ).toHaveBeenCalledWith({ + data: { + priority: "low", + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 priority set to low.", + }); + }); + + it("should execute correctly when task not found", async() => { + expect.assertions(4); + vi.resetAllMocks(); + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({}), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + getString: vi.fn().mockReturnValue("low"), + }, + } as never; + await priority.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should call findFirst", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockBot.database.tasks.update, + "should not call update", + ).not.toHaveBeenCalled(); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 not found.", + }); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await priority.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await priority.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commands/tag.spec.ts b/test/commands/tag.spec.ts new file mode 100644 index 0000000..8c03c15 --- /dev/null +++ b/test/commands/tag.spec.ts @@ -0,0 +1,251 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ApplicationCommandOptionType, + InteractionContextType, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { tag } from "../../src/commands/tag.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("tag command", () => { + it("should have the correct data", () => { + expect.assertions(17); + expect(tag.data.name, "did not have the correct name").toBe("tag"); + expect(tag.data.name.length, "name is too long").toBeLessThanOrEqual(32); + expect(tag.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect(tag.data.description, "did not have the correct description").toBe( + "Add or remove a tag from a task.", + ); + expect( + tag.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect(tag.data.contexts, "did not have the correct context").toStrictEqual( + [ InteractionContextType.Guild ], + ); + expect(tag.data.options, "should have 2 options").toHaveLength(2); + expect( + tag.data.options[0].toJSON().name, + "should have the correct name", + ).toBe("task"); + expect( + tag.data.options[0].toJSON().description, + "should have the correct description", + ).toBe("The task number."); + expect( + tag.data.options[0].toJSON().required, + "should be required", + ).toBeTruthy(); + expect( + tag.data.options[0].toJSON()["min_value"], + "should have a min value of 1", + ).toBe(1); + expect(tag.data.options[0].toJSON().type, "should be a number option").toBe( + ApplicationCommandOptionType.Integer, + ); + + expect( + tag.data.options[1].toJSON().name, + "should have the correct name", + ).toBe("tag"); + expect( + tag.data.options[1].toJSON().description, + "should have the correct description", + ).toBe("The tag."); + expect( + tag.data.options[1].toJSON().required, + "should be required", + ).toBeTruthy(); + expect( + tag.data.options[1].toJSON().choices, + "should not have choices", + ).toBeUndefined(); + expect(tag.data.options[1].toJSON().type, "should be a string option").toBe( + ApplicationCommandOptionType.String, + ); + }); + + it("should execute correctly when adding tag", async() => { + expect.assertions(4); + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockResolvedValue({ + numericalId: 1, + tags: [], + }), + update: vi.fn().mockResolvedValue({}), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + getString: vi.fn().mockReturnValue("discord"), + }, + } as never; + await tag.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should call findFirst", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockBot.database.tasks.update, + "should call update", + ).toHaveBeenCalledWith({ + data: { + tags: { + push: "discord", + }, + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Tag discord added to task 1.", + }); + }); + + it("should execute correctly when removing tag", async() => { + expect.assertions(4); + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockResolvedValue({ + numericalId: 1, + tags: [ "discord", "website" ], + }), + update: vi.fn().mockResolvedValue({}), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + getString: vi.fn().mockReturnValue("discord"), + }, + } as never; + await tag.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should call findFirst", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockBot.database.tasks.update, + "should call update", + ).toHaveBeenCalledWith({ + data: { + tags: [ "website" ], + }, + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Tag discord removed from task 1.", + }); + }); + + it("should execute correctly when task not found", async() => { + expect.assertions(4); + vi.resetAllMocks(); + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({}), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + getString: vi.fn().mockReturnValue("low"), + }, + } as never; + await tag.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should call findFirst", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockBot.database.tasks.update, + "should not call update", + ).not.toHaveBeenCalled(); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 not found.", + }); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await tag.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await tag.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commands/update.spec.ts b/test/commands/update.spec.ts new file mode 100644 index 0000000..57fb9f1 --- /dev/null +++ b/test/commands/update.spec.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ActionRowBuilder, + InteractionContextType, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { update } from "../../src/commands/update.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("discord.js", async() => { + const actual = await vi.importActual("discord.js"); + return { + ...actual, + ActionRowBuilder: vi.fn(() => { + return { + addComponents: vi.fn().mockReturnThis(), + }; + }), + ModalBuilder: vi.fn(() => { + return { + addComponents: vi.fn().mockReturnThis(), + setCustomId: vi.fn().mockReturnThis(), + setTitle: vi.fn().mockReturnThis(), + }; + }), + TextInputBuilder: vi.fn(() => { + return { + setCustomId: vi.fn().mockReturnThis(), + setLabel: vi.fn().mockReturnThis(), + setRequired: vi.fn().mockReturnThis(), + setStyle: vi.fn().mockReturnThis(), + }; + }), + TextInputStyle: { Paragraph: "Paragraph", Short: "Short" }, + }; +}); +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("update command", () => { + it("should have the correct data", () => { + expect.assertions(7); + expect(update.data.name, "did not have the correct name").toBe("update"); + expect(update.data.name.length, "name is too long").toBeLessThanOrEqual(32); + expect(update.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect( + update.data.description, + "did not have the correct description", + ).toBe("Update a task."); + expect( + update.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect( + update.data.contexts, + "did not have the correct context", + ).toStrictEqual([ InteractionContextType.Guild ]); + expect(update.data.options, "should not have options").toHaveLength(0); + }); + + it("should execute correctly", async() => { + expect.assertions(28); + const mockBot = {} as never; + const mockInteraction = { + showModal: vi.fn(), + } as never; + await update.run(mockBot, mockInteraction); + expect(TextInputBuilder, "should create text inputs").toHaveBeenCalledTimes( + 4, + ); + const [ taskNumberCall, titleCall, descriptionCall, dueCall ] + = vi.mocked(TextInputBuilder).mock.results; + expect( + taskNumberCall.value.setLabel, + "should set task number label", + ).toHaveBeenCalledWith("Task Number"); + expect( + taskNumberCall.value.setRequired, + "should set task number required", + ).toHaveBeenCalledWith(true); + expect( + taskNumberCall.value.setStyle, + "should set task number style", + ).toHaveBeenCalledWith(TextInputStyle.Short); + expect( + taskNumberCall.value.setCustomId, + "should set task number custom id", + ).toHaveBeenCalledWith("taskNumber"); + expect( + titleCall.value.setLabel, + "should set title label", + ).toHaveBeenCalledWith("Title"); + expect( + titleCall.value.setRequired, + "should not set title required", + ).toHaveBeenCalledWith(false); + expect( + titleCall.value.setStyle, + "should set title style", + ).toHaveBeenCalledWith(TextInputStyle.Short); + expect( + titleCall.value.setCustomId, + "should set title custom id", + ).toHaveBeenCalledWith("title"); + expect( + descriptionCall.value.setLabel, + "should set description label", + ).toHaveBeenCalledWith("Description"); + expect( + descriptionCall.value.setRequired, + "should not set description required", + ).toHaveBeenCalledWith(false); + expect( + descriptionCall.value.setStyle, + "should set description style", + ).toHaveBeenCalledWith(TextInputStyle.Paragraph); + expect( + descriptionCall.value.setCustomId, + "should set description custom id", + ).toHaveBeenCalledWith("description"); + expect(dueCall.value.setLabel, "should set due label").toHaveBeenCalledWith( + "Due Date", + ); + expect( + dueCall.value.setRequired, + "should not set due required", + ).toHaveBeenCalledWith(false); + expect(dueCall.value.setStyle, "should set due style").toHaveBeenCalledWith( + TextInputStyle.Short, + ); + expect( + dueCall.value.setCustomId, + "should set due custom id", + ).toHaveBeenCalledWith("dueDate"); + expect(ActionRowBuilder, "should create action rows").toHaveBeenCalledTimes( + 4, + ); + const [ rowZeroCall, rowOneCall, rowTwoCall, rowThreeCall ] + = vi.mocked(ActionRowBuilder).mock.results; + expect( + rowZeroCall.value.addComponents, + "should add number to row", + ).toHaveBeenCalledWith(taskNumberCall.value); + expect( + rowOneCall.value.addComponents, + "should add title to row", + ).toHaveBeenCalledWith(titleCall.value); + expect( + rowTwoCall.value.addComponents, + "should add description to row", + ).toHaveBeenCalledWith(descriptionCall.value); + expect( + rowThreeCall.value.addComponents, + "should add due to row", + ).toHaveBeenCalledWith(dueCall.value); + expect(ModalBuilder, "should update modal").toHaveBeenCalledTimes(1); + expect( + vi.mocked(ModalBuilder).mock.results[0].value.setTitle, + "should set modal title", + ).toHaveBeenCalledWith("Update Task"); + expect( + vi.mocked(ModalBuilder).mock.results[0].value.addComponents, + "should add components to modal", + ).toHaveBeenCalledTimes(1); + + expect( + vi.mocked(ModalBuilder).mock.results[0].value.addComponents, + "should add components to modal", + ).toHaveBeenCalledWith( + rowZeroCall.value, + rowOneCall.value, + rowTwoCall.value, + rowThreeCall.value, + ); + expect( + vi.mocked(ModalBuilder).mock.results[0].value.setCustomId, + "should set modal custom id", + ).toHaveBeenCalledWith("update-task"); + expect( + mockInteraction.showModal, + "should display the modal", + ).toHaveBeenCalledWith(ModalBuilder.mock.results[0].value); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await update.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await update.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/commands/view.spec.ts b/test/commands/view.spec.ts new file mode 100644 index 0000000..2cb43c6 --- /dev/null +++ b/test/commands/view.spec.ts @@ -0,0 +1,271 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ApplicationCommandOptionType, + InteractionContextType, +} from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { view } from "../../src/commands/view.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; +}); + +describe("view command", () => { + it("should have the correct data", () => { + expect.assertions(11); + expect(view.data.name, "did not have the correct name").toBe("view"); + expect(view.data.name.length, "name is too long").toBeLessThanOrEqual(32); + expect(view.data.name, "name has invalid characters").toMatch( + /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u, + ); + expect(view.data.description, "did not have the correct description").toBe( + "View a task by its ID.", + ); + expect( + view.data.description.length, + "description is too long", + ).toBeLessThanOrEqual(100); + expect( + view.data.contexts, + "did not have the correct context", + ).toStrictEqual([ InteractionContextType.Guild ]); + expect(view.data.options, "should have 1 option").toHaveLength(1); + expect( + view.data.options[0].toJSON().name, + "did not have the correct option name", + ).toBe("id"); + expect( + view.data.options[0].toJSON().description, + "did not have the correct option description", + ).toBe("The ID of the task to view."); + expect( + view.data.options[0].toJSON().required, + "did not have the correct option required value", + ).toBeTruthy(); + expect( + view.data.options[0].toJSON().type, + "did not have the correct option type", + ).toBe(ApplicationCommandOptionType.Integer); + }); + + it("should execute correctly", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + findUnique: vi.fn().mockReturnValue({ + assignees: [ "123" ], + completed: false, + deleted: false, + description: "Task 1 description", + dueAt: new Date("2021-10-10"), + numericalId: 1, + priority: "critical", + tags: [ "discord" ], + title: "Task 1", + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { getInteger: vi.fn().mockReturnValue(1) }, + } as never; + await view.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findUnique, + "should query database", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + embeds: [ + { + description: "Task 1 description", + fields: [ + { inline: true, name: "Priority", value: "critical" }, + { inline: true, name: "Completed", value: "No" }, + { name: "Tag", value: "discord" }, + { name: "Assignee", value: "<@123>" }, + ], + title: "Task 1", + }, + ], + }); + }); + + it("should execute correctly with a completed task", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + findUnique: vi.fn().mockReturnValue({ + assignees: [ "123" ], + completed: true, + deleted: false, + description: "Task 1 description", + dueAt: new Date("2021-10-10"), + numericalId: 1, + priority: "critical", + tags: [ "discord" ], + title: "Task 1", + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { getInteger: vi.fn().mockReturnValue(1) }, + } as never; + await view.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findUnique, + "should query database", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + embeds: [ + { + description: "Task 1 description", + fields: [ + { inline: true, name: "Priority", value: "critical" }, + { inline: true, name: "Completed", value: "Yes" }, + { name: "Tag", value: "discord" }, + { name: "Assignee", value: "<@123>" }, + ], + title: "Task 1", + }, + ], + }); + }); + + it("should execute correctly with a deleted task", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + findUnique: vi.fn().mockReturnValue({ + assignees: [ "123" ], + completed: false, + deleted: true, + description: "Task 1 description", + dueAt: new Date("2021-10-10"), + numericalId: 1, + priority: "critical", + tags: [ "discord" ], + title: "Task 1", + }), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { getInteger: vi.fn().mockReturnValue(1) }, + } as never; + await view.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findUnique, + "should query database", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 has been deleted.", + }); + }); + + it("should execute correctly when task not found", async() => { + expect.assertions(3); + const mockBot = { + database: { + tasks: { + findUnique: vi.fn().mockReturnValue(null), + }, + }, + } as never; + const mockInteraction = { + deferReply: vi.fn(), + editReply: vi.fn(), + options: { + getInteger: vi.fn().mockReturnValue(1), + }, + } as never; + await view.run(mockBot, mockInteraction); + expect( + mockInteraction.deferReply, + "should defer the reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findUnique, + "should query database", + ).toHaveBeenCalledWith({ + where: { + numericalId: 1, + }, + }); + expect( + mockInteraction.editReply, + "should call editReply", + ).toHaveBeenCalledWith({ + content: "Task 1 not found.", + }); + }); + + it("should handle errors correctly", async() => { + expect.assertions(1); + await view.run( + {} as never, + { editReply: vi.fn(), replied: false, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should handle errors with interaction.reply correctly", async() => { + expect.assertions(1); + vi.resetAllMocks(); + await view.run( + {} as never, + { editReply: vi.fn(), replied: true, reply: vi.fn() } as never, + ); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/config/intents.spec.ts b/test/config/intents.spec.ts new file mode 100644 index 0000000..ab41afc --- /dev/null +++ b/test/config/intents.spec.ts @@ -0,0 +1,18 @@ + +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { GatewayIntentBits } from "discord.js"; +import { describe, it, expect } from "vitest"; +import { intents } from "../../src/config/intents.ts"; + +describe("intents", () => { + it("should include expected intents", () => { + expect.assertions(1); + expect(intents, "missing guilds"). + toContain(GatewayIntentBits.Guilds); + }); +}); diff --git a/test/events/onInteractionCreate.spec.ts b/test/events/onInteractionCreate.spec.ts new file mode 100644 index 0000000..160b30d --- /dev/null +++ b/test/events/onInteractionCreate.spec.ts @@ -0,0 +1,156 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { onInteractionCreate } from "../../src/events/onInteractionCreate.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; + +vi.mock("../../src/utils/sendDebugLog.ts", () => { + return { + sendDebugLog: vi.fn(), + }; +}); + +const mockBot = { + env: { + discordDebugWebhook: { + send: vi.fn(), + }, + }, +}; + +describe("onInteractionCreate", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should respond with the correct message when command not in guild", + async() => { + expect.assertions(1); + const mockInteraction = { + commandName: "test", + inCachedGuild: vi.fn().mockReturnValue(false), + isChatInputCommand: vi.fn().mockReturnValue(true), + reply: vi.fn(), + }; + await onInteractionCreate(mockBot as never, mockInteraction as never); + expect(mockInteraction.reply, "should reply with correct body"). + toHaveBeenCalledWith({ + content: "How did you run this out of a guild?", + ephemeral: true, + }); + }); + + it("should handle chat input commands", async() => { + expect.assertions(1); + const mockInteraction = { + commandName: "test", + inCachedGuild: vi.fn().mockReturnValue(true), + isChatInputCommand: vi.fn().mockReturnValue(true), + reply: vi.fn(), + }; + await onInteractionCreate(mockBot as never, mockInteraction as never); + expect(mockInteraction.reply, "should reply with correct body"). + toHaveBeenCalledWith({ + content: "Interaction test not found.", + ephemeral: true, + }); + }); + + it("should respond with the correct message when modal not in guild", + async() => { + expect.assertions(1); + const mockInteraction = { + customId: "test", + inCachedGuild: vi.fn().mockReturnValue(false), + isChatInputCommand: vi.fn().mockReturnValue(false), + isModalSubmit: vi.fn().mockReturnValue(true), + reply: vi.fn(), + }; + await onInteractionCreate(mockBot as never, mockInteraction as never); + expect(mockInteraction.reply, "should reply with correct body"). + toHaveBeenCalledWith({ + content: "How did you get a modal outside of a guild?", + ephemeral: true, + }); + }); + + it("should handle modal submit interactions", async() => { + expect.assertions(1); + const mockInteraction = { + customId: "test", + inCachedGuild: vi.fn().mockReturnValue(true), + isChatInputCommand: vi.fn().mockReturnValue(false), + isModalSubmit: vi.fn().mockReturnValue(true), + reply: vi.fn(), + }; + await onInteractionCreate(mockBot as never, mockInteraction as never); + expect(mockInteraction.reply, "should reply with correct body"). + toHaveBeenCalledWith({ + content: `Modal test has no handler.`, + ephemeral: true, + }); + }); + + it("should throw an error if the interaction type is unknown", async() => { + expect.assertions(1); + const mockInteraction = { + customId: "test", + inCachedGuild: vi.fn().mockReturnValue(true), + isAutocomplete: vi.fn().mockReturnValue(false), + isChatInputCommand: vi.fn().mockReturnValue(false), + isModalSubmit: vi.fn().mockReturnValue(false), + reply: vi.fn(), + }; + await onInteractionCreate(mockBot as never, mockInteraction as never); + expect(errorHandler, "should call error handler"). + toHaveBeenCalledTimes(1); + }); + + it("should call the error handler if an error is thrown", async() => { + expect.assertions(1); + const mockInteraction = { + commandName: "test", + editReply: vi.fn(), + inCachedGuild: vi.fn().mockReturnValue(true), + isAutocomplete: vi.fn().mockReturnValue(false), + isChatInputCommand: vi.fn().mockImplementation(() => { + throw new Error("Test error"); + }), + replied: true, + reply: vi.fn(), + }; + vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; + }); + await onInteractionCreate(mockBot as never, mockInteraction as never); + expect(errorHandler, "should call error handler"). + toHaveBeenCalledTimes(1); + }); + + it("should call the error handler if autocomplete is thrown", async() => { + expect.assertions(1); + const mockInteraction = { + commandName: "test", + inCachedGuild: vi.fn().mockReturnValue(true), + isAutocomplete: vi.fn().mockReturnValue(true), + isChatInputCommand: vi.fn().mockImplementation(() => { + throw new Error("Test error"); + }), + reply: vi.fn(), + }; + vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; + }); + await onInteractionCreate(mockBot as never, mockInteraction as never); + expect(errorHandler, "should call error handler"). + toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/events/onReady.spec.ts b/test/events/onReady.spec.ts new file mode 100644 index 0000000..43a633b --- /dev/null +++ b/test/events/onReady.spec.ts @@ -0,0 +1,99 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { onReady } from "../../src/events/onReady.js"; +import { displayCommandCurl } from "../../src/utils/displayCommandCurl.js"; +import { errorHandler } from "../../src/utils/errorHandler.js"; +import { sendDebugLog } from "../../src/utils/sendDebugLog.js"; + +vi.mock("../../src/utils/sendDebugLog.ts", () => { + return { + sendDebugLog: vi.fn(), + }; +}); + +const mockBot = { + discord: { + user: { + id: "123", + }, + }, + env: { + discordDebugWebhook: { + send: vi.fn(), + }, + }, +}; + +describe("onReady", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call sendDebugLog with the correct messages", async() => { + expect.assertions(4); + vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; + }); + await onReady(mockBot as never); + + expect(errorHandler, "should call error handler").not.toHaveBeenCalled(); + + expect(sendDebugLog, "should send debug message").toHaveBeenCalledTimes(2); + expect(sendDebugLog, "should send debug message").toHaveBeenCalledWith( + mockBot, + { + content: "Bot has authenticated to Discord.", + }, + ); + expect(sendDebugLog, "should send CURL string").toHaveBeenCalledWith( + mockBot, + { + files: [ + { + attachment: Buffer.from(displayCommandCurl(mockBot as never)), + name: "curl.sh", + }, + ], + }, + ); + }); + + it("should not throw an error", async() => { + expect.assertions(1); + await expect( + onReady(mockBot as never), + "should not error", + ).resolves.not.toThrow(); + }); + + it("should call the error handler if an error is thrown", async() => { + expect.assertions(1); + vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; + }); + + vi.mock("../../src/utils/sendDebugLog.ts", () => { + return { + sendDebugLog: vi.fn().mockRejectedValue(new Error("Test error")), + }; + }); + + await onReady({} as never); + + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + vi.mock("../../src/utils/sendDebugLog.ts", () => { + return { + sendDebugLog: vi.fn(), + }; + }); + }); +}); diff --git a/test/index.spec.ts b/test/index.spec.ts index 8d1f4ed..b00ed8b 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -3,3 +3,132 @@ * @license Naomi's Public License * @author Naomi Carrigan */ + +import { PrismaClient } from "@prisma/client"; +import { Client, Events, WebhookClient } from "discord.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { intents } from "../src/config/intents.js"; +import { onInteractionCreate } from "../src/events/onInteractionCreate.js"; +import { onReady } from "../src/events/onReady.js"; +import { boot } from "../src/index.js"; +import { validateEnvironmentVariables } + from "../src/utils/validateEnvironmentVariables.js"; + +vi.mock("@prisma/client"); +vi.mock("discord.js"); +vi.mock("../src/utils/sendDebugLog.js"); +vi.mock("../src/utils/validateEnvironmentVariables.js"); +vi.mock("../src/events/onReady.ts", () => { + return { + onReady: vi.fn(), + }; +}); +vi.mock("../src/events/onInteractionCreate.ts", () => { + return { + onInteractionCreate: vi.fn(), + }; +}); + +const mockBot = { + database: { + $connect: vi.fn().mockResolvedValue(undefined), + } as never as PrismaClient, + discord: { + login: vi.fn().mockResolvedValue("Logged in"), + on: vi.fn(), + } as never as Client, + env: { + discordToken: "mock-token", + // Add other necessary environment variables + }, +}; + +describe("boot function", () => { + beforeEach(() => { + vi.resetAllMocks(); + + vi.mocked(Client).mockImplementation(() => { + return mockBot.discord; + }); + vi.mocked(PrismaClient).mockImplementation(() => { + return mockBot.database; + }); + vi.mocked(validateEnvironmentVariables).mockReturnValue( + mockBot.env as never, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should initialize the bot and connect to all platforms", async() => { + expect.assertions(9); + await boot(); + + expect( + validateEnvironmentVariables, + "should validate env", + ).toHaveBeenCalledWith(); + expect(Client, "should construct discord bot").toHaveBeenCalledWith({ + intents, + }); + expect(PrismaClient, "should construct prisma client"). + toHaveBeenCalledTimes(1); + expect( + mockBot.database.$connect, + "should connect to database", + ).toHaveBeenCalledTimes(1); + expect(mockBot.discord.on, "did not mount interaction create"). + toHaveBeenCalledWith( + Events.InteractionCreate, + expect.any(Function), + ); + const interactionCallback = mockBot.discord.on.mock.calls.find((call) => { + return call[0] === Events.InteractionCreate; + })?.[1]; + interactionCallback({} as never); + expect(onInteractionCreate, "should call oninteractioncreate"). + toHaveBeenCalledWith(mockBot, {}); + expect(mockBot.discord.on, "did not mount ready").toHaveBeenCalledWith( + Events.ClientReady, + expect.any(Function), + ); + + const readyCallback = mockBot.discord.on.mock.calls.find((call) => { + return call[0] === Events.ClientReady; + })?.[1]; + readyCallback(); + expect(onReady, "should call onready").toHaveBeenCalledWith(mockBot); + expect( + mockBot.discord.login, + "should login to Discord", + ).toHaveBeenCalledWith(mockBot.env.discordToken); + }); + + it("should handle errors and send them to the debug webhook", async() => { + expect.assertions(1); + const mockError = new Error("Test error"); + vi.mocked(validateEnvironmentVariables).mockImplementation(() => { + throw mockError; + }); + + const mockWebhookSend = vi.fn().mockResolvedValue(undefined); + vi.mocked(WebhookClient).mockImplementation(() => { + return { + send: mockWebhookSend, + } as unknown as WebhookClient; + }); + + process.env.DISCORD_DEBUG_WEBHOOK = "https://mock-webhook-url"; + + await boot(); + + expect( + mockWebhookSend, + "should send directly to webhook", + ).toHaveBeenCalledWith({ + content: `Error: ${JSON.stringify(mockError, null, 2)}`, + }); + }); +}); diff --git a/test/modules/defaultCommand.spec.ts b/test/modules/defaultCommand.spec.ts new file mode 100644 index 0000000..c744709 --- /dev/null +++ b/test/modules/defaultCommand.spec.ts @@ -0,0 +1,24 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { describe, it, expect, vi } from "vitest"; +import { defaultCommand } from "../../src/modules/defaultCommand.js"; + +describe("defaultCommand", () => { + it("should respond with expected values", async() => { + expect.assertions(1); + const mockInteraction = { + commandName: "test", + reply: vi.fn(), + }; + await defaultCommand({} as never, mockInteraction as never); + expect(mockInteraction.reply, "should reply with correct body"). + toHaveBeenCalledWith({ + content: "Interaction test not found.", + ephemeral: true, + }); + }); +}); diff --git a/test/modules/modalHandlers.spec.ts b/test/modules/modalHandlers.spec.ts new file mode 100644 index 0000000..0d9af74 --- /dev/null +++ b/test/modules/modalHandlers.spec.ts @@ -0,0 +1,491 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { describe, it, expect, vi } from "vitest"; +import { + createModal, + defaultModal, + updateModal, +} from "../../src/modules/modalHandlers.ts"; +import { errorHandler } from "../../src/utils/errorHandler.ts"; + +describe("default modal", () => { + it("should send the expected response", async() => { + expect.assertions(1); + const mockInteraction = { + customId: "test", + reply: vi.fn(), + }; + await defaultModal({} as never, mockInteraction as never); + expect( + mockInteraction.reply, + "should reply with correct body", + ).toHaveBeenCalledWith({ + content: "Modal test has no handler.", + ephemeral: true, + }); + }); +}); + +describe("create modal", () => { + it("should send the expected response and save to the database", async() => { + expect.assertions(4); + const mockInteraction = { + customId: "create-modal", + deferReply: vi.fn(), + editReply: vi.fn(), + fields: { + getTextInputValue: vi.fn().mockImplementation((value: string) => { + return value === "dueDate" + ? "September 5, 2000" + : value; + }), + }, + reply: vi.fn(), + }; + const mockBot = { + database: { + tasks: { + count: vi.fn().mockReturnValue(1), + create: vi.fn().mockImplementation((data: { data: unknown }) => { + return data.data; + }), + }, + }, + }; + await createModal(mockBot as never, mockInteraction as never); + expect( + mockInteraction.deferReply, + "should defer reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.count, + "should generate an id", + ).toHaveBeenCalledOnce(); + expect( + mockBot.database.tasks.create, + "should save to the database", + ).toHaveBeenCalledWith({ + data: { + description: "description", + dueAt: expect.any(Date), + numericalId: 2, + title: "title", + }, + }); + expect( + mockInteraction.editReply, + "should reply with correct body", + ).toHaveBeenCalledWith({ + content: "Task 2 created.", + }); + }); + + it("should handle the fallback date", async() => { + expect.assertions(4); + const mockInteraction = { + customId: "create-modal", + deferReply: vi.fn(), + editReply: vi.fn(), + fields: { + getTextInputValue: vi.fn().mockImplementation((value: string) => { + return value; + }), + }, + reply: vi.fn(), + }; + const mockBot = { + database: { + tasks: { + count: vi.fn().mockReturnValue(1), + create: vi.fn().mockImplementation((data: { data: unknown }) => { + return data.data; + }), + }, + }, + }; + await createModal(mockBot as never, mockInteraction as never); + expect( + mockInteraction.deferReply, + "should defer reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.count, + "should generate an id", + ).toHaveBeenCalledOnce(); + expect( + mockBot.database.tasks.create, + "should save to the database", + ).toHaveBeenCalledWith({ + data: { + description: "description", + dueAt: expect.any(Date), + numericalId: 2, + title: "title", + }, + }); + expect( + mockInteraction.editReply, + "should reply with correct body", + ).toHaveBeenCalledWith({ + content: "Task 2 created.", + }); + }); + + it("should call the error handler if an error is thrown", async() => { + expect.assertions(1); + const mockInteraction = { + commandName: "test", + inCachedGuild: vi.fn().mockReturnValue(true), + isChatInputCommand: vi.fn().mockImplementation(() => { + throw new Error("Test error"); + }), + reply: vi.fn(), + }; + vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; + }); + await createModal({} as never, mockInteraction as never); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should call the error handler if replied and is thrown", async() => { + expect.assertions(1); + vi.clearAllMocks(); + const mockInteraction = { + commandName: "test", + deferReply: vi.fn().mockImplementation(() => { + throw new Error("Test error"); + }), + editReply: vi.fn(), + replied: true, + }; + const mockBot = { + env: { + debugHook: { + send: vi.fn(), + }, + }, + }; + vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; + }); + await createModal(mockBot as never, mockInteraction as never); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); + +describe("update modal", () => { + it("should respond when the number input is invalid", async() => { + expect.assertions(3); + const mockInteraction = { + customId: "create-modal", + deferReply: vi.fn(), + editReply: vi.fn(), + fields: { + getTextInputValue: vi.fn().mockImplementation((value: string) => { + return value === "dueDate" + ? "September 5, 2000" + : value; + }), + }, + reply: vi.fn(), + }; + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockReturnValue(null), + }, + }, + }; + + await updateModal(mockBot as never, mockInteraction as never); + expect( + mockInteraction.deferReply, + "should defer reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should find the task", + ).not.toHaveBeenCalledOnce(); + expect( + mockInteraction.editReply, + "should reply with correct body", + ).toHaveBeenCalledWith({ + content: "Invalid task number.", + }); + }); + + it("should respond when the task number is not found", async() => { + expect.assertions(3); + const mockInteraction = { + customId: "create-modal", + deferReply: vi.fn(), + editReply: vi.fn(), + fields: { + getTextInputValue: vi.fn().mockImplementation((value: string) => { + // eslint-disable-next-line no-nested-ternary + return value === "dueDate" + ? "September 5, 2000" + : value === "taskNumber" + ? "1" + : value; + }), + }, + reply: vi.fn(), + }; + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockReturnValue(null), + }, + }, + }; + + await updateModal(mockBot as never, mockInteraction as never); + expect( + mockInteraction.deferReply, + "should defer reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should find the task", + ).toHaveBeenCalledWith({ where: { numericalId: 1 } }); + expect( + mockInteraction.editReply, + "should reply with correct body", + ).toHaveBeenCalledWith({ + content: "Task 1 not found.", + }); + }); + + it("should update the task", async() => { + expect.assertions(4); + const mockInteraction = { + customId: "create-modal", + deferReply: vi.fn(), + editReply: vi.fn(), + fields: { + getTextInputValue: vi.fn().mockImplementation((value: string) => { + // eslint-disable-next-line no-nested-ternary + return value === "dueDate" + ? "September 5, 2000" + : value === "taskNumber" + ? "1" + : value; + }), + }, + reply: vi.fn(), + }; + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockReturnValue({ + description: "Naomi's test description", + dueAt: new Date("October 1, 2000"), + numericalId: 1, + title: "Naomi's test task", + }), + update: vi.fn(), + }, + }, + }; + + await updateModal(mockBot as never, mockInteraction as never); + expect( + mockInteraction.deferReply, + "should defer reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should find the task", + ).toHaveBeenCalledWith({ where: { numericalId: 1 } }); + expect( + mockBot.database.tasks.update, + "should update the task", + ).toHaveBeenCalledWith({ + data: { + description: "description", + dueAt: new Date("September 5, 2000"), + title: "title", + }, + where: { numericalId: 1 }, + }); + expect( + mockInteraction.editReply, + "should reply with correct body", + ).toHaveBeenCalledWith({ + content: "Task 1 updated.", + }); + }); + + it("should handle invalid dates", async() => { + expect.assertions(4); + const mockInteraction = { + customId: "create-modal", + deferReply: vi.fn(), + editReply: vi.fn(), + fields: { + getTextInputValue: vi.fn().mockImplementation((value: string) => { + return value === "taskNumber" + ? "1" + : value; + }), + }, + reply: vi.fn(), + }; + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockReturnValue({ + description: "Naomi's test description", + dueAt: new Date("October 1, 2000"), + numericalId: 1, + title: "Naomi's test task", + }), + update: vi.fn(), + }, + }, + }; + + await updateModal(mockBot as never, mockInteraction as never); + expect( + mockInteraction.deferReply, + "should defer reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should find the task", + ).toHaveBeenCalledWith({ where: { numericalId: 1 } }); + expect( + mockBot.database.tasks.update, + "should update the task", + ).toHaveBeenCalledWith({ + data: { + description: "description", + dueAt: expect.any(Date), + title: "title", + }, + where: { numericalId: 1 }, + }); + expect( + mockInteraction.editReply, + "should reply with correct body", + ).toHaveBeenCalledWith({ + content: "Task 1 updated.", + }); + }); + + it("should update the task with all fallback values", async() => { + expect.assertions(4); + const mockInteraction = { + customId: "create-modal", + deferReply: vi.fn(), + editReply: vi.fn(), + fields: { + getTextInputValue: vi.fn().mockImplementation((value: string) => { + return value === "taskNumber" + ? "1" + : ""; + }), + }, + reply: vi.fn(), + }; + const mockBot = { + database: { + tasks: { + findFirst: vi.fn().mockReturnValue({ + description: "Naomi's test description", + dueAt: new Date("October 1, 2000"), + numericalId: 1, + title: "Naomi's test task", + }), + update: vi.fn(), + }, + }, + }; + + await updateModal(mockBot as never, mockInteraction as never); + expect( + mockInteraction.deferReply, + "should defer reply", + ).toHaveBeenCalledWith({ ephemeral: true }); + expect( + mockBot.database.tasks.findFirst, + "should find the task", + ).toHaveBeenCalledWith({ where: { numericalId: 1 } }); + expect( + mockBot.database.tasks.update, + "should update the task", + ).toHaveBeenCalledWith({ + data: {}, + where: { numericalId: 1 }, + }); + expect( + mockInteraction.editReply, + "should reply with correct body", + ).toHaveBeenCalledWith({ + content: "Task 1 updated.", + }); + }); + + it("should call the error handler if an error is thrown", async() => { + expect.assertions(1); + vi.clearAllMocks(); + const mockInteraction = { + commandName: "test", + deferReply: vi.fn().mockImplementation(() => { + throw new Error("Test error"); + }), + reply: vi.fn(), + }; + const mockBot = { + env: { + debugHook: { + send: vi.fn(), + }, + }, + }; + vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; + }); + await updateModal(mockBot as never, mockInteraction as never); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); + + it("should call the error handler if replied and is thrown", async() => { + expect.assertions(1); + vi.clearAllMocks(); + const mockInteraction = { + commandName: "test", + deferReply: vi.fn().mockImplementation(() => { + throw new Error("Test error"); + }), + editReply: vi.fn(), + replied: true, + }; + const mockBot = { + env: { + debugHook: { + send: vi.fn(), + }, + }, + }; + vi.mock("../../src/utils/errorHandler.ts", () => { + return { + errorHandler: vi.fn(), + }; + }); + await updateModal(mockBot as never, mockInteraction as never); + expect(errorHandler, "should call error handler").toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/utils/displayCommandCurl.spec.ts b/test/utils/displayCommandCurl.spec.ts new file mode 100644 index 0000000..5116ac1 --- /dev/null +++ b/test/utils/displayCommandCurl.spec.ts @@ -0,0 +1,33 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect } from "vitest"; +import { displayCommandCurl } from "../../src/utils/displayCommandCurl.js"; + +const expectedCommandObject = `[{"options":[],"name":"create","description":"Create a new task.","contexts":[0],"type":1},{"options":[],"name":"update","description":"Update a task.","contexts":[0],"type":1},{"options":[{"min_value":1,"type":4,"name":"task","description":"The task number.","required":true},{"type":3,"choices":[{"name":"Low","value":"low"},{"name":"Medium","value":"medium"},{"name":"High","value":"high"},{"name":"Critical","value":"critical"},{"name":"None","value":"none"}],"name":"priority","description":"The priority level.","required":true}],"name":"priority","description":"Set the priority of a task.","contexts":[0],"type":1},{"options":[{"min_value":1,"type":4,"name":"task","description":"The task number.","required":true},{"type":3,"name":"tag","description":"The tag.","required":true}],"name":"tag","description":"Add or remove a tag from a task.","contexts":[0],"type":1},{"options":[{"min_value":1,"type":4,"name":"task","description":"The task number.","required":true},{"name":"assignee","description":"The user to (un)assign.","required":true,"type":6}],"name":"assign","description":"Add or remove someone as a task assignee.","contexts":[0],"type":1},{"options":[{"type":3,"choices":[{"name":"Low","value":"low"},{"name":"Medium","value":"medium"},{"name":"High","value":"high"},{"name":"Critical","value":"critical"},{"name":"None","value":"none"}],"name":"priority","description":"List tasks under this priority.","required":false},{"type":3,"name":"tag","description":"List tasks with this tag.","required":false},{"name":"assignee","description":"List tasks assigned to this user.","required":false,"type":6},{"name":"completed","description":"List completed tasks.","required":false,"type":5}],"name":"list","description":"List all tasks, with optional filters.","contexts":[0],"type":1},{"options":[{"min_value":1,"type":4,"name":"id","description":"The ID of the task to view.","required":true}],"name":"view","description":"View a task by its ID.","contexts":[0],"type":1},{"options":[{"min_value":1,"type":4,"name":"id","description":"The ID of the task to complete.","required":true}],"name":"complete","description":"Mark a task as completed.","contexts":[0],"type":1},{"options":[{"min_value":1,"type":4,"name":"id","description":"The ID of the task to delete.","required":true}],"name":"delete","description":"Mark a task as deleted. WARNING: This will scrub all PII from the task and CANNOT be undone.","contexts":[0],"type":1}]`; + +describe("display command curl", () => { + it("should return the expected string", () => { + expect.assertions(1); + const string + = displayCommandCurl({ discord: { user: { id: "123" } } } as never); + + expect(string, "did not return valid curl string").toBe(`curl -X PUT -H "Authorization: Bot {TOKEN}" -H "Content-Type: application/json" --data '${expectedCommandObject}' https://discord.com/api/v10/applications/123/commands`); + }); + + it("should handle the fallback ID", () => { + expect.assertions(1); + const string = displayCommandCurl({ discord: { user: {} } } as never); + + expect(string, "did not return valid curl string").toBe(`curl -X PUT -H "Authorization: Bot {TOKEN}" -H "Content-Type: application/json" --data '${expectedCommandObject}' https://discord.com/api/v10/applications/{ID}/commands`); + }); + + it("should include all commands in the payload", () => { + expect.assertions(1); + const string + = displayCommandCurl({ discord: { user: { id: "123" } } } as never); + expect(string, "missing create command").toContain("\"name\":\"create\""); + }); +}); diff --git a/test/utils/errorHandler.spec.ts b/test/utils/errorHandler.spec.ts new file mode 100644 index 0000000..3a34ae6 --- /dev/null +++ b/test/utils/errorHandler.spec.ts @@ -0,0 +1,96 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { EmbedBuilder } from "discord.js"; +import { describe, it, expect, vi } from "vitest"; +import { errorHandler } from "../../src/utils/errorHandler.ts"; +import { sendDebugLog } from "../../src/utils/sendDebugLog.ts"; + +vi.mock("../../src/utils/sendDebugLog.ts", () => { + return { + sendDebugLog: vi.fn(), + }; +}); + +const mockBot = { + env: { + discordDebugWebhook: { + send: vi.fn(), + }, + }, +}; + +describe("errorHandler", () => { + it("should call sendDebugLog", async() => { + expect.assertions(1); + await errorHandler(mockBot as never, "test", new Error("Test error")); + expect(sendDebugLog, "should send debug log").toHaveBeenCalledTimes(1); + }); + + it("should properly format the embed", async() => { + expect.assertions(1); + const error = new Error("Test error"); + const id = await errorHandler(mockBot as never, "test", error); + expect(sendDebugLog, "should send debug log").toHaveBeenCalledWith( + mockBot, + { + embeds: [ + new EmbedBuilder({ + description: error.message, + fields: [ + { + name: "Stack", + value: `\`\`\`\n${String(error.stack).slice(0, 1000)}`, + }, + ], + footer: { + // eslint-disable-next-line @typescript-eslint/naming-convention + icon_url: undefined, + text: `Error ID: ${id}`, + }, + title: "Error: test", + }), + ], + }, + ); + }); + + it("should handle non-error objects", async() => { + expect.assertions(1); + const id = await errorHandler(mockBot as never, "test", "Test error"); + expect(sendDebugLog, "should send debug log").toHaveBeenCalledWith( + mockBot, + { + embeds: [ + new EmbedBuilder({ + description: "Test error", + footer: { + // eslint-disable-next-line @typescript-eslint/naming-convention + icon_url: undefined, + text: `Error ID: ${id}`, + }, + title: "Error: test", + }), + ], + }, + ); + }); + + it("should call the reply function if provided", async() => { + expect.assertions(1); + const replyFunction = vi.fn().mockName("reply"); + const id = await errorHandler( + mockBot as never, + "test", + new Error("Test error"), + replyFunction, + ); + expect(replyFunction, "should send error ID to user").toHaveBeenCalledWith({ + content: `Oops! Something went wrong! Please reach out to us in our [support server](https://chat.nhcarrigan.com) and bring this error ID: ${id}`, + ephemeral: true, + }); + }); +}); diff --git a/test/utils/sendDebugLog.spec.ts b/test/utils/sendDebugLog.spec.ts new file mode 100644 index 0000000..5338623 --- /dev/null +++ b/test/utils/sendDebugLog.spec.ts @@ -0,0 +1,63 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { describe, it, expect, vi } from "vitest"; +import { sendDebugLog } from "../../src/utils/sendDebugLog.ts"; + +const mockBot = { + discord: { + user: { + displayAvatarURL: vi. + fn(). + mockReturnValue("https://cdn.nhcarrigan.com/nhcarrigan.png"), + username: "Tasks", + }, + }, + env: { + discordDebugWebhook: { + send: vi.fn(), + }, + }, +}; + +describe("send debug log", () => { + it("should send a message to the webhook", () => { + expect.assertions(2); + sendDebugLog(mockBot as never, { content: "Test message." }); + expect( + mockBot.env.discordDebugWebhook.send, + "should send message", + ).toHaveBeenCalledTimes(1); + expect( + mockBot.env.discordDebugWebhook.send, + "should send message", + ).toHaveBeenCalledWith({ + avatarURL: "https://cdn.nhcarrigan.com/nhcarrigan.png", + content: "Test message.", + username: "Tasks", + }); + }); + + it("should fallback when no user", () => { + expect.assertions(2); + // @ts-expect-error - Testing fallback when user is undefined. + mockBot.discord.user = undefined; + vi.resetAllMocks(); + sendDebugLog(mockBot as never, { content: "Test message." }); + expect( + mockBot.env.discordDebugWebhook.send, + "should send message", + ).toHaveBeenCalledTimes(1); + expect( + mockBot.env.discordDebugWebhook.send, + "should send message", + ).toHaveBeenCalledWith({ + avatarURL: "https://cdn.nhcarrigan.com/profile.png", + content: "Test message.", + username: "RIG Task Bot", + }); + }); +}); diff --git a/test/utils/validateEnvironmentVariables.spec.ts b/test/utils/validateEnvironmentVariables.spec.ts new file mode 100644 index 0000000..8bdbe88 --- /dev/null +++ b/test/utils/validateEnvironmentVariables.spec.ts @@ -0,0 +1,63 @@ + +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { describe, it, expect, afterAll, beforeAll } from "vitest"; +import { validateEnvironmentVariables } + from "../../src/utils/validateEnvironmentVariables.js"; + +describe("validate environment variables", () => { + beforeAll(() => { + delete process.env.DISCORD_TOKEN; + delete process.env.DISCORD_DEBUG_WEBHOOK; + }); + afterAll(() => { + delete process.env.DISCORD_TOKEN; + delete process.env.DISCORD_DEBUG_WEBHOOK; + }); + + it("should throw when DISCORD_TOKEN is not set", () => { + expect.assertions(1); + expect(() => { + validateEnvironmentVariables(); + }, + "did not throw on missing DISCORD_TOKEN"). + toThrow(new ReferenceError("DISCORD_TOKEN cannot be undefined.")); + }); + + it("should throw when DISCORD_DEBUG_WEBHOOK is not set", () => { + expect.assertions(1); + process.env.DISCORD_TOKEN = "test"; + expect(() => { + validateEnvironmentVariables(); + } + , "did not throw on missing DISCORD_DEBUG_WEBHOOK"). + toThrow(new ReferenceError("DISCORD_DEBUG_WEBHOOK cannot be undefined.")); + }); + + it("should throw when MONGO_URI is not set", () => { + expect.assertions(1); + process.env.DISCORD_DEBUG_WEBHOOK + // eslint-disable-next-line stylistic/max-len + = "https://discord.com/api/webhooks/11111111111111111/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + expect(() => { + validateEnvironmentVariables(); + }, + "did not throw on missing MONGO_URI"). + toThrow(new ReferenceError("MONGO_URI cannot be undefined.")); + }); + + it("should return the expected environment variables", () => { + expect.assertions(2); + process.env.MONGO_URI = "test"; + const result = validateEnvironmentVariables(); + expect(result.discordToken, "did not return correct token").toBe("test"); + expect(result.discordDebugWebhook.url, + "did not correctly instantiate debug hook"). + // eslint-disable-next-line stylistic/max-len + toBe("https://discord.com/api/webhooks/11111111111111111/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index fd2fc8f..7b52228 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,10 @@ export default defineConfig({ all: true, allowExternal: true, thresholds: { - lines: 0, + lines: 100, + statements: 100, + branches: 100, + functions: 100, }, }, },