diff --git a/packages/bot/src/functions/download/commands/download/command.spec.ts b/packages/bot/src/functions/download/commands/download/command.spec.ts new file mode 100644 index 000000000..8a02694a8 --- /dev/null +++ b/packages/bot/src/functions/download/commands/download/command.spec.ts @@ -0,0 +1,363 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals' + +const interactionReplyMock = jest.fn() +const downloadServiceMock = { + executeDownload: jest.fn(), +} + +jest.mock('../../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: any[]) => interactionReplyMock(...args), +})) + +jest.mock('./service', () => ({ + DownloadCommandService: jest.fn(() => downloadServiceMock), +})) + +import downloadCommand from './command' + +function createMockUser(id = 'user-123') { + return { + id, + } +} + +function createInteraction({ + userId = 'user-123', + user = null as any, + guildId = 'guild-123', + url = 'https://example.com/video.mp4', +} = {}) { + const interaction = { + user: user || createMockUser(userId), + guildId, + options: { + getString: jest.fn((name: string, required?: boolean) => + name === 'url' ? url : null, + ), + }, + } + + return interaction as any +} + +describe('download command', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('shows processing message immediately', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: 5242880, // 5 MB + }) + + await downloadCommand.execute({ interaction }) + + // First reply should be the processing message + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Processing'), + }), + }), + ) + }) + + test('extracts URL from interaction options', async () => { + const testUrl = 'https://youtube.com/watch?v=dQw4w9WgXcQ' + const interaction = createInteraction({ url: testUrl }) + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: 5242880, + }) + + await downloadCommand.execute({ interaction }) + + expect(downloadServiceMock.executeDownload).toHaveBeenCalledWith( + expect.objectContaining({ + url: testUrl, + }), + ) + }) + + test('passes user ID to download service', async () => { + const userId = 'user-999' + const interaction = createInteraction({ userId }) + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: 5242880, + }) + + await downloadCommand.execute({ interaction }) + + expect(downloadServiceMock.executeDownload).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + }), + ) + }) + + test('passes guild ID to download service', async () => { + const guildId = 'guild-456' + const interaction = createInteraction({ guildId }) + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: 5242880, + }) + + await downloadCommand.execute({ interaction }) + + expect(downloadServiceMock.executeDownload).toHaveBeenCalledWith( + expect.objectContaining({ + guildId, + }), + ) + }) + + test('sets format to video by default', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: 5242880, + }) + + await downloadCommand.execute({ interaction }) + + expect(downloadServiceMock.executeDownload).toHaveBeenCalledWith( + expect.objectContaining({ + format: 'video', + }), + ) + }) + + test('shows success message with file name and size on successful download', async () => { + const interaction = createInteraction() + const fileName = 'my-video.mp4' + const fileSize = 52428800 // 50 MB + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName, + fileSize, + }) + + await downloadCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Downloaded'), + }), + }), + ) + }) + + test('formats file size correctly for success message', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: 5242880, // 5 MB + }) + + await downloadCommand.execute({ interaction }) + + // Should format 5242880 bytes as ~5.00 MB + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('.00 MB'), + }), + }), + ) + }) + + test('handles downloads larger than 1 GB', async () => { + const interaction = createInteraction() + const largeSize = 1073741824 // 1 GB + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'large-video.mp4', + fileSize: largeSize, + }) + + await downloadCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('1024.00 MB'), + }), + }), + ) + }) + + test('shows error message when download fails', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockResolvedValue({ + success: false, + error: 'URL not accessible', + }) + + await downloadCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Download failed'), + }), + }), + ) + }) + + test('includes error message in failure response', async () => { + const interaction = createInteraction() + const errorMessage = 'Invalid URL format' + downloadServiceMock.executeDownload.mockResolvedValue({ + success: false, + error: errorMessage, + }) + + await downloadCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining(errorMessage), + }), + }), + ) + }) + + test('marks error response as ephemeral', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockResolvedValue({ + success: false, + error: 'Download failed', + }) + + await downloadCommand.execute({ interaction }) + + // Find the reply call with the error + const errorCall = interactionReplyMock.mock.calls.find((call) => + call[0].content.content.includes('Download failed'), + ) + expect(errorCall[0]).toHaveProperty('content.ephemeral', true) + }) + + test('handles unexpected errors during download', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockRejectedValue( + new Error('Network error'), + ) + + await downloadCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('An error occurred'), + }), + }), + ) + }) + + test('marks unexpected error response as ephemeral', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockRejectedValue( + new Error('Network error'), + ) + + await downloadCommand.execute({ interaction }) + + // Find the reply call with the error + const errorCall = interactionReplyMock.mock.calls.find((call) => + call[0].content.content.includes('An error occurred'), + ) + expect(errorCall[0]).toHaveProperty('content.ephemeral', true) + }) + + test('formats zero file size gracefully', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'empty.mp4', + fileSize: 0, + }) + + await downloadCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Unknown'), + }), + }), + ) + }) + + test('handles undefined file size', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: undefined, + }) + + await downloadCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Unknown'), + }), + }), + ) + }) + + test('has correct command metadata', () => { + expect(downloadCommand.data.name).toBe('download') + expect(downloadCommand.data.description).toContain('Download media') + }) + + test('handles guild ID as undefined', async () => { + const interaction = createInteraction({ guildId: null as any }) + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: 5242880, + }) + + await downloadCommand.execute({ interaction }) + + expect(downloadServiceMock.executeDownload).toHaveBeenCalledWith( + expect.objectContaining({ + guildId: undefined, + }), + ) + }) + + test('sends multiple replies in correct order', async () => { + const interaction = createInteraction() + downloadServiceMock.executeDownload.mockResolvedValue({ + success: true, + fileName: 'video.mp4', + fileSize: 5242880, + }) + + await downloadCommand.execute({ interaction }) + + // Should have at least 2 calls: processing and then success/failure + expect(interactionReplyMock.mock.calls.length).toBeGreaterThanOrEqual(2) + + // First call should be processing message + expect(interactionReplyMock.mock.calls[0][0].content.content).toContain( + 'Processing', + ) + }) +}) diff --git a/packages/bot/src/functions/moderation/commands/ban.spec.ts b/packages/bot/src/functions/moderation/commands/ban.spec.ts new file mode 100644 index 000000000..56fda6542 --- /dev/null +++ b/packages/bot/src/functions/moderation/commands/ban.spec.ts @@ -0,0 +1,309 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals' + +const moderationServiceMock = { + createCase: jest.fn(), +} +const interactionReplyMock = jest.fn() +const postToModLogMock = jest.fn() +const infoLogMock = jest.fn() +const errorLogMock = jest.fn() + +jest.mock('@lucky/shared/services', () => ({ + moderationService: moderationServiceMock, +})) + +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: any[]) => interactionReplyMock(...args), +})) + +jest.mock('../helpers/modLogPoster', () => ({ + postToModLog: (...args: any[]) => postToModLogMock(...args), +})) + +jest.mock('@lucky/shared/utils', () => ({ + infoLog: (...args: any[]) => infoLogMock(...args), + errorLog: (...args: any[]) => errorLogMock(...args), +})) + +import banCommand from './ban' + +function createMockUser(id = 'user-123', tag = 'TestUser#1234') { + return { + id, + tag, + send: jest.fn().mockResolvedValue(undefined), + } +} + +function createMockGuild(id = 'guild-123', name = 'Test Guild') { + return { + id, + name, + members: { + ban: jest.fn().mockResolvedValue(undefined), + }, + } +} + +function createInteraction({ + guildId = 'guild-123', + guild = undefined as any, + userId = 'user-123', + user = null as any, + targetUser = null as any, + reason = null as string | null, + deleteMessages = null as string | null, + silent = false, +} = {}) { + const interaction = { + guild: guild !== undefined ? guild : createMockGuild(guildId), + guildId, + channelId: 'channel-123', + user: user || createMockUser(userId), + options: { + getUser: jest.fn((name: string, required?: boolean) => + name === 'user' ? targetUser : null, + ), + getString: jest.fn((name: string) => { + if (name === 'reason') return reason + if (name === 'delete_messages') return deleteMessages + return null + }), + getBoolean: jest.fn((name: string) => + name === 'silent' ? silent : null, + ), + }, + } + + return interaction as any +} + +describe('ban command', () => { + beforeEach(() => { + jest.clearAllMocks() + moderationServiceMock.createCase.mockResolvedValue({ + caseNumber: 1, + }) + }) + + test('returns early if not in a guild', async () => { + const interaction = createInteraction({ guild: null }) + const targetUser = createMockUser('target-123') + interaction.options.getUser.mockImplementation((name: string) => + name === 'user' ? targetUser : null, + ) + + await banCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining( + 'only be used in a server', + ), + }), + }), + ) + }) + + test('sends DM to user before banning (when not silent)', async () => { + const targetUser = createMockUser('target-123', 'TargetUser#5678') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + silent: false, + }) + + await banCommand.execute({ interaction }) + + expect(targetUser.send).toHaveBeenCalled() + expect(targetUser.send.mock.calls.length).toBeGreaterThan(0) + }) + + test('does not send DM when silent flag is true', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + silent: true, + }) + + await banCommand.execute({ interaction }) + + expect(targetUser.send).not.toHaveBeenCalled() + }) + + test('calls guild.members.ban with correct parameters', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'flooding', + deleteMessages: '86400', + silent: true, + }) + + await banCommand.execute({ interaction }) + + expect(guild.members.ban).toHaveBeenCalledWith('target-123', { + reason: 'flooding', + deleteMessageSeconds: 86400, + }) + }) + + test('creates moderation case with correct data', async () => { + const targetUser = createMockUser('target-123', 'BadUser#1111') + const guild = createMockGuild('guild-456', 'My Server') + const moderator = createMockUser('mod-123', 'Moderator#2222') + const interaction = createInteraction({ + guild, + user: moderator, + targetUser, + reason: 'harassment', + deleteMessages: '0', + }) + + await banCommand.execute({ interaction }) + + expect(moderationServiceMock.createCase).toHaveBeenCalledWith({ + guildId: 'guild-456', + type: 'ban', + userId: 'target-123', + username: 'BadUser#1111', + moderatorId: 'mod-123', + moderatorName: 'Moderator#2222', + reason: 'harassment', + channelId: 'channel-123', + }) + }) + + test('uses default reason when none provided', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: null, + }) + + await banCommand.execute({ interaction }) + + expect(moderationServiceMock.createCase).toHaveBeenCalledWith( + expect.objectContaining({ + reason: 'No reason provided', + }), + ) + }) + + test('includes message deletion info in embed when deleteMessages > 0', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + deleteMessages: '86400', + silent: true, + }) + + await banCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalled() + expect(guild.members.ban).toHaveBeenCalledWith('target-123', { + reason: 'spam', + deleteMessageSeconds: 86400, + }) + }) + + test('posts to mod log after successful ban', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + }) + + await banCommand.execute({ interaction }) + + expect(postToModLogMock).toHaveBeenCalledWith(guild, expect.any(Object)) + }) + + test('logs info message on successful ban', async () => { + const targetUser = createMockUser('target-456', 'TargetUser#9999') + const guild = createMockGuild('guild-789', 'Admin Server') + const moderator = createMockUser('mod-999', 'Admin#0000') + const interaction = createInteraction({ + guild, + user: moderator, + targetUser, + reason: 'spam', + }) + + await banCommand.execute({ interaction }) + + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('TargetUser#9999'), + }), + ) + }) + + test('handles DM send failure gracefully', async () => { + const targetUser = createMockUser('target-123') + targetUser.send.mockRejectedValue(new Error('DM failed')) + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + silent: false, + }) + + await banCommand.execute({ interaction }) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Failed to send DM'), + }), + ) + // Ban should still proceed + expect(guild.members.ban).toHaveBeenCalled() + }) + + test('handles ban failure', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + guild.members.ban.mockRejectedValue(new Error('No permissions')) + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + }) + + await banCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Failed to ban user'), + }), + }), + ) + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to ban user', + }), + ) + }) + + test('has correct command metadata', () => { + expect(banCommand.data.name).toBe('ban') + expect(banCommand.data.description).toContain('Ban a user') + }) +}) diff --git a/packages/bot/src/functions/moderation/commands/kick.spec.ts b/packages/bot/src/functions/moderation/commands/kick.spec.ts new file mode 100644 index 000000000..b03250199 --- /dev/null +++ b/packages/bot/src/functions/moderation/commands/kick.spec.ts @@ -0,0 +1,364 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals' + +const moderationServiceMock = { + createCase: jest.fn(), +} +const interactionReplyMock = jest.fn() +const postToModLogMock = jest.fn() +const infoLogMock = jest.fn() +const errorLogMock = jest.fn() + +jest.mock('@lucky/shared/services', () => ({ + moderationService: moderationServiceMock, +})) + +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: any[]) => interactionReplyMock(...args), +})) + +jest.mock('../helpers/modLogPoster', () => ({ + postToModLog: (...args: any[]) => postToModLogMock(...args), +})) + +jest.mock('@lucky/shared/utils', () => ({ + infoLog: (...args: any[]) => infoLogMock(...args), + errorLog: (...args: any[]) => errorLogMock(...args), +})) + +import kickCommand from './kick' + +function createMockUser(id = 'user-123', tag = 'TestUser#1234') { + return { + id, + tag, + send: jest.fn().mockResolvedValue(undefined), + } +} + +function createMockMember(userId = 'user-123') { + return { + user: { id: userId }, + kick: jest.fn().mockResolvedValue(undefined), + } +} + +function createMockGuild(id = 'guild-123', name = 'Test Guild') { + return { + id, + name, + members: { + fetch: jest.fn().mockResolvedValue(undefined), + }, + } +} + +function createInteraction({ + guildId = 'guild-123', + guild = undefined as any, + userId = 'user-123', + user = null as any, + targetUser = null as any, + targetMember = null as any, + reason = null as string | null, + silent = false, +} = {}) { + const interaction = { + guild: guild !== undefined ? guild : createMockGuild(guildId), + guildId, + channelId: 'channel-123', + user: user || createMockUser(userId), + options: { + getUser: jest.fn((name: string, required?: boolean) => + name === 'user' ? targetUser : null, + ), + getString: jest.fn((name: string) => + name === 'reason' ? reason : null, + ), + getBoolean: jest.fn((name: string) => + name === 'silent' ? silent : null, + ), + }, + } + + return interaction as any +} + +describe('kick command', () => { + beforeEach(() => { + jest.clearAllMocks() + moderationServiceMock.createCase.mockResolvedValue({ + caseNumber: 42, + }) + }) + + test('returns early if not in a guild', async () => { + const interaction = createInteraction({ guild: null }) + const targetUser = createMockUser('target-123') + interaction.options.getUser.mockImplementation((name: string) => + name === 'user' ? targetUser : null, + ) + + await kickCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining( + 'only be used in a server', + ), + }), + }), + ) + }) + + test('returns error if member not found in guild', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(null) + const interaction = createInteraction({ + guild, + targetUser, + reason: 'disruptive', + }) + + await kickCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('User not found'), + }), + }), + ) + }) + + test('sends DM to user before kicking (when not silent)', async () => { + const targetUser = createMockUser('target-123', 'TargetUser#5678') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + reason: 'disruptive', + silent: false, + }) + + await kickCommand.execute({ interaction }) + + expect(targetUser.send).toHaveBeenCalled() + expect(targetUser.send.mock.calls.length).toBeGreaterThan(0) + }) + + test('does not send DM when silent flag is true', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + reason: 'disruptive', + silent: true, + }) + + await kickCommand.execute({ interaction }) + + expect(targetUser.send).not.toHaveBeenCalled() + }) + + test('calls member.kick with correct reason', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + reason: 'spam', + silent: true, + }) + + await kickCommand.execute({ interaction }) + + expect(targetMember.kick).toHaveBeenCalledWith('spam') + }) + + test('creates moderation case with correct data', async () => { + const targetUser = createMockUser('target-999', 'BadUser#1111') + const targetMember = createMockMember('target-999') + const guild = createMockGuild('guild-456', 'My Server') + const moderator = createMockUser('mod-123', 'Moderator#2222') + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + user: moderator, + targetUser, + targetMember, + reason: 'harassment', + }) + + await kickCommand.execute({ interaction }) + + expect(moderationServiceMock.createCase).toHaveBeenCalledWith({ + guildId: 'guild-456', + type: 'kick', + userId: 'target-999', + username: 'BadUser#1111', + moderatorId: 'mod-123', + moderatorName: 'Moderator#2222', + reason: 'harassment', + channelId: 'channel-123', + }) + }) + + test('uses default reason when none provided', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + reason: null, + }) + + await kickCommand.execute({ interaction }) + + expect(moderationServiceMock.createCase).toHaveBeenCalledWith( + expect.objectContaining({ + reason: 'No reason provided', + }), + ) + }) + + test('includes case number in response embed', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + reason: 'spam', + silent: true, + }) + + moderationServiceMock.createCase.mockResolvedValue({ + caseNumber: 99, + }) + + await kickCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalled() + expect(moderationServiceMock.createCase).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'kick', + }), + ) + }) + + test('posts to mod log after successful kick', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + reason: 'spam', + }) + + await kickCommand.execute({ interaction }) + + expect(postToModLogMock).toHaveBeenCalledWith(guild, expect.any(Object)) + }) + + test('logs info message on successful kick', async () => { + const targetUser = createMockUser('target-456', 'TargetUser#9999') + const targetMember = createMockMember('target-456') + const guild = createMockGuild('guild-789', 'Admin Server') + const moderator = createMockUser('mod-999', 'Admin#0000') + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + user: moderator, + targetUser, + targetMember, + reason: 'spam', + }) + + await kickCommand.execute({ interaction }) + + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('TargetUser#9999'), + }), + ) + }) + + test('handles DM send failure gracefully', async () => { + const targetUser = createMockUser('target-123') + targetUser.send.mockRejectedValue(new Error('DM failed')) + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + reason: 'spam', + silent: false, + }) + + await kickCommand.execute({ interaction }) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Failed to send DM'), + }), + ) + // Kick should still proceed + expect(targetMember.kick).toHaveBeenCalled() + }) + + test('handles kick failure', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + targetMember.kick.mockRejectedValue(new Error('No permissions')) + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + reason: 'spam', + }) + + await kickCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Failed to kick user'), + }), + }), + ) + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to kick user', + }), + ) + }) + + test('has correct command metadata', () => { + expect(kickCommand.data.name).toBe('kick') + expect(kickCommand.data.description).toContain('Kick a member') + }) +}) diff --git a/packages/bot/src/functions/moderation/commands/mute.spec.ts b/packages/bot/src/functions/moderation/commands/mute.spec.ts new file mode 100644 index 000000000..c1e04b4d5 --- /dev/null +++ b/packages/bot/src/functions/moderation/commands/mute.spec.ts @@ -0,0 +1,412 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals' + +const moderationServiceMock = { + createCase: jest.fn(), +} +const interactionReplyMock = jest.fn() +const postToModLogMock = jest.fn() +const formatDurationHumanMock = jest.fn((seconds: number) => { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${seconds / 60}m` + if (seconds < 86400) return `${seconds / 3600}h` + return `${seconds / 86400}d` +}) +const infoLogMock = jest.fn() +const errorLogMock = jest.fn() + +jest.mock('@lucky/shared/services', () => ({ + moderationService: moderationServiceMock, +})) + +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: any[]) => interactionReplyMock(...args), +})) + +jest.mock('../helpers/modLogPoster', () => ({ + postToModLog: (...args: any[]) => postToModLogMock(...args), +})) + +jest.mock('../../../utils/general/formatDuration', () => ({ + formatDurationHuman: (seconds: number) => formatDurationHumanMock(seconds), +})) + +jest.mock('@lucky/shared/utils', () => ({ + infoLog: (...args: any[]) => infoLogMock(...args), + errorLog: (...args: any[]) => errorLogMock(...args), +})) + +import muteCommand from './mute' + +function createMockUser(id = 'user-123', tag = 'TestUser#1234') { + return { + id, + tag, + send: jest.fn().mockResolvedValue(undefined), + } +} + +function createMockMember(userId = 'user-123') { + return { + user: { id: userId }, + timeout: jest.fn().mockResolvedValue(undefined), + } +} + +function createMockGuild(id = 'guild-123', name = 'Test Guild') { + return { + id, + name, + members: { + fetch: jest.fn().mockResolvedValue(undefined), + }, + } +} + +function createInteraction({ + guildId = 'guild-123', + guild = undefined as any, + userId = 'user-123', + user = null as any, + targetUser = null as any, + targetMember = null as any, + duration = '3600', + reason = null as string | null, + silent = false, +} = {}) { + const interaction = { + guild: guild !== undefined ? guild : createMockGuild(guildId), + guildId, + channelId: 'channel-123', + user: user || createMockUser(userId), + options: { + getUser: jest.fn((name: string, required?: boolean) => + name === 'user' ? targetUser : null, + ), + getString: jest.fn((name: string) => { + if (name === 'duration') return duration + if (name === 'reason') return reason + return null + }), + getBoolean: jest.fn((name: string) => + name === 'silent' ? silent : null, + ), + }, + } + + return interaction as any +} + +describe('mute command', () => { + beforeEach(() => { + jest.clearAllMocks() + moderationServiceMock.createCase.mockResolvedValue({ + caseNumber: 1, + }) + }) + + test('returns early if not in a guild', async () => { + const interaction = createInteraction({ guild: null }) + const targetUser = createMockUser('target-123') + interaction.options.getUser.mockImplementation((name: string) => + name === 'user' ? targetUser : null, + ) + + await muteCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining( + 'only be used in a server', + ), + }), + }), + ) + }) + + test('returns error if member not found in guild', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(null) + const interaction = createInteraction({ + guild, + targetUser, + duration: '3600', + reason: 'spam', + }) + + await muteCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('User not found'), + }), + }), + ) + }) + + test('converts duration seconds to milliseconds for member.timeout', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration: '3600', + reason: 'spam', + silent: true, + }) + + await muteCommand.execute({ interaction }) + + expect(targetMember.timeout).toHaveBeenCalledWith(3600000, 'spam') + }) + + test('creates moderation case with duration in seconds', async () => { + const targetUser = createMockUser('target-123', 'BadUser#1111') + const targetMember = createMockMember('target-123') + const guild = createMockGuild('guild-456', 'My Server') + const moderator = createMockUser('mod-123', 'Moderator#2222') + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + user: moderator, + targetUser, + targetMember, + duration: '604800', + reason: 'harassment', + }) + + await muteCommand.execute({ interaction }) + + expect(moderationServiceMock.createCase).toHaveBeenCalledWith({ + guildId: 'guild-456', + type: 'mute', + userId: 'target-123', + username: 'BadUser#1111', + moderatorId: 'mod-123', + moderatorName: 'Moderator#2222', + reason: 'harassment', + duration: 604800, + channelId: 'channel-123', + }) + }) + + test('uses default reason when none provided', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration: '3600', + reason: null, + }) + + await muteCommand.execute({ interaction }) + + expect(moderationServiceMock.createCase).toHaveBeenCalledWith( + expect.objectContaining({ + reason: 'No reason provided', + }), + ) + }) + + test('includes duration in response embed', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration: '3600', + reason: 'spam', + silent: true, + }) + + await muteCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalled() + expect(interactionReplyMock.mock.calls.length).toBeGreaterThan(0) + }) + + test('sends DM with duration when not silent', async () => { + const targetUser = createMockUser('target-123', 'TargetUser#5678') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration: '3600', + reason: 'spam', + silent: false, + }) + + await muteCommand.execute({ interaction }) + + expect(targetUser.send).toHaveBeenCalled() + expect(targetUser.send.mock.calls.length).toBeGreaterThan(0) + }) + + test('does not send DM when silent flag is true', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration: '3600', + reason: 'spam', + silent: true, + }) + + await muteCommand.execute({ interaction }) + + expect(targetUser.send).not.toHaveBeenCalled() + }) + + test('posts to mod log after successful mute', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration: '3600', + reason: 'spam', + }) + + await muteCommand.execute({ interaction }) + + expect(postToModLogMock).toHaveBeenCalledWith(guild, expect.any(Object)) + }) + + test('logs info message on successful mute', async () => { + const targetUser = createMockUser('target-456', 'TargetUser#9999') + const targetMember = createMockMember('target-456') + const guild = createMockGuild('guild-789', 'Admin Server') + const moderator = createMockUser('mod-999', 'Admin#0000') + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + user: moderator, + targetUser, + targetMember, + duration: '3600', + reason: 'spam', + }) + + await muteCommand.execute({ interaction }) + + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('TargetUser#9999'), + }), + ) + }) + + test('handles DM send failure gracefully', async () => { + const targetUser = createMockUser('target-123') + targetUser.send.mockRejectedValue(new Error('DM failed')) + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration: '3600', + reason: 'spam', + silent: false, + }) + + await muteCommand.execute({ interaction }) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Failed to send DM'), + }), + ) + // Mute should still proceed + expect(targetMember.timeout).toHaveBeenCalled() + }) + + test('handles mute failure', async () => { + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + targetMember.timeout.mockRejectedValue(new Error('No permissions')) + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration: '3600', + reason: 'spam', + }) + + await muteCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Failed to mute user'), + }), + }), + ) + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to mute user', + }), + ) + }) + + test('has correct command metadata', () => { + expect(muteCommand.data.name).toBe('mute') + expect(muteCommand.data.description).toContain('Timeout') + }) + + test('handles various duration choices correctly', async () => { + const durations = ['60', '300', '600', '3600', '86400', '604800'] + + for (const duration of durations) { + jest.clearAllMocks() + moderationServiceMock.createCase.mockResolvedValue({ + caseNumber: 1, + }) + + const targetUser = createMockUser('target-123') + const targetMember = createMockMember('target-123') + const guild = createMockGuild() + guild.members.fetch.mockResolvedValue(targetMember) + const interaction = createInteraction({ + guild, + targetUser, + targetMember, + duration, + reason: 'test', + silent: true, + }) + + await muteCommand.execute({ interaction }) + + expect(targetMember.timeout).toHaveBeenCalledWith( + parseInt(duration) * 1000, + 'test', + ) + } + }) +}) diff --git a/packages/bot/src/functions/moderation/commands/warn.spec.ts b/packages/bot/src/functions/moderation/commands/warn.spec.ts new file mode 100644 index 000000000..99f739e32 --- /dev/null +++ b/packages/bot/src/functions/moderation/commands/warn.spec.ts @@ -0,0 +1,335 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals' + +const moderationServiceMock = { + createCase: jest.fn(), +} +const interactionReplyMock = jest.fn() +const postToModLogMock = jest.fn() +const infoLogMock = jest.fn() +const errorLogMock = jest.fn() + +jest.mock('@lucky/shared/services', () => ({ + moderationService: moderationServiceMock, +})) + +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: any[]) => interactionReplyMock(...args), +})) + +jest.mock('../helpers/modLogPoster', () => ({ + postToModLog: (...args: any[]) => postToModLogMock(...args), +})) + +jest.mock('@lucky/shared/utils', () => ({ + infoLog: (...args: any[]) => infoLogMock(...args), + errorLog: (...args: any[]) => errorLogMock(...args), +})) + +import warnCommand from './warn' + +function createMockUser(id = 'user-123', tag = 'TestUser#1234') { + return { + id, + tag, + send: jest.fn().mockResolvedValue(undefined), + } +} + +function createMockGuild(id = 'guild-123', name = 'Test Guild') { + return { + id, + name, + } +} + +function createInteraction({ + guildId = 'guild-123', + guild = undefined as any, + userId = 'user-123', + user = null as any, + targetUser = null as any, + reason = null as string | null, + silent = false, +} = {}) { + const interaction = { + guild: guild !== undefined ? guild : createMockGuild(guildId), + guildId, + channelId: 'channel-123', + user: user || createMockUser(userId), + options: { + getUser: jest.fn((name: string, required?: boolean) => + name === 'user' ? targetUser : null, + ), + getString: jest.fn((name: string) => + name === 'reason' ? reason : null, + ), + getBoolean: jest.fn((name: string) => + name === 'silent' ? silent : null, + ), + }, + } + + return interaction as any +} + +describe('warn command', () => { + beforeEach(() => { + jest.clearAllMocks() + moderationServiceMock.createCase.mockResolvedValue({ + caseNumber: 5, + }) + }) + + test('returns early if not in a guild', async () => { + const interaction = createInteraction({ guild: null }) + const targetUser = createMockUser('target-123') + interaction.options.getUser.mockImplementation((name: string) => + name === 'user' ? targetUser : null, + ) + + await warnCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining( + 'only be used in a server', + ), + }), + }), + ) + }) + + test('creates moderation case for warning', async () => { + const targetUser = createMockUser('target-123', 'BadUser#1111') + const guild = createMockGuild('guild-456', 'My Server') + const moderator = createMockUser('mod-123', 'Moderator#2222') + const interaction = createInteraction({ + guild, + user: moderator, + targetUser, + reason: 'off-topic spam', + }) + + await warnCommand.execute({ interaction }) + + expect(moderationServiceMock.createCase).toHaveBeenCalledWith({ + guildId: 'guild-456', + type: 'warn', + userId: 'target-123', + username: 'BadUser#1111', + moderatorId: 'mod-123', + moderatorName: 'Moderator#2222', + reason: 'off-topic spam', + channelId: 'channel-123', + }) + }) + + test('uses default reason when none provided', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: null, + }) + + await warnCommand.execute({ interaction }) + + expect(moderationServiceMock.createCase).toHaveBeenCalledWith( + expect.objectContaining({ + reason: 'No reason provided', + }), + ) + }) + + test('includes case number in response embed', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'warning test', + silent: true, + }) + + moderationServiceMock.createCase.mockResolvedValue({ + caseNumber: 42, + }) + + await warnCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalled() + expect(interactionReplyMock.mock.calls.length).toBeGreaterThan(0) + }) + + test('sends DM to user with case number when not silent', async () => { + const targetUser = createMockUser('target-123', 'TargetUser#5678') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'warning test', + silent: false, + }) + + moderationServiceMock.createCase.mockResolvedValue({ + caseNumber: 10, + }) + + await warnCommand.execute({ interaction }) + + expect(targetUser.send).toHaveBeenCalled() + expect(targetUser.send.mock.calls.length).toBeGreaterThan(0) + }) + + test('does not send DM when silent flag is true', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'warning test', + silent: true, + }) + + await warnCommand.execute({ interaction }) + + expect(targetUser.send).not.toHaveBeenCalled() + }) + + test('posts to mod log after successful warning', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + }) + + await warnCommand.execute({ interaction }) + + expect(postToModLogMock).toHaveBeenCalledWith(guild, expect.any(Object)) + }) + + test('logs info message on successful warning', async () => { + const targetUser = createMockUser('target-456', 'TargetUser#9999') + const guild = createMockGuild('guild-789', 'Admin Server') + const moderator = createMockUser('mod-999', 'Admin#0000') + const interaction = createInteraction({ + guild, + user: moderator, + targetUser, + reason: 'spam', + }) + + await warnCommand.execute({ interaction }) + + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('TargetUser#9999'), + }), + ) + }) + + test('handles DM send failure gracefully', async () => { + const targetUser = createMockUser('target-123') + targetUser.send.mockRejectedValue(new Error('DM failed')) + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + silent: false, + }) + + await warnCommand.execute({ interaction }) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Failed to send DM'), + }), + ) + // Warning should still be created + expect(moderationServiceMock.createCase).toHaveBeenCalled() + }) + + test('handles warning creation failure', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + moderationServiceMock.createCase.mockRejectedValue( + new Error('DB error'), + ) + const interaction = createInteraction({ + guild, + targetUser, + reason: 'spam', + }) + + await warnCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Failed to issue warning'), + }), + }), + ) + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to issue warning', + }), + ) + }) + + test('includes reason in both embed and DM', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const reason = 'excessive pinging' + const interaction = createInteraction({ + guild, + targetUser, + reason, + silent: false, + }) + + await warnCommand.execute({ interaction }) + + // Check that both main embed and DM were sent + expect(interactionReplyMock).toHaveBeenCalled() + expect(targetUser.send).toHaveBeenCalled() + }) + + test('has correct command metadata', () => { + expect(warnCommand.data.name).toBe('warn') + expect(warnCommand.data.description).toContain('warning') + }) + + test('creates case before sending DM', async () => { + const targetUser = createMockUser('target-123') + const guild = createMockGuild() + const interaction = createInteraction({ + guild, + targetUser, + reason: 'test', + silent: false, + }) + + const callOrder: string[] = [] + moderationServiceMock.createCase.mockImplementation(() => { + callOrder.push('createCase') + return Promise.resolve({ caseNumber: 99 }) + }) + targetUser.send.mockImplementation(() => { + callOrder.push('sendDM') + return Promise.resolve(undefined) + }) + + await warnCommand.execute({ interaction }) + + // Case should be created before DM is sent + expect(callOrder.indexOf('createCase')).toBeLessThan( + callOrder.indexOf('sendDM'), + ) + }) +})