generated from nhcarrigan/template
Compare commits
No commits in common. "126ee5a306a66e7aaa65b129a541217b9a619086" and "2408f0b43e9d3a790faa065a2363d85137dbe9d8" have entirely different histories.
126ee5a306
...
2408f0b43e
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
prod
|
||||||
|
coverage
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"eslint.validate": ["typescript"]
|
||||||
|
}
|
16
README.md
16
README.md
@ -1,20 +1,10 @@
|
|||||||
# New Repository Template
|
# Retro Island Gaming Task Bot
|
||||||
|
|
||||||
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
|
This application serves as a ToDo/Trello feature built into a Discord bot.
|
||||||
|
|
||||||
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
|
|
||||||
|
|
||||||
## Readme
|
|
||||||
|
|
||||||
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
|
|
||||||
|
|
||||||
<!-- # Project Name
|
|
||||||
|
|
||||||
Project Description
|
|
||||||
|
|
||||||
## Live Version
|
## Live Version
|
||||||
|
|
||||||
This page is currently deployed. [View the live website.]
|
This app is currently not live.
|
||||||
|
|
||||||
## Feedback and Bugs
|
## Feedback and Bugs
|
||||||
|
|
||||||
|
3
dev.env
Normal file
3
dev.env
Normal 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"
|
15
eslint.config.js
Normal file
15
eslint.config.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import NaomisConfig from "@nhcarrigan/eslint-config";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...NaomisConfig,
|
||||||
|
{
|
||||||
|
files: ["**/*.spec.ts"],
|
||||||
|
"rules": {
|
||||||
|
"max-nested-callbacks": "off",
|
||||||
|
"@typescript-eslint/consistent-type-assertions": "off",
|
||||||
|
"max-lines-per-function": "off",
|
||||||
|
"max-statements": "off",
|
||||||
|
"max-lines": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
37
package.json
Normal file
37
package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "rig-task-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "op run --env-file='./dev.env' --no-masking -- node prod/index.js",
|
||||||
|
"format": "eslint src test --fix --max-warnings 0",
|
||||||
|
"lint": "eslint src test --max-warnings 0",
|
||||||
|
"start": "op run --env-file='./prod.env' -- node prod/index.js",
|
||||||
|
"test": "rm -rf prod && vitest run --coverage"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@fastify/pre-commit": "2.1.0",
|
||||||
|
"@nhcarrigan/eslint-config": "5.0.0-rc2",
|
||||||
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
|
"@types/node": "22.7.4",
|
||||||
|
"@vitest/coverage-istanbul": "2.1.1",
|
||||||
|
"eslint": "9.11.1",
|
||||||
|
"prisma": "5.20.0",
|
||||||
|
"typescript": "5.6.2",
|
||||||
|
"vitest": "2.1.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "5.20.0",
|
||||||
|
"discord.js": "14.16.2"
|
||||||
|
},
|
||||||
|
"pre-commit": [
|
||||||
|
"lint",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
}
|
4597
pnpm-lock.yaml
generated
Normal file
4597
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
prisma/schema.prisma
Normal file
22
prisma/schema.prisma
Normal 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([])
|
||||||
|
}
|
3
prod.env
Normal file
3
prod.env
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
DISCORD_TOKEN="op://Environment Variables - Naomi/Tasks Bot/token"
|
||||||
|
DISCORD_DEBUG_WEBHOOK="op://Environment Variables - Naomi/Tasks Bot/webhook"
|
||||||
|
MONGO_URI="op://Environment Variables - Naomi/Tasks Bot/mongo"
|
73
src/commands/assign.ts
Normal file
73
src/commands/assign.ts
Normal 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
44
src/commands/complete.ts
Normal 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
60
src/commands/create.ts
Normal 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
62
src/commands/delete.ts
Normal 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
87
src/commands/list.ts
Normal 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
69
src/commands/priority.ts
Normal 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
73
src/commands/tag.ts
Normal 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
68
src/commands/update.ts
Normal 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));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
94
src/commands/view.ts
Normal file
94
src/commands/view.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line max-lines-per-function
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inline: true,
|
||||||
|
name: "Due Date",
|
||||||
|
value: task.dueAt.toLocaleDateString("en-US"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tag",
|
||||||
|
value: task.tags.length > 0
|
||||||
|
? task.tags.join(", ")
|
||||||
|
: "No tags.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Assignee",
|
||||||
|
value:
|
||||||
|
task.assignees.length > 0
|
||||||
|
? task.assignees.
|
||||||
|
map((id) => {
|
||||||
|
return `<@${id}>`;
|
||||||
|
}).
|
||||||
|
join(", ")
|
||||||
|
: "No assignees.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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
12
src/config/intents.ts
Normal 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,
|
||||||
|
];
|
99
src/events/onInteractionCreate.ts
Normal file
99
src/events/onInteractionCreate.ts
Normal 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
29
src/events/onReady.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
50
src/index.ts
Normal file
50
src/index.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Client, Events, WebhookClient } from "discord.js";
|
||||||
|
import { intents } from "./config/intents.js";
|
||||||
|
import { onInteractionCreate } from "./events/onInteractionCreate.js";
|
||||||
|
import { onReady } from "./events/onReady.js";
|
||||||
|
import { validateEnvironmentVariables }
|
||||||
|
from "./utils/validateEnvironmentVariables.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry point file. Handles starting up the application
|
||||||
|
* process and mounting the necessary event listeners.
|
||||||
|
*/
|
||||||
|
const boot = async(): Promise<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
18
src/interfaces/bot.ts
Normal 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
15
src/interfaces/command.ts
Normal 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>;
|
||||||
|
}
|
21
src/modules/defaultCommand.ts
Normal file
21
src/modules/defaultCommand.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
139
src/modules/modalHandlers.ts
Normal file
139
src/modules/modalHandlers.ts
Normal 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 };
|
54
src/utils/displayCommandCurl.ts
Normal file
54
src/utils/displayCommandCurl.ts
Normal 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
52
src/utils/errorHandler.ts
Normal 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
27
src/utils/sendDebugLog.ts
Normal 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",
|
||||||
|
});
|
||||||
|
};
|
39
src/utils/validateEnvironmentVariables.ts
Normal file
39
src/utils/validateEnvironmentVariables.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
251
test/commands/assign.spec.ts
Normal file
251
test/commands/assign.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
166
test/commands/complete.spec.ts
Normal file
166
test/commands/complete.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
196
test/commands/create.spec.ts
Normal file
196
test/commands/create.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
182
test/commands/delete.spec.ts
Normal file
182
test/commands/delete.spec.ts
Normal 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
381
test/commands/list.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
243
test/commands/priority.spec.ts
Normal file
243
test/commands/priority.spec.ts
Normal 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
251
test/commands/tag.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
217
test/commands/update.spec.ts
Normal file
217
test/commands/update.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
342
test/commands/view.spec.ts
Normal file
342
test/commands/view.spec.ts
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* @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" },
|
||||||
|
{
|
||||||
|
inline: true,
|
||||||
|
name: "Due Date",
|
||||||
|
value: new Date("2021-10-10").toLocaleDateString("en-US"),
|
||||||
|
},
|
||||||
|
{ name: "Tag", value: "discord" },
|
||||||
|
{ name: "Assignee", value: "<@123>" },
|
||||||
|
],
|
||||||
|
title: "Task 1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should execute correctly with fallback values", async() => {
|
||||||
|
expect.assertions(3);
|
||||||
|
const mockBot = {
|
||||||
|
database: {
|
||||||
|
tasks: {
|
||||||
|
findUnique: vi.fn().mockReturnValue({
|
||||||
|
assignees: [ ],
|
||||||
|
completed: false,
|
||||||
|
deleted: false,
|
||||||
|
description: "Task 1 description",
|
||||||
|
dueAt: new Date("2021-10-10"),
|
||||||
|
numericalId: 1,
|
||||||
|
priority: "critical",
|
||||||
|
tags: [ ],
|
||||||
|
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" },
|
||||||
|
{
|
||||||
|
inline: true,
|
||||||
|
name: "Due Date",
|
||||||
|
value: new Date("2021-10-10").toLocaleDateString("en-US"),
|
||||||
|
},
|
||||||
|
{ name: "Tag", value: "No tags." },
|
||||||
|
{ name: "Assignee", value: "No assignees." },
|
||||||
|
],
|
||||||
|
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" },
|
||||||
|
{
|
||||||
|
inline: true,
|
||||||
|
name: "Due Date",
|
||||||
|
value: new Date("2021-10-10").toLocaleDateString("en-US"),
|
||||||
|
},
|
||||||
|
{ 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);
|
||||||
|
});
|
||||||
|
});
|
18
test/config/intents.spec.ts
Normal file
18
test/config/intents.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
156
test/events/onInteractionCreate.spec.ts
Normal file
156
test/events/onInteractionCreate.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
99
test/events/onReady.spec.ts
Normal file
99
test/events/onReady.spec.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
134
test/index.spec.ts
Normal file
134
test/index.spec.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Client, Events, WebhookClient } from "discord.js";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { intents } from "../src/config/intents.js";
|
||||||
|
import { onInteractionCreate } from "../src/events/onInteractionCreate.js";
|
||||||
|
import { onReady } from "../src/events/onReady.js";
|
||||||
|
import { boot } from "../src/index.js";
|
||||||
|
import { validateEnvironmentVariables }
|
||||||
|
from "../src/utils/validateEnvironmentVariables.js";
|
||||||
|
|
||||||
|
vi.mock("@prisma/client");
|
||||||
|
vi.mock("discord.js");
|
||||||
|
vi.mock("../src/utils/sendDebugLog.js");
|
||||||
|
vi.mock("../src/utils/validateEnvironmentVariables.js");
|
||||||
|
vi.mock("../src/events/onReady.ts", () => {
|
||||||
|
return {
|
||||||
|
onReady: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../src/events/onInteractionCreate.ts", () => {
|
||||||
|
return {
|
||||||
|
onInteractionCreate: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBot = {
|
||||||
|
database: {
|
||||||
|
$connect: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as never as PrismaClient,
|
||||||
|
discord: {
|
||||||
|
login: vi.fn().mockResolvedValue("Logged in"),
|
||||||
|
on: vi.fn(),
|
||||||
|
} as never as Client,
|
||||||
|
env: {
|
||||||
|
discordToken: "mock-token",
|
||||||
|
// Add other necessary environment variables
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("boot function", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
|
||||||
|
vi.mocked(Client).mockImplementation(() => {
|
||||||
|
return mockBot.discord;
|
||||||
|
});
|
||||||
|
vi.mocked(PrismaClient).mockImplementation(() => {
|
||||||
|
return mockBot.database;
|
||||||
|
});
|
||||||
|
vi.mocked(validateEnvironmentVariables).mockReturnValue(
|
||||||
|
mockBot.env as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize the bot and connect to all platforms", async() => {
|
||||||
|
expect.assertions(9);
|
||||||
|
await boot();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateEnvironmentVariables,
|
||||||
|
"should validate env",
|
||||||
|
).toHaveBeenCalledWith();
|
||||||
|
expect(Client, "should construct discord bot").toHaveBeenCalledWith({
|
||||||
|
intents,
|
||||||
|
});
|
||||||
|
expect(PrismaClient, "should construct prisma client").
|
||||||
|
toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
mockBot.database.$connect,
|
||||||
|
"should connect to database",
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockBot.discord.on, "did not mount interaction create").
|
||||||
|
toHaveBeenCalledWith(
|
||||||
|
Events.InteractionCreate,
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
const interactionCallback = mockBot.discord.on.mock.calls.find((call) => {
|
||||||
|
return call[0] === Events.InteractionCreate;
|
||||||
|
})?.[1];
|
||||||
|
interactionCallback({} as never);
|
||||||
|
expect(onInteractionCreate, "should call oninteractioncreate").
|
||||||
|
toHaveBeenCalledWith(mockBot, {});
|
||||||
|
expect(mockBot.discord.on, "did not mount ready").toHaveBeenCalledWith(
|
||||||
|
Events.ClientReady,
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
const readyCallback = mockBot.discord.on.mock.calls.find((call) => {
|
||||||
|
return call[0] === Events.ClientReady;
|
||||||
|
})?.[1];
|
||||||
|
readyCallback();
|
||||||
|
expect(onReady, "should call onready").toHaveBeenCalledWith(mockBot);
|
||||||
|
expect(
|
||||||
|
mockBot.discord.login,
|
||||||
|
"should login to Discord",
|
||||||
|
).toHaveBeenCalledWith(mockBot.env.discordToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle errors and send them to the debug webhook", async() => {
|
||||||
|
expect.assertions(1);
|
||||||
|
const mockError = new Error("Test error");
|
||||||
|
vi.mocked(validateEnvironmentVariables).mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockWebhookSend = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(WebhookClient).mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
send: mockWebhookSend,
|
||||||
|
} as unknown as WebhookClient;
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.DISCORD_DEBUG_WEBHOOK = "https://mock-webhook-url";
|
||||||
|
|
||||||
|
await boot();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mockWebhookSend,
|
||||||
|
"should send directly to webhook",
|
||||||
|
).toHaveBeenCalledWith({
|
||||||
|
content: `Error: ${JSON.stringify(mockError, null, 2)}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
24
test/modules/defaultCommand.spec.ts
Normal file
24
test/modules/defaultCommand.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
491
test/modules/modalHandlers.spec.ts
Normal file
491
test/modules/modalHandlers.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
33
test/utils/displayCommandCurl.spec.ts
Normal file
33
test/utils/displayCommandCurl.spec.ts
Normal 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\"");
|
||||||
|
});
|
||||||
|
});
|
96
test/utils/errorHandler.spec.ts
Normal file
96
test/utils/errorHandler.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
63
test/utils/sendDebugLog.spec.ts
Normal file
63
test/utils/sendDebugLog.spec.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
63
test/utils/validateEnvironmentVariables.spec.ts
Normal file
63
test/utils/validateEnvironmentVariables.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@nhcarrigan/typescript-config",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./prod",
|
||||||
|
},
|
||||||
|
"exclude": ["./test", "vitest.config.ts"]
|
||||||
|
}
|
18
vitest.config.ts
Normal file
18
vitest.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
coverage: {
|
||||||
|
provider: "istanbul",
|
||||||
|
reporter: ["text", "html"],
|
||||||
|
all: true,
|
||||||
|
allowExternal: true,
|
||||||
|
thresholds: {
|
||||||
|
lines: 100,
|
||||||
|
statements: 100,
|
||||||
|
branches: 100,
|
||||||
|
functions: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user