feat: first version of bot (#2)

### Explanation

This should set up everything we need for our initial launch. Test coverage is at 100% to ensure nothing breaks.

### Issue

_No response_

### Attestations

- [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [x] I have pinned the dependencies to a specific patch version.

### Style

- [x] I have run the linter and resolved any errors.
- [x] My pull request uses an appropriate title, matching the conventional commit standards.
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [x] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [x] All new and existing tests pass locally with my changes.
- [x] Code coverage remains at or above the configured threshold.

### Documentation

Coming soon - I'm working on the infra for docs next

### Versioning

Major - My pull request introduces a breaking change.

Reviewed-on: https://codeberg.org/nhcarrigan/rig-task-bot/pulls/2
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit is contained in:
Naomi Carrigan 2024-09-30 01:41:25 +00:00 committed by Naomi the Technomancer
parent da6fbfd45e
commit 296a50fedd
49 changed files with 4816 additions and 3 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules node_modules
prod prod
coverage

View File

@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript"]
}

3
dev.env Normal file
View File

@ -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"

View File

@ -2,4 +2,14 @@ import NaomisConfig from "@nhcarrigan/eslint-config";
export default [ export default [
...NaomisConfig, ...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"
}
}
]; ];

View File

@ -6,21 +6,32 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"dev": "op run --env-file='./dev.env' --no-masking -- node prod/index.js",
"format": "eslint src test --fix --max-warnings 0", "format": "eslint src test --fix --max-warnings 0",
"lint": "eslint src test --max-warnings 0", "lint": "eslint src test --max-warnings 0",
"start": "op run --env-file='./prod.env' -- node prod/index.js", "start": "op run --env-file='./prod.env' -- node prod/index.js",
"test": "vitest run --coverage" "test": "rm -rf prod && vitest run --coverage"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@fastify/pre-commit": "2.1.0",
"@nhcarrigan/eslint-config": "5.0.0-rc2", "@nhcarrigan/eslint-config": "5.0.0-rc2",
"@nhcarrigan/typescript-config": "4.0.0", "@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "22.7.4", "@types/node": "22.7.4",
"@vitest/coverage-istanbul": "2.1.1", "@vitest/coverage-istanbul": "2.1.1",
"eslint": "9.11.1", "eslint": "9.11.1",
"prisma": "5.20.0",
"typescript": "5.6.2", "typescript": "5.6.2",
"vitest": "2.1.1" "vitest": "2.1.1"
} },
"dependencies": {
"@prisma/client": "5.20.0",
"discord.js": "14.16.2"
},
"pre-commit": [
"lint",
"test"
]
} }

277
pnpm-lock.yaml generated
View File

@ -7,7 +7,17 @@ settings:
importers: 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: devDependencies:
'@fastify/pre-commit':
specifier: 2.1.0
version: 2.1.0
'@nhcarrigan/eslint-config': '@nhcarrigan/eslint-config':
specifier: 5.0.0-rc2 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)) 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: eslint:
specifier: 9.11.1 specifier: 9.11.1
version: 9.11.1 version: 9.11.1
prisma:
specifier: 5.20.0
version: 5.20.0
typescript: typescript:
specifier: 5.6.2 specifier: 5.6.2
version: 5.6.2 version: 5.6.2
@ -107,6 +120,34 @@ packages:
resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==}
engines: {node: '>=6.9.0'} 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': '@es-joy/jsdoccomment@0.48.0':
resolution: {integrity: sha512-G6QUWIcC+KvSwXNsJyDTHvqUdNoAVJPPgkc3+Uk4WBKqZvoXhlvazOgm9aL0HwihJLQf0l+tOE2UFzXBqCqgDw==} resolution: {integrity: sha512-G6QUWIcC+KvSwXNsJyDTHvqUdNoAVJPPgkc3+Uk4WBKqZvoXhlvazOgm9aL0HwihJLQf0l+tOE2UFzXBqCqgDw==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -287,6 +328,9 @@ packages:
resolution: {integrity: sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==} resolution: {integrity: sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'} engines: {node: '>=12.22'}
@ -357,6 +401,30 @@ packages:
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} 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': '@rollup/rollup-android-arm-eabi@4.22.5':
resolution: {integrity: sha512-SU5cvamg0Eyu/F+kLeMXS7GoahL+OoizlclVFX3l5Ql6yNlywJJ0OuqTzUx0v+aHhPHEB/56CT06GQrRrGNYww==} resolution: {integrity: sha512-SU5cvamg0Eyu/F+kLeMXS7GoahL+OoizlclVFX3l5Ql6yNlywJJ0OuqTzUx0v+aHhPHEB/56CT06GQrRrGNYww==}
cpu: [arm] cpu: [arm]
@ -440,6 +508,18 @@ packages:
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} 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': '@stylistic/eslint-plugin@2.8.0':
resolution: {integrity: sha512-Ufvk7hP+bf+pD35R/QfunF793XlSRIC7USr3/EdgduK9j13i2JjmsM0LUz3/foS+jDYp2fzyWZA9N44CPur0Ow==} resolution: {integrity: sha512-Ufvk7hP+bf+pD35R/QfunF793XlSRIC7USr3/EdgduK9j13i2JjmsM0LUz3/foS+jDYp2fzyWZA9N44CPur0Ow==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -461,6 +541,9 @@ packages:
'@types/normalize-package-data@2.4.4': '@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
'@types/ws@8.5.12':
resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==}
'@typescript-eslint/eslint-plugin@8.7.0': '@typescript-eslint/eslint-plugin@8.7.0':
resolution: {integrity: sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==} resolution: {integrity: sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -595,6 +678,10 @@ packages:
'@vitest/utils@2.1.1': '@vitest/utils@2.1.1':
resolution: {integrity: sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==} 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: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@ -823,6 +910,16 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} 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: doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -1338,6 +1435,10 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 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: istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1435,6 +1536,12 @@ packages:
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} 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: loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
@ -1448,6 +1555,9 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
magic-bytes.js@1.10.0:
resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==}
magic-string@0.30.11: magic-string@0.30.11:
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
@ -1644,6 +1754,11 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
prisma@5.20.0:
resolution: {integrity: sha512-6obb3ucKgAnsGS9x9gLOe8qa51XxvJ3vLQtmyf52CTey1Qcez3A6W6ROH5HIz5Q5bW+0VpmZb8WBohieMFGpig==}
engines: {node: '>=16.13'}
hasBin: true
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@ -1894,6 +2009,9 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.2.0' typescript: '>=4.2.0'
ts-mixer@6.0.4:
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
tsconfig-paths@3.15.0: tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@ -1943,6 +2061,10 @@ packages:
undici-types@6.19.8: undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} 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: update-browserslist-db@1.1.1:
resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==}
hasBin: true hasBin: true
@ -2036,6 +2158,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true 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: why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2053,6 +2180,18 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'} 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: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -2179,6 +2318,53 @@ snapshots:
'@babel/helper-validator-identifier': 7.24.7 '@babel/helper-validator-identifier': 7.24.7
to-fast-properties: 2.0.0 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': '@es-joy/jsdoccomment@0.48.0':
dependencies: dependencies:
comment-parser: 1.4.1 comment-parser: 1.4.1
@ -2295,6 +2481,11 @@ snapshots:
dependencies: dependencies:
levn: 0.4.1 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/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.3.0': {} '@humanwhocodes/retry@0.3.0': {}
@ -2381,6 +2572,31 @@ snapshots:
'@pkgr/core@0.1.1': {} '@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': '@rollup/rollup-android-arm-eabi@4.22.5':
optional: true optional: true
@ -2431,6 +2647,15 @@ snapshots:
'@rtsao/scc@1.1.0': {} '@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)': '@stylistic/eslint-plugin@2.8.0(eslint@9.11.1)(typescript@5.6.2)':
dependencies: dependencies:
'@typescript-eslint/utils': 8.7.0(eslint@9.11.1)(typescript@5.6.2) '@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/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)': '@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: dependencies:
'@eslint-community/regexpp': 4.11.1 '@eslint-community/regexpp': 4.11.1
@ -2638,6 +2867,8 @@ snapshots:
loupe: 3.1.1 loupe: 3.1.1
tinyrainbow: 1.2.0 tinyrainbow: 1.2.0
'@vladfrangu/async_event_emitter@2.4.6': {}
acorn-jsx@5.3.2(acorn@7.4.1): acorn-jsx@5.3.2(acorn@7.4.1):
dependencies: dependencies:
acorn: 7.4.1 acorn: 7.4.1
@ -2887,6 +3118,28 @@ snapshots:
dependencies: dependencies:
path-type: 4.0.0 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: doctrine@2.1.0:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
@ -3533,6 +3786,8 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
isexe@3.1.1: {}
istanbul-lib-coverage@3.2.2: {} istanbul-lib-coverage@3.2.2: {}
istanbul-lib-instrument@6.0.3: istanbul-lib-instrument@6.0.3:
@ -3634,6 +3889,10 @@ snapshots:
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash.snakecase@4.1.1: {}
lodash@4.17.21: {}
loose-envify@1.4.0: loose-envify@1.4.0:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
@ -3648,6 +3907,8 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
magic-bytes.js@1.10.0: {}
magic-string@0.30.11: magic-string@0.30.11:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
@ -3830,6 +4091,12 @@ snapshots:
prettier@3.3.3: {} prettier@3.3.3: {}
prisma@5.20.0:
dependencies:
'@prisma/engines': 5.20.0
optionalDependencies:
fsevents: 2.3.3
prop-types@15.8.1: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@ -4117,6 +4384,8 @@ snapshots:
dependencies: dependencies:
typescript: 5.6.2 typescript: 5.6.2
ts-mixer@6.0.4: {}
tsconfig-paths@3.15.0: tsconfig-paths@3.15.0:
dependencies: dependencies:
'@types/json5': 0.0.29 '@types/json5': 0.0.29
@ -4179,6 +4448,8 @@ snapshots:
undici-types@6.19.8: {} undici-types@6.19.8: {}
undici@6.19.8: {}
update-browserslist-db@1.1.1(browserslist@4.24.0): update-browserslist-db@1.1.1(browserslist@4.24.0):
dependencies: dependencies:
browserslist: 4.24.0 browserslist: 4.24.0
@ -4296,6 +4567,10 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
which@4.0.0:
dependencies:
isexe: 3.1.1
why-is-node-running@2.3.0: why-is-node-running@2.3.0:
dependencies: dependencies:
siginfo: 2.0.0 siginfo: 2.0.0
@ -4315,6 +4590,8 @@ snapshots:
string-width: 5.1.2 string-width: 5.1.2
strip-ansi: 7.1.0 strip-ansi: 7.1.0
ws@8.18.0: {}
yallist@3.1.1: {} yallist@3.1.1: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}

22
prisma/schema.prisma Normal file
View File

@ -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([])
}

View File

@ -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"

73
src/commands/assign.ts Normal file
View File

@ -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));
}
},
};

44
src/commands/complete.ts Normal file
View File

@ -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));
}
},
};

60
src/commands/create.ts Normal file
View File

@ -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<TextInputBuilder>().addComponents(
title,
);
const rowTwo = new ActionRowBuilder<TextInputBuilder>().addComponents(
description,
);
const rowThree = new ActionRowBuilder<TextInputBuilder>().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));
}
},
};

62
src/commands/delete.ts Normal file
View File

@ -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),
);
}
},
};

87
src/commands/list.ts Normal file
View File

@ -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));
}
},
};

69
src/commands/priority.ts Normal file
View File

@ -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));
}
},
};

73
src/commands/tag.ts Normal file
View File

@ -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));
}
},
};

68
src/commands/update.ts Normal file
View File

@ -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<TextInputBuilder>().addComponents(
number,
);
const rowOne = new ActionRowBuilder<TextInputBuilder>().addComponents(
title,
);
const rowTwo = new ActionRowBuilder<TextInputBuilder>().addComponents(
description,
);
const rowThree = new ActionRowBuilder<TextInputBuilder>().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));
}
},
};

61
src/commands/view.ts Normal file
View File

@ -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));
}
},
};

12
src/config/intents.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { GatewayIntentBits } from "discord.js";
export const intents: Array<GatewayIntentBits> = [
GatewayIntentBits.Guilds,
];

View File

@ -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<string, Command> = {
assign: assign,
complete: complete,
create: create,
delete: deleteCommand,
list: list,
priority: priority,
tag: tag,
update: update,
view: view,
};
const modalMap: Record<string, ModalHandler> = {
// 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<void> => {
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),
);
}
};

29
src/events/onReady.ts Normal file
View File

@ -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<void> => {
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);
}
};

View File

@ -3,3 +3,48 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @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<void> => {
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 };

18
src/interfaces/bot.ts Normal file
View File

@ -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;
}

15
src/interfaces/command.ts Normal file
View File

@ -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<void>;
}

View File

@ -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,
});
};

View File

@ -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<void>;
/**
* 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<void> => {
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<void> => {
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<void> => {
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<Tasks> = {};
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 };

View File

@ -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}`;
};

52
src/utils/errorHandler.ts Normal file
View File

@ -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<string> => {
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();
};

27
src/utils/sendDebugLog.ts Normal file
View File

@ -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<void> => {
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",
});
};

View File

@ -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,
};
};

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

381
test/commands/list.spec.ts Normal file
View File

@ -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);
});
});

View File

@ -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);
});
});

251
test/commands/tag.spec.ts Normal file
View File

@ -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);
});
});

View File

@ -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);
});
});

271
test/commands/view.spec.ts Normal file
View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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(),
};
});
});
});

View File

@ -3,3 +3,132 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @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)}`,
});
});
});

View File

@ -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,
});
});
});

View File

@ -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);
});
});

View File

@ -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\"");
});
});

View File

@ -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,
});
});
});

View File

@ -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",
});
});
});

View File

@ -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");
});
});

View File

@ -8,7 +8,10 @@ export default defineConfig({
all: true, all: true,
allowExternal: true, allowExternal: true,
thresholds: { thresholds: {
lines: 0, lines: 100,
statements: 100,
branches: 100,
functions: 100,
}, },
}, },
}, },