diff --git a/application/command/anonymousQuestionCommand.ts b/application/command/anonymousQuestionCommand.ts new file mode 100644 index 0000000..c22ad0a --- /dev/null +++ b/application/command/anonymousQuestionCommand.ts @@ -0,0 +1,69 @@ +import { Command, Context } from "../../types"; +import ChatService from "../../domain/service/chatService"; +import LoggerService from "../../domain/service/loggerService"; +import ChannelResolver from "../../domain/service/channelResolver"; +import SendAnonymousQuestionUseCase from "../usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +export default class AnonymousQuestionCommand implements Command { + readonly name = "!pergunta"; + + private readonly chatService: ChatService; + + private readonly loggerService: LoggerService; + + private readonly channelResolver: ChannelResolver; + + private readonly questionTrackingService: QuestionTrackingService; + + constructor( + chatService: ChatService, + loggerService: LoggerService, + channelResolver: ChannelResolver, + questionTrackingService: QuestionTrackingService + ) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute(context: Context): Promise { + if (!context.message) { + return; + } + + const { message } = context; + + const isDM = !message.inGuild(); + + if (!isDM) { + this.chatService.sendMessageToChannel("Este comando só pode ser usado em mensagens diretas.", context.channelId); + return; + } + + const questionContent = message.content.replace(/!pergunta\s+/i, "").trim(); + + if (!questionContent) { + this.chatService.sendMessageToChannel( + "Por favor, forneçe uma pergunta após o comando. Exemplo: `!pergunta Como faço para...`", + message.channelId + ); + return; + } + + const sendAnonymousQuestionUseCase = new SendAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + channelResolver: this.channelResolver, + questionTrackingService: this.questionTrackingService, + }); + + await sendAnonymousQuestionUseCase.execute({ + userId: message.author.id, + username: message.author.username, + questionContent, + dmChannelId: message.channelId, + }); + } +} diff --git a/application/usecases/approveAnonymousQuestionUseCase.ts b/application/usecases/approveAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..e9750d1 --- /dev/null +++ b/application/usecases/approveAnonymousQuestionUseCase.ts @@ -0,0 +1,106 @@ +import { ButtonInteraction, EmbedBuilder } from "discord.js"; +import ChatService from "../../domain/service/chatService"; +import ChannelResolver from "../../domain/service/channelResolver"; +import LoggerService from "../../domain/service/loggerService"; +import { ChannelSlug } from "../../types"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +interface ApproveAnonymousQuestionUseCaseOptions { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; +} + +interface ApproveAnonymousQuestionParams { + questionId: string; + moderatorId: string; + interactionId: string; + questionContent: string; + interaction: ButtonInteraction; +} + +export default class ApproveAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private channelResolver: ChannelResolver; + + private questionTrackingService: QuestionTrackingService; + + constructor({ + chatService, + loggerService, + channelResolver, + questionTrackingService, + }: ApproveAnonymousQuestionUseCaseOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + questionId, + moderatorId, + interactionId, + questionContent, + interaction, + }: ApproveAnonymousQuestionParams) { + this.loggerService.log(`A aprovar a pergunta ${questionId} pelo moderador ${moderatorId}`); + + const customIdParts = interactionId.split("_"); + const dmChannelId = customIdParts[2]; + const userId = customIdParts[3]; + + const publicChannelId = this.channelResolver.getBySlug(ChannelSlug.QUESTIONS); + await this.chatService.sendMessageToChannel(`**Pergunta anónima:**\n\n${questionContent}`, publicChannelId); + + const publicMessageLink = interaction.guildId + ? `https://discord.com/channels/${interaction.guildId}/${publicChannelId}/` + : ""; + + if (dmChannelId) { + await this.chatService.sendMessageToChannel( + `A tua pergunta anónima foi aprovada e publicada no canal <#${publicChannelId}>!${ + publicMessageLink ? `\n\nVê aqui: ${publicMessageLink}` : "" + }`, + dmChannelId + ); + } + + try { + const updatedEmbed = new EmbedBuilder() + .setTitle("Pergunta Anónima Aprovada") + .setDescription(questionContent) + .setColor(0x00ff00) // Green + .setFields([ + { name: "ID", value: questionId, inline: true }, + { name: "Aprovado por", value: `<@${moderatorId}>`, inline: true }, + ]) + .setFooter({ text: "Esta pergunta foi aprovada e publicada" }); + + if (interaction.message && "edit" in interaction.message) { + await interaction.message.edit({ + embeds: [updatedEmbed], + components: [], + }); + } else { + await interaction.update({ + embeds: [updatedEmbed], + components: [], + }); + } + } catch (error) { + this.loggerService.log(`Erro ao atualizar mensagem de moderação: ${error}`); + } + + if (userId) { + this.questionTrackingService.removeQuestion(userId); + this.loggerService.log(`Pergunta ${questionId} removida do tracking para o user ${userId}`); + } + + return { success: true }; + } +} diff --git a/application/usecases/rejectAnonymousQuestionUseCase.ts b/application/usecases/rejectAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..74e3a93 --- /dev/null +++ b/application/usecases/rejectAnonymousQuestionUseCase.ts @@ -0,0 +1,80 @@ +import { ButtonInteraction, EmbedBuilder } from "discord.js"; +import ChatService from "../../domain/service/chatService"; +import LoggerService from "../../domain/service/loggerService"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +interface RejectAnonymousQuestionUseCaseOptions { + chatService: ChatService; + loggerService: LoggerService; + questionTrackingService: QuestionTrackingService; +} + +interface RejectAnonymousQuestionParams { + questionId: string; + moderatorId: string; + interactionId: string; + questionContent: string; + interaction: ButtonInteraction; + reason?: string; +} + +export default class RejectAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private questionTrackingService: QuestionTrackingService; + + constructor({ chatService, loggerService, questionTrackingService }: RejectAnonymousQuestionUseCaseOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + questionId, + moderatorId, + interactionId, + questionContent, + interaction, + }: RejectAnonymousQuestionParams) { + this.loggerService.log(`Rejeitando a pergunta ${questionId} pelo moderador ${moderatorId}`); + + const customIdParts = interactionId.split("_"); + const dmChannelId = customIdParts[2]; + const userId = customIdParts[3]; + + if (dmChannelId) { + await this.chatService.sendMessageToChannel( + `A tua pergunta anónima foi rejeitada pelos moderadores.`, + dmChannelId + ); + } + + try { + const updatedEmbed = new EmbedBuilder() + .setTitle("Pergunta Anónima Rejeitada") + .setDescription(questionContent) + .setColor(0xff0000) // Red + .addFields([ + { name: "ID", value: questionId || "N/A", inline: true }, + { name: "Rejeitado por", value: moderatorId ? `<@${moderatorId}>` : "Desconhecido", inline: true }, + ]) + .setFooter({ text: "Esta pergunta foi rejeitada" }); + + await interaction.update({ + embeds: [updatedEmbed], + components: [], + }); + } catch (error) { + this.loggerService.log(`Erro ao atualizar mensagem de moderação: ${error}`); + } + + if (userId) { + this.questionTrackingService.removeQuestion(userId); + this.loggerService.log(`Pergunta ${questionId} removida do tracking para o user ${userId}`); + } + + return { success: true }; + } +} diff --git a/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts b/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..a9643b9 --- /dev/null +++ b/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts @@ -0,0 +1,94 @@ +import LoggerService from "../../../domain/service/loggerService"; +import ChatService from "../../../domain/service/chatService"; +import ChannelResolver from "../../../domain/service/channelResolver"; +import { ChannelSlug } from "../../../types"; +import QuestionTrackingService from "../../../domain/service/questionTrackingService"; + +export default class SendAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private channelResolver: ChannelResolver; + + private questionTrackingService: QuestionTrackingService; + + constructor({ + chatService, + loggerService, + channelResolver, + questionTrackingService, + }: { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; + }) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + userId, + username, + questionContent, + dmChannelId, + }: { + userId: string; + username: string; + questionContent: string; + dmChannelId: string; + }): Promise { + if (this.questionTrackingService.hasPendingQuestion(userId)) { + await this.chatService.sendMessageToChannel( + "Já tens uma pergunta pendente. Por favor, aguarda até que seja aprovada ou rejeitada antes de enviar outra.", + dmChannelId + ); + return; + } + + this.loggerService.log(`Pergunta anónima recebida de ${username}: ${questionContent}`); + + const questionId = Date.now().toString(); + + const moderationChannelId = this.channelResolver.getBySlug(ChannelSlug.MODERATION); + + const approveCustomId = `approve_${questionId}_${dmChannelId}_${userId}`; + const rejectCustomId = `reject_${questionId}_${dmChannelId}_${userId}`; + + await this.chatService.sendEmbedWithButtons( + moderationChannelId, + { + title: "Nova Pergunta Anónima", + description: questionContent, + color: 0x3498db, // Blue + fields: [ + { name: "ID", value: questionId, inline: true }, + { name: "Enviado por", value: username, inline: true }, + ], + footer: { text: "Pergunta anónima - Moderação necessária" }, + }, + [ + { + customId: approveCustomId, + label: "Aprovar", + style: "SUCCESS", + }, + { + customId: rejectCustomId, + label: "Rejeitar", + style: "DANGER", + }, + ] + ); + + this.questionTrackingService.trackQuestion(userId, questionId); + + await this.chatService.sendMessageToChannel( + "A tua pergunta anónima foi recebida e será analisada pelos moderadores.", + dmChannelId + ); + } +} diff --git a/domain/service/channelResolver.ts b/domain/service/channelResolver.ts index 2b001c0..1d921d9 100644 --- a/domain/service/channelResolver.ts +++ b/domain/service/channelResolver.ts @@ -3,6 +3,8 @@ import { ChannelSlug } from "../../types"; const fallbackChannelIds: Record = { [ChannelSlug.ENTRANCE]: "855861944930402344", [ChannelSlug.JOBS]: "876826576749215744", + [ChannelSlug.MODERATION]: "987719981443723266", + [ChannelSlug.QUESTIONS]: "1065751368809324634", }; export default class ChannelResolver { diff --git a/domain/service/chatService.ts b/domain/service/chatService.ts index 0133401..415a919 100644 --- a/domain/service/chatService.ts +++ b/domain/service/chatService.ts @@ -1,3 +1,19 @@ +export interface EmbedOptions { + title?: string; + description?: string; + color?: number; + fields?: Array<{ name: string; value: string; inline?: boolean }>; + footer?: { text: string; iconURL?: string }; +} + +export interface ButtonOptions { + customId: string; + label: string; + style: "PRIMARY" | "SECONDARY" | "SUCCESS" | "DANGER"; +} + export default interface ChatService { - sendMessageToChannel(message: string, channelId: string): void; + sendMessageToChannel(message: string, channelId: string): Promise; + + sendEmbedWithButtons(channelId: string, embedOptions: EmbedOptions, buttons: ButtonOptions[]): Promise; } diff --git a/domain/service/commandUseCaseResolver.ts b/domain/service/commandUseCaseResolver.ts index 403d454..d953d37 100644 --- a/domain/service/commandUseCaseResolver.ts +++ b/domain/service/commandUseCaseResolver.ts @@ -1,5 +1,4 @@ import { Command, Context } from "../../types"; -import UseCaseNotFound from "../exception/useCaseNotFound"; import LoggerService from "./loggerService"; export default class CommandUseCaseResolver { @@ -13,15 +12,17 @@ export default class CommandUseCaseResolver { this.commands = commands; } - async resolveByCommand(command: string, context: Context): Promise { + async resolveByCommand(command: string, context: Context): Promise { this.loggerService.log(`Command received: "${command}"`); const commandInstance = this.commands.find((cmd) => cmd.name === command); if (!commandInstance) { - throw new UseCaseNotFound().byCommand(command); + this.loggerService.log(`Command not found: "${command}"`); + return false; } await commandInstance.execute(context); + return true; } } diff --git a/domain/service/interactionResolver.ts b/domain/service/interactionResolver.ts new file mode 100644 index 0000000..e77add0 --- /dev/null +++ b/domain/service/interactionResolver.ts @@ -0,0 +1,89 @@ +import { ButtonInteraction } from "discord.js"; +import ChatService from "./chatService"; +import ChannelResolver from "./channelResolver"; +import LoggerService from "./loggerService"; +import ApproveAnonymousQuestionUseCase from "../../application/usecases/approveAnonymousQuestionUseCase"; +import RejectAnonymousQuestionUseCase from "../../application/usecases/rejectAnonymousQuestionUseCase"; +import QuestionTrackingService from "./questionTrackingService"; + +interface InteractionResolverOptions { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; +} + +export default class InteractionResolver { + private readonly chatService: ChatService; + + private readonly loggerService: LoggerService; + + private readonly channelResolver: ChannelResolver; + + private readonly questionTrackingService: QuestionTrackingService; + + constructor({ chatService, loggerService, channelResolver, questionTrackingService }: InteractionResolverOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async resolveButtonInteraction(interaction: ButtonInteraction): Promise { + const { customId } = interaction; + const [action, questionId] = customId.split("_"); + + try { + const { message } = interaction; + let questionContent = "Pergunta anónima"; + + if (message.embeds && message.embeds.length > 0) { + questionContent = message.embeds[0].description || questionContent; + } + + if (action === "approve") { + const approveUseCase = new ApproveAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + channelResolver: this.channelResolver, + questionTrackingService: this.questionTrackingService, + }); + + await approveUseCase.execute({ + questionId, + moderatorId: interaction.user.id, + interactionId: customId, + questionContent, + interaction, + }); + } else if (action === "reject") { + const rejectUseCase = new RejectAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + questionTrackingService: this.questionTrackingService, + }); + + await rejectUseCase.execute({ + questionId, + moderatorId: interaction.user.id, + interactionId: customId, + questionContent, + interaction, + }); + } + } catch (error) { + this.loggerService.log(`Erro ao processar interação de botão: ${error}`); + + if (!interaction.replied && !interaction.deferred) { + try { + await interaction.reply({ + content: "Ocorreu um erro ao processar esta ação.", + ephemeral: true, + }); + } catch (replyError) { + this.loggerService.log(`Não foi possível responder à interação: ${replyError}`); + } + } + } + } +} diff --git a/domain/service/questionTrackingService.ts b/domain/service/questionTrackingService.ts new file mode 100644 index 0000000..3fbae51 --- /dev/null +++ b/domain/service/questionTrackingService.ts @@ -0,0 +1,19 @@ +export default class QuestionTrackingService { + private pendingQuestions: Map = new Map(); // userId -> questionId + + hasPendingQuestion(userId: string): boolean { + return this.pendingQuestions.has(userId); + } + + trackQuestion(userId: string, questionId: string): void { + this.pendingQuestions.set(userId, questionId); + } + + removeQuestion(userId: string): void { + this.pendingQuestions.delete(userId); + } + + getQuestionId(userId: string): string | undefined { + return this.pendingQuestions.get(userId); + } +} diff --git a/index.ts b/index.ts index 4de7849..747a146 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -import { Client, Events, GatewayIntentBits, GuildMember, Message } from "discord.js"; +import { Client, Events, GatewayIntentBits, GuildMember, Message, Partials } from "discord.js"; import * as dotenv from "dotenv"; import { CronJob } from "cron"; import SendWelcomeMessageUseCase from "./application/usecases/sendWelcomeMessageUseCase"; @@ -10,13 +10,16 @@ import MessageRepository from "./domain/repository/messageRepository"; import LoggerService from "./domain/service/loggerService"; import CommandUseCaseResolver from "./domain/service/commandUseCaseResolver"; import ChannelResolver from "./domain/service/channelResolver"; +import InteractionResolver from "./domain/service/interactionResolver"; import KataService from "./domain/service/kataService/kataService"; import CodewarsKataService from "./infrastructure/service/codewarsKataService"; import ContentAggregatorService from "./domain/service/contentAggregatorService/contentAggregatorService"; import LemmyContentAggregatorService from "./infrastructure/service/lemmyContentAggregatorService"; +import QuestionTrackingService from "./domain/service/questionTrackingService"; import CodewarsLeaderboardCommand from "./application/command/codewarsLeaderboardCommand"; import DontAskToAskCommand from "./application/command/dontAskToAskCommand"; import OnlyCodeQuestionsCommand from "./application/command/onlyCodeQuestionsCommand"; +import AnonymousQuestionCommand from "./application/command/anonymousQuestionCommand"; import { Command } from "./types"; dotenv.config(); @@ -28,26 +31,37 @@ const client = new Client({ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent, ], + partials: [Partials.Channel], }); const messageRepository: MessageRepository = new FileMessageRepository(); const chatService: ChatService = new DiscordChatService(client); const loggerService: LoggerService = new ConsoleLoggerService(); const channelResolver: ChannelResolver = new ChannelResolver(); +const questionTrackingService: QuestionTrackingService = new QuestionTrackingService(); const kataService: KataService = new CodewarsKataService(); const lemmyContentAggregatorService: ContentAggregatorService = new LemmyContentAggregatorService(); const commands: Command[] = [ new CodewarsLeaderboardCommand(chatService, kataService), new DontAskToAskCommand(chatService), new OnlyCodeQuestionsCommand(chatService), + new AnonymousQuestionCommand(chatService, loggerService, channelResolver, questionTrackingService), ]; const useCaseResolver = new CommandUseCaseResolver({ commands, loggerService, }); +const interactionResolver = new InteractionResolver({ + chatService, + loggerService, + channelResolver, + questionTrackingService, +}); + const checkForNewPosts = async () => { loggerService.log("Checking for new posts on content aggregator..."); @@ -101,7 +115,7 @@ client.once(Events.ClientReady, () => { client.login(DISCORD_TOKEN); -client.on("guildMemberAdd", (member: GuildMember) => +client.on(Events.GuildMemberAdd, (member: GuildMember) => new SendWelcomeMessageUseCase({ messageRepository, chatService, @@ -110,18 +124,25 @@ client.on("guildMemberAdd", (member: GuildMember) => }).execute(member) ); -client.on(Events.MessageCreate, (messages: Message) => { +client.on(Events.MessageCreate, async (message: Message) => { const COMMAND_PREFIX = "!"; - if (!messages.content.startsWith(COMMAND_PREFIX)) return; + if (!message.content.startsWith(COMMAND_PREFIX)) return; - const command = messages.content.split(" ")[0]; + const command = message.content.split(" ")[0]; try { - useCaseResolver.resolveByCommand(command, { - channelId: messages.channel.id, + await useCaseResolver.resolveByCommand(command, { + channelId: message.channel.id, + message, }); } catch (error: unknown) { loggerService.log(error); } }); + +client.on(Events.InteractionCreate, async (interaction) => { + if (interaction.isButton()) { + await interactionResolver.resolveButtonInteraction(interaction); + } +}); diff --git a/infrastructure/service/discordChatService.ts b/infrastructure/service/discordChatService.ts index 3ae84f4..68a678f 100644 --- a/infrastructure/service/discordChatService.ts +++ b/infrastructure/service/discordChatService.ts @@ -1,5 +1,5 @@ -import { Client, TextBasedChannel } from "discord.js"; -import ChatService from "../../domain/service/chatService"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, TextBasedChannel } from "discord.js"; +import ChatService, { ButtonOptions, EmbedOptions } from "../../domain/service/chatService"; export default class DiscordChatService implements ChatService { constructor(private client: Client) {} @@ -18,4 +18,57 @@ export default class DiscordChatService implements ChatService { const textChannel = channel as TextBasedChannel & { send: (content: string) => Promise }; await textChannel.send(message); } + + private mapButtonStyle(style: ButtonOptions["style"]): ButtonStyle { + switch (style) { + case "PRIMARY": + return ButtonStyle.Primary; + case "SECONDARY": + return ButtonStyle.Secondary; + case "SUCCESS": + return ButtonStyle.Success; + case "DANGER": + return ButtonStyle.Danger; + default: + return ButtonStyle.Secondary; + } + } + + async sendEmbedWithButtons(channelId: string, embedOptions: EmbedOptions, buttons: ButtonOptions[]): Promise { + const channel = await this.client.channels.fetch(channelId); + + if (channel === null) { + throw new Error(`Channel with id ${channelId} not found!`); + } + + if (!channel.isTextBased() || !("send" in channel)) { + throw new Error(`Channel with id ${channelId} is not a text channel!`); + } + + const textChannel = channel as TextBasedChannel & { send: (content: unknown) => Promise }; + + const embed = new EmbedBuilder() + .setTitle(embedOptions.title ?? "") + .setDescription(embedOptions.description ?? "") + .setColor(embedOptions.color ?? 0x0099ff); + + if (embedOptions.fields) { + embed.setFields(embedOptions.fields); + } + + if (embedOptions.footer) { + embed.setFooter({ text: embedOptions.footer.text, iconURL: embedOptions.footer.iconURL }); + } + + const row = new ActionRowBuilder().addComponents( + ...buttons.map((button) => + new ButtonBuilder() + .setCustomId(button.customId) + .setLabel(button.label) + .setStyle(this.mapButtonStyle(button.style)) + ) + ); + + await textChannel.send({ embeds: [embed], components: [row] }); + } } diff --git a/types.ts b/types.ts index 5e7f2bf..ed9fb51 100644 --- a/types.ts +++ b/types.ts @@ -1,14 +1,19 @@ +import { Message } from "discord.js"; + export interface ChatMember { id: string; } export interface Context { channelId: string; + message?: Message; } export enum ChannelSlug { ENTRANCE = "ENTRANCE", JOBS = "JOBS", + MODERATION = "MODERATION", + QUESTIONS = "QUESTIONS", } export type CommandMessages = { diff --git a/vitest/application/usecases/approveAnonymousQuestionUseCase.spec.ts b/vitest/application/usecases/approveAnonymousQuestionUseCase.spec.ts new file mode 100644 index 0000000..c0a124a --- /dev/null +++ b/vitest/application/usecases/approveAnonymousQuestionUseCase.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Mock } from "vitest"; +import { ButtonInteraction, EmbedBuilder } from "discord.js"; +import ApproveAnonymousQuestionUseCase from "../../../application/usecases/approveAnonymousQuestionUseCase"; +import ChatService from "../../../domain/service/chatService"; +import ChannelResolver from "../../../domain/service/channelResolver"; +import LoggerService from "../../../domain/service/loggerService"; +import QuestionTrackingService from "../../../domain/service/questionTrackingService"; + +describe("ApproveAnonymousQuestionUseCase", () => { + let chatService: ChatService; + let loggerService: LoggerService; + let channelResolver: ChannelResolver; + let questionTrackingService: QuestionTrackingService; + let interaction: ButtonInteraction; + + beforeEach(() => { + chatService = { + sendMessageToChannel: vi.fn(), + } as unknown as ChatService; + + loggerService = { + log: vi.fn(), + } as unknown as LoggerService; + + channelResolver = { + getBySlug: vi.fn(() => "questions-channel"), + } as unknown as ChannelResolver; + + questionTrackingService = { + removeQuestion: vi.fn(), + } as unknown as QuestionTrackingService; + + interaction = { + guildId: "guild-1", + update: vi.fn(), + } as unknown as ButtonInteraction; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("publishes to public channel, notifies the user and clears tracking", async () => { + const useCase = new ApproveAnonymousQuestionUseCase({ + chatService, + loggerService, + channelResolver, + questionTrackingService, + }); + + await useCase.execute({ + questionId: "123", + moderatorId: "mod-1", + interactionId: "approve_123_dm-99_user-42", + questionContent: "What is the airspeed?", + interaction, + }); + + expect(chatService.sendMessageToChannel).toHaveBeenCalledWith( + "**Pergunta anónima:**\n\nWhat is the airspeed?", + "questions-channel" + ); + + expect(chatService.sendMessageToChannel).toHaveBeenCalledWith( + expect.stringContaining("aprovada e publicada"), + "dm-99" + ); + + expect(questionTrackingService.removeQuestion).toHaveBeenCalledWith("user-42"); + + expect(interaction.update).toHaveBeenCalledTimes(1); + const payload = (interaction.update as unknown as Mock).mock.calls[0][0]; + expect(payload.components).toEqual([]); + expect(payload.embeds[0]).toBeInstanceOf(EmbedBuilder); + }); +}); diff --git a/vitest/application/usecases/rejectAnonymousQuestionUseCase.spec.ts b/vitest/application/usecases/rejectAnonymousQuestionUseCase.spec.ts new file mode 100644 index 0000000..77aacf1 --- /dev/null +++ b/vitest/application/usecases/rejectAnonymousQuestionUseCase.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Mock } from "vitest"; +import { ButtonInteraction, EmbedBuilder } from "discord.js"; +import RejectAnonymousQuestionUseCase from "../../../application/usecases/rejectAnonymousQuestionUseCase"; +import ChatService from "../../../domain/service/chatService"; +import LoggerService from "../../../domain/service/loggerService"; +import QuestionTrackingService from "../../../domain/service/questionTrackingService"; + +describe("RejectAnonymousQuestionUseCase", () => { + let chatService: ChatService; + let loggerService: LoggerService; + let questionTrackingService: QuestionTrackingService; + let interaction: ButtonInteraction; + + beforeEach(() => { + chatService = { + sendMessageToChannel: vi.fn(), + } as unknown as ChatService; + + loggerService = { + log: vi.fn(), + } as unknown as LoggerService; + + questionTrackingService = { + removeQuestion: vi.fn(), + } as unknown as QuestionTrackingService; + + interaction = { + update: vi.fn(), + } as unknown as ButtonInteraction; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("notifies user of rejection, updates embed and clears tracking", async () => { + const useCase = new RejectAnonymousQuestionUseCase({ + chatService, + loggerService, + questionTrackingService, + }); + + await useCase.execute({ + questionId: "321", + moderatorId: "mod-9", + interactionId: "reject_321_dm-77_user-55", + questionContent: "Will it blend?", + interaction, + }); + + expect(chatService.sendMessageToChannel).toHaveBeenCalledWith( + "A tua pergunta anónima foi rejeitada pelos moderadores.", + "dm-77" + ); + + expect(questionTrackingService.removeQuestion).toHaveBeenCalledWith("user-55"); + + expect(interaction.update).toHaveBeenCalledTimes(1); + const payload = (interaction.update as unknown as Mock).mock.calls[0][0]; + expect(payload.components).toEqual([]); + expect(payload.embeds[0]).toBeInstanceOf(EmbedBuilder); + }); +}); diff --git a/vitest/service/commandUseCaseResolver.spec.ts b/vitest/service/commandUseCaseResolver.spec.ts index c0197c1..0f530d1 100644 --- a/vitest/service/commandUseCaseResolver.spec.ts +++ b/vitest/service/commandUseCaseResolver.spec.ts @@ -48,10 +48,8 @@ describe("CommandUseCaseResolver", () => { expect(() => commandUseCaseResolver.resolveByCommand("!cwl", mockContext)).not.toThrow(); }); - it("should throw UseCaseNotFound error for unknown command", async () => { - await expect(commandUseCaseResolver.resolveByCommand("!unknown", mockContext)).rejects.toThrow( - 'Use case for command "!unknown" not found' - ); + it("should return false for unknown command", async () => { + await expect(commandUseCaseResolver.resolveByCommand("!unknown", mockContext)).resolves.toBe(false); }); afterEach(() => {