diff --git a/packages/bot/src/bot/start/initializer.spec.ts b/packages/bot/src/bot/start/initializer.spec.ts new file mode 100644 index 000000000..373ceb62b --- /dev/null +++ b/packages/bot/src/bot/start/initializer.spec.ts @@ -0,0 +1,408 @@ +import { + describe, + it, + expect, + beforeEach, + jest, +} from '@jest/globals' +import { BotInitializer } from './initializer' +import type { CustomClient } from '../../types' + +const errorLogMock = jest.fn() +const infoLogMock = jest.fn() +const createClientMock = jest.fn() +const startClientMock = jest.fn() +const createPlayerMock = jest.fn() +const getCommandsMock = jest.fn() +const setCommandsMock = jest.fn() +const handleEventsMock = jest.fn() +const initProviderHealthMock = jest.fn() +const redisClientConnectMock = jest.fn() +const redisClientDisconnectMock = jest.fn() +const musicWatchdogStartMock = jest.fn() + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: (...args: unknown[]) => errorLogMock(...args), + infoLog: (...args: unknown[]) => infoLogMock(...args), +})) + +jest.mock('../../handlers/clientHandler', () => ({ + createClient: (...args: unknown[]) => createClientMock(...args), + startClient: (...args: unknown[]) => startClientMock(...args), +})) + +jest.mock('../../handlers/playerHandler', () => ({ + createPlayer: (...args: unknown[]) => createPlayerMock(...args), +})) + +jest.mock('../../handlers/commandsHandler', () => ({ + setCommands: (...args: unknown[]) => setCommandsMock(...args), +})) + +jest.mock('../../register', () => ({ + getCommands: (...args: unknown[]) => getCommandsMock(...args), +})) + +jest.mock('../../handlers/eventHandler', () => ({ + __esModule: true, + default: (...args: unknown[]) => handleEventsMock(...args), +})) + +jest.mock('../../utils/music/search/providerHealth', () => ({ + initProviderHealth: (...args: unknown[]) => initProviderHealthMock(...args), +})) + +jest.mock('../../utils/music/watchdog', () => ({ + musicWatchdogService: { + startOrphanSessionMonitor: (...args: unknown[]) => musicWatchdogStartMock(...args), + }, +})) + +jest.mock('@lucky/shared/services', () => ({ + redisClient: { + connect: (...args: unknown[]) => redisClientConnectMock(...args), + disconnect: (...args: unknown[]) => redisClientDisconnectMock(...args), + }, +})) + +describe('BotInitializer', () => { + let initializer: BotInitializer + + beforeEach(() => { + jest.clearAllMocks() + initializer = new BotInitializer() + redisClientConnectMock.mockResolvedValue(true) + createClientMock.mockResolvedValue({ + removeAllListeners: jest.fn(), + destroy: jest.fn().mockResolvedValue(undefined), + player: undefined, + } as unknown as CustomClient) + createPlayerMock.mockResolvedValue({}) + getCommandsMock.mockResolvedValue([]) + setCommandsMock.mockResolvedValue(undefined) + handleEventsMock.mockReturnValue(undefined) + initProviderHealthMock.mockResolvedValue(undefined) + startClientMock.mockResolvedValue(undefined) + }) + + describe('initializeBot', () => { + it('initializes bot successfully with all services', async () => { + const result = await initializer.initializeBot() + + expect(result.success).toBe(true) + expect(result.client).toBeDefined() + expect(redisClientConnectMock).toHaveBeenCalled() + expect(initProviderHealthMock).toHaveBeenCalled() + expect(createClientMock).toHaveBeenCalled() + expect(createPlayerMock).toHaveBeenCalled() + expect(getCommandsMock).toHaveBeenCalled() + expect(setCommandsMock).toHaveBeenCalled() + expect(handleEventsMock).toHaveBeenCalled() + expect(startClientMock).toHaveBeenCalled() + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Bot initialization completed successfully', + }) + ) + }) + + it('skips Redis initialization when skipRedis option is true', async () => { + const result = await initializer.initializeBot({ skipRedis: true }) + + expect(result.success).toBe(true) + expect(redisClientConnectMock).not.toHaveBeenCalled() + }) + + it('skips player creation when skipPlayer option is true', async () => { + const result = await initializer.initializeBot({ skipPlayer: true }) + + expect(result.success).toBe(true) + expect(createPlayerMock).not.toHaveBeenCalled() + }) + + it('skips commands setup when skipCommands option is true', async () => { + const result = await initializer.initializeBot({ skipCommands: true }) + + expect(result.success).toBe(true) + expect(getCommandsMock).not.toHaveBeenCalled() + expect(setCommandsMock).not.toHaveBeenCalled() + }) + + it('skips event handlers when skipEvents option is true', async () => { + const result = await initializer.initializeBot({ skipEvents: true }) + + expect(result.success).toBe(true) + expect(handleEventsMock).not.toHaveBeenCalled() + }) + + it('returns cached client if already initialized', async () => { + const firstResult = await initializer.initializeBot() + const firstClientCallCount = createClientMock.mock.calls.length + jest.clearAllMocks() + + const secondResult = await initializer.initializeBot() + + expect(firstResult.client).toBe(secondResult.client) + expect(createClientMock).not.toHaveBeenCalled() + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('already initialized'), + }) + ) + }) + + it('returns error result when redis connection fails', async () => { + redisClientConnectMock.mockResolvedValue(false) + + const result = await initializer.initializeBot() + + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('initialization failed'), + }) + ) + }) + + it('returns error result when client creation fails', async () => { + createClientMock.mockRejectedValue(new Error('Client creation failed')) + + const result = await initializer.initializeBot() + + expect(result.success).toBe(false) + expect(result.error).toBe('Failed to create Discord client') + }) + + it('returns error result when provider health init fails', async () => { + initProviderHealthMock.mockRejectedValue(new Error('Provider health failed')) + + const result = await initializer.initializeBot() + + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + + it('initializes bot state with correct flags', async () => { + const result = await initializer.initializeBot() + expect(result.success).toBe(true) + + const state = initializer.getState() + expect(state.isInitialized).toBe(true) + expect(state.isConnected).toBe(true) + expect(state.isReady).toBe(true) + expect(state.startTime).toBeDefined() + }) + + it('sets isInitialized flag correctly', async () => { + expect(initializer.isBotInitialized()).toBe(false) + + const result = await initializer.initializeBot() + expect(result.success).toBe(true) + + expect(initializer.isBotInitialized()).toBe(true) + }) + + it('returns client from getClient after initialization', async () => { + expect(initializer.getClient()).toBeNull() + + const result = await initializer.initializeBot() + expect(result.success).toBe(true) + + expect(initializer.getClient()).toBeDefined() + }) + }) + + describe('shutdown', () => { + it('cleans up client and resets state', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + const client = initializer.getClient() + + await initializer.shutdown() + + expect(client?.removeAllListeners).toHaveBeenCalled() + expect(client?.destroy).toHaveBeenCalled() + expect(initializer.getClient()).toBeNull() + expect(initializer.isBotInitialized()).toBe(false) + }) + + it('resets bot state after shutdown', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + let state = initializer.getState() + expect(state.isInitialized).toBe(true) + + await initializer.shutdown() + + state = initializer.getState() + expect(state.isInitialized).toBe(false) + expect(state.isConnected).toBe(false) + expect(state.isReady).toBe(false) + }) + + it('logs success on clean shutdown', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + await initializer.shutdown() + + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Bot shutdown completed', + }) + ) + }) + + it('handles errors during shutdown gracefully', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + const client = initializer.getClient() + const destroyError = new Error('Destroy failed') + ;(client?.destroy as jest.Mock).mockRejectedValue(destroyError) + + await initializer.shutdown() + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Error during bot shutdown'), + }) + ) + }) + + it('silently succeeds when shutdown called with no client', async () => { + expect(() => initializer.shutdown()).not.toThrow() + }) + + it('calls removeAllListeners before destroy', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + const client = initializer.getClient() + const removeAllListenersMock = client?.removeAllListeners as jest.Mock + const destroyMock = client?.destroy as jest.Mock + + await initializer.shutdown() + + expect(removeAllListenersMock.mock.invocationCallOrder[0]).toBeLessThan( + destroyMock.mock.invocationCallOrder[0] + ) + }) + + it('sets client to null after destroy', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + expect(initializer.getClient()).not.toBeNull() + + await initializer.shutdown() + + expect(initializer.getClient()).toBeNull() + }) + + it('allows multiple shutdown calls safely', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + await initializer.shutdown() + await initializer.shutdown() + + expect(initializer.getClient()).toBeNull() + }) + }) + + describe('getClient', () => { + it('returns null before initialization', () => { + expect(initializer.getClient()).toBeNull() + }) + + it('returns client after initialization', async () => { + const result = await initializer.initializeBot() + expect(result.success).toBe(true) + + const client = initializer.getClient() + + expect(client).not.toBeNull() + expect(client?.destroy).toBeDefined() + }) + }) + + describe('getState', () => { + it('returns default state before initialization', () => { + const state = initializer.getState() + + expect(state.isInitialized).toBe(false) + expect(state.isConnected).toBe(false) + expect(state.isReady).toBe(false) + }) + + it('returns copy of state not reference', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + const state1 = initializer.getState() + const state2 = initializer.getState() + + expect(state1).toEqual(state2) + expect(state1).not.toBe(state2) + }) + }) + + describe('isBotInitialized', () => { + it('returns false before initialization', () => { + expect(initializer.isBotInitialized()).toBe(false) + }) + + it('returns true after successful initialization', async () => { + const result = await initializer.initializeBot() + expect(result.success).toBe(true) + + expect(initializer.isBotInitialized()).toBe(true) + }) + + it('returns false after shutdown', async () => { + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + + await initializer.shutdown() + + expect(initializer.isBotInitialized()).toBe(false) + }) + }) + + describe('integration: player startup', () => { + it('starts orphan session monitor when player is created', async () => { + const mockPlayer = { type: 'player' } + createPlayerMock.mockResolvedValue(mockPlayer) + + const result = await initializer.initializeBot() + expect(result.success).toBe(true) + + expect(musicWatchdogStartMock).toHaveBeenCalledWith(mockPlayer) + }) + + it('does not start watchdog when skipPlayer is true', async () => { + const result = await initializer.initializeBot({ skipPlayer: true }) + expect(result.success).toBe(true) + + expect(musicWatchdogStartMock).not.toHaveBeenCalled() + }) + }) + + describe('integration: full lifecycle', () => { + it('completes full init-shutdown cycle', async () => { + expect(initializer.isBotInitialized()).toBe(false) + + const initResult = await initializer.initializeBot() + expect(initResult.success).toBe(true) + expect(initializer.isBotInitialized()).toBe(true) + + await initializer.shutdown() + expect(initializer.isBotInitialized()).toBe(false) + expect(initializer.getClient()).toBeNull() + }) + }) +}) diff --git a/packages/bot/src/bot/start/initializer.ts b/packages/bot/src/bot/start/initializer.ts index c5adf7368..697c96264 100644 --- a/packages/bot/src/bot/start/initializer.ts +++ b/packages/bot/src/bot/start/initializer.ts @@ -143,6 +143,7 @@ export class BotInitializer { async shutdown(): Promise { if (this.client) { try { + this.client.removeAllListeners() await this.client.destroy() this.client = null this.isInitialized = false diff --git a/packages/bot/src/handlers/eventHandler.spec.ts b/packages/bot/src/handlers/eventHandler.spec.ts index 2f3086271..48f9520fb 100644 --- a/packages/bot/src/handlers/eventHandler.spec.ts +++ b/packages/bot/src/handlers/eventHandler.spec.ts @@ -20,6 +20,9 @@ const infoLogMock = jest.fn() const debugLogMock = jest.fn() const captureExceptionMock = jest.fn() const namedSessionListMock = jest.fn() +const cleanupGuildStateMock = jest.fn() +const aiDevToolkitStartMock = jest.fn() +const handleReactionRolesMock = jest.fn() jest.mock('../utils/general/interactionReply', () => ({ interactionReply: (...args: unknown[]) => interactionReplyMock(...args), @@ -71,6 +74,10 @@ jest.mock('../utils/music/namedSessions', () => ({ }, })) +jest.mock('./player/trackNowPlaying', () => ({ + cleanupGuildState: (...args: unknown[]) => cleanupGuildStateMock(...args), +})) + jest.mock('@lucky/shared/utils', () => ({ errorLog: (...args: unknown[]) => errorLogMock(...args), infoLog: (...args: unknown[]) => infoLogMock(...args), @@ -78,6 +85,12 @@ jest.mock('@lucky/shared/utils', () => ({ captureException: (...args: unknown[]) => captureExceptionMock(...args), })) +jest.mock('../services/AiDevToolkitService', () => ({ + aiDevToolkitService: { + start: (...args: unknown[]) => aiDevToolkitStartMock(...args), + }, +})) + function createMockClient() { const onMock = jest.fn() const onceMock = jest.fn() @@ -357,4 +370,230 @@ describe('eventHandler', () => { expect(handleMusicButtonInteractionMock).not.toHaveBeenCalled() }) }) + + describe('guild and channel cleanup', () => { + function getGuildDeleteHandler( + onMock: jest.Mock, + ): ((guild: unknown) => Promise) | undefined { + const call = onMock.mock.calls.find((args) => args[0] === Events.GuildDelete) + return call?.[1] as ((guild: unknown) => Promise) | undefined + } + + function getChannelDeleteHandler( + onMock: jest.Mock, + ): ((channel: unknown) => void) | undefined { + const call = onMock.mock.calls.find( + (args) => args[0] === Events.ChannelDelete, + ) + return call?.[1] as ((channel: unknown) => void) | undefined + } + + describe('handleGuildDelete', () => { + it('calls cleanupGuildState when guild is deleted', async () => { + const { client, onMock } = createMockClient() + handleEvents(client as unknown as never) + + const handler = getGuildDeleteHandler(onMock) + expect(handler).toBeDefined() + + const mockGuild = { id: 'guild-delete-123' } + await handler?.(mockGuild) + + expect(cleanupGuildStateMock).toHaveBeenCalledWith('guild-delete-123') + }) + + it('logs error when guild delete cleanup fails', async () => { + const { client, onMock } = createMockClient() + handleEvents(client as unknown as never) + + const handler = getGuildDeleteHandler(onMock) + const mockGuild = { id: 'guild-error-456' } + + await handler?.(mockGuild) + + expect(cleanupGuildStateMock).toHaveBeenCalled() + }) + + it('handles errors during guild cleanup gracefully', async () => { + const { client, onMock } = createMockClient() + cleanupGuildStateMock.mockImplementation(() => { + throw new Error('Cleanup failed') + }) + + handleEvents(client as unknown as never) + + const handler = getGuildDeleteHandler(onMock) + const mockGuild = { id: 'guild-789' } + + await handler?.(mockGuild) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'Error clearing history on guild delete', + ), + }), + ) + }) + }) + + describe('handleChannelDelete', () => { + it('calls cleanupGuildState when text channel is deleted', () => { + const { client, onMock } = createMockClient() + handleEvents(client as unknown as never) + + const handler = getChannelDeleteHandler(onMock) + expect(handler).toBeDefined() + + const mockChannel = { + guildId: 'guild-ch-123', + isDMBased: () => false, + } + + handler?.(mockChannel) + + expect(cleanupGuildStateMock).toHaveBeenCalledWith('guild-ch-123') + }) + + it('skips cleanup when channel is DM-based', () => { + const { client, onMock } = createMockClient() + handleEvents(client as unknown as never) + + const handler = getChannelDeleteHandler(onMock) + const mockChannel = { + guildId: 'guild-unused', + isDMBased: () => true, + } + + handler?.(mockChannel) + + expect(cleanupGuildStateMock).not.toHaveBeenCalled() + }) + + it('logs error when channel cleanup fails', () => { + const { client, onMock } = createMockClient() + cleanupGuildStateMock.mockImplementation(() => { + throw new Error('Channel cleanup failed') + }) + + handleEvents(client as unknown as never) + + const handler = getChannelDeleteHandler(onMock) + const mockChannel = { + guildId: 'guild-error-ch', + isDMBased: () => false, + } + + handler?.(mockChannel) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'Error clearing state on channel delete', + ), + }), + ) + }) + }) + }) + + describe('client ready', () => { + function getClientReadyHandler( + onceMock: jest.Mock, + ): ((client: unknown) => void) | undefined { + const call = onceMock.mock.calls.find((args) => args[0] === 'clientReady') + return call?.[1] as ((client: unknown) => void) | undefined + } + + it('logs when client is ready', () => { + const { client, onceMock } = createMockClient() + const mockClient = { + ...client, + user: { tag: 'TestBot#0001' }, + } + + handleEvents(mockClient as unknown as never) + + const handler = getClientReadyHandler(onceMock) + expect(handler).toBeDefined() + + handler?.({}) + + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Logged in as'), + }), + ) + }) + + it('logs command count when ready', () => { + const { client, onceMock } = createMockClient() + client.commands.set('cmd1', { execute: jest.fn() }) + client.commands.set('cmd2', { execute: jest.fn() }) + const mockClient = { + ...client, + user: { tag: 'TestBot#0001' }, + } + + handleEvents(mockClient as unknown as never) + + const handler = getClientReadyHandler(onceMock) + handler?.({}) + + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Bot is ready'), + }), + ) + }) + + it('starts ai dev toolkit service when enabled', async () => { + process.env.AI_DEV_TOOLKIT_BOARD_ENABLED = 'true' + aiDevToolkitStartMock.mockResolvedValue(undefined) + + const { client, onceMock } = createMockClient() + const mockClient = { + ...client, + user: { tag: 'TestBot#0001' }, + } + + handleEvents(mockClient as unknown as never) + + const handler = getClientReadyHandler(onceMock) + handler?.(mockClient) + + await new Promise((resolve) => setImmediate(resolve)) + + expect(aiDevToolkitStartMock).toHaveBeenCalled() + + delete process.env.AI_DEV_TOOLKIT_BOARD_ENABLED + }) + + it('logs error if ai dev toolkit fails to start', async () => { + process.env.AI_DEV_TOOLKIT_BOARD_ENABLED = 'true' + aiDevToolkitStartMock.mockRejectedValue(new Error('Toolkit start failed')) + + const { client, onceMock } = createMockClient() + const mockClient = { + ...client, + user: { tag: 'TestBot#0001' }, + } + + handleEvents(mockClient as unknown as never) + + const handler = getClientReadyHandler(onceMock) + handler?.(mockClient) + + await new Promise((resolve) => setImmediate(resolve)) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('AiDevToolkitService'), + }), + ) + + delete process.env.AI_DEV_TOOLKIT_BOARD_ENABLED + }) + }) }) + diff --git a/packages/bot/src/handlers/eventHandler.ts b/packages/bot/src/handlers/eventHandler.ts index 824f0abac..4b9146a83 100644 --- a/packages/bot/src/handlers/eventHandler.ts +++ b/packages/bot/src/handlers/eventHandler.ts @@ -22,6 +22,7 @@ import { handleMusicButtonInteraction } from './musicButtonHandler' import { reactionRolesService } from '@lucky/shared/services' import { aiDevToolkitService } from '../services/AiDevToolkitService' import { namedSessionService } from '../utils/music/namedSessions' +import { cleanupGuildState } from './player/trackNowPlaying' function handleClientReady(client: Client): void { client.once('clientReady', () => { @@ -203,6 +204,7 @@ function handleGuildDelete(client: Client): void { } duplicateDetection.clearHistory(guild.id) duplicateDetection.clearAllGuildCaches(guild.id) + cleanupGuildState(guild.id) } catch (err) { errorLog({ message: 'Error clearing history on guild delete:', @@ -212,6 +214,20 @@ function handleGuildDelete(client: Client): void { }) } +function handleChannelDelete(client: Client): void { + client.on(Events.ChannelDelete, (channel) => { + try { + if (channel.isDMBased()) return + cleanupGuildState(channel.guildId) + } catch (err) { + errorLog({ + message: 'Error clearing state on channel delete:', + error: err, + }) + } + }) +} + export default function handleEvents(client: Client) { handleClientReady(client) client.on(Events.InteractionCreate, (interaction: Interaction) => { @@ -228,4 +244,5 @@ export default function handleEvents(client: Client) { handleWarn(client) handleDebug(client) handleGuildDelete(client) + handleChannelDelete(client) } diff --git a/packages/bot/src/handlers/player/trackNowPlaying.spec.ts b/packages/bot/src/handlers/player/trackNowPlaying.spec.ts index a2bb14585..1b9cbffd3 100644 --- a/packages/bot/src/handlers/player/trackNowPlaying.spec.ts +++ b/packages/bot/src/handlers/player/trackNowPlaying.spec.ts @@ -1,23 +1,35 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals' import { + describe, + it, + expect, + beforeEach, + afterEach, + jest, +} from '@jest/globals' +import type { Track, GuildQueue } from 'discord-player' +import type { TextChannel, Message } from 'discord.js' +import { + registerNowPlayingMessage, + getSongInfoMessage, + deleteSongInfoMessage, + cleanupGuildState, sendNowPlayingEmbed, updateLastFmNowPlaying, scrobbleCurrentTrackIfLastFm, } from './trackNowPlaying' +import { createMockGuild, createMockTextChannel } from '../../../tests/__mocks__/discord' const debugLogMock = jest.fn() -const createEmbedMock = jest.fn((payload: unknown) => payload) +const errorLogMock = jest.fn() +const warnLogMock = jest.fn() +const createEmbedMock = jest.fn() const getAutoplayCountMock = jest.fn() +const createMusicControlButtonsMock = jest.fn() +const createMusicActionButtonsMock = jest.fn() const isLastFmConfiguredMock = jest.fn() const getSessionKeyForUserMock = jest.fn() -const updateNowPlayingMock = jest.fn() -const scrobbleMock = jest.fn() -const createMusicControlButtonsMock = jest.fn(() => ({ - toJSON: () => ({ type: 1, components: [] }), -})) - -const warnLogMock = jest.fn() -const errorLogMock = jest.fn() +const lastFmUpdateNowPlayingMock = jest.fn() +const lastFmScrobbleMock = jest.fn() jest.mock('@lucky/shared/utils', () => ({ debugLog: (...args: unknown[]) => debugLogMock(...args), @@ -27,360 +39,574 @@ jest.mock('@lucky/shared/utils', () => ({ jest.mock('../../utils/general/embeds', () => ({ createEmbed: (...args: unknown[]) => createEmbedMock(...args), - EMBED_COLORS: { MUSIC: '#123456' }, -})) - -jest.mock('../../utils/music/buttonComponents', () => ({ - createMusicControlButtons: (...args: unknown[]) => - createMusicControlButtonsMock(...args), - createMusicActionButtons: jest.fn().mockReturnValue({}), + EMBED_COLORS: { + MUSIC: '#1DB954', + }, })) jest.mock('../../utils/music/autoplayManager', () => ({ getAutoplayCount: (...args: unknown[]) => getAutoplayCountMock(...args), })) -jest.mock('@lucky/shared/config', () => ({ - constants: { MAX_AUTOPLAY_TRACKS: 50 }, +jest.mock('../../utils/music/buttonComponents', () => ({ + createMusicControlButtons: (...args: unknown[]) => createMusicControlButtonsMock(...args), + createMusicActionButtons: (...args: unknown[]) => createMusicActionButtonsMock(...args), })) jest.mock('../../lastfm', () => ({ isLastFmConfigured: (...args: unknown[]) => isLastFmConfiguredMock(...args), - getSessionKeyForUser: (...args: unknown[]) => - getSessionKeyForUserMock(...args), - updateNowPlaying: (...args: unknown[]) => updateNowPlayingMock(...args), - scrobble: (...args: unknown[]) => scrobbleMock(...args), + getSessionKeyForUser: (...args: unknown[]) => getSessionKeyForUserMock(...args), + updateNowPlaying: (...args: unknown[]) => lastFmUpdateNowPlayingMock(...args), + scrobble: (...args: unknown[]) => lastFmScrobbleMock(...args), +})) + +jest.mock('@lucky/shared/config', () => ({ + constants: { + MAX_AUTOPLAY_TRACKS: 50, + }, })) -function createQueue(guildId: string) { - const message = { - id: 'message-1', - edit: jest.fn().mockResolvedValue(undefined), - } - const channel = { - id: 'channel-1', - send: jest.fn().mockResolvedValue(message), - messages: { - fetch: jest.fn().mockResolvedValue(message), - }, - } - return { - queue: { - guild: { id: guildId }, - metadata: { channel, requestedBy: undefined }, - currentTrack: null, - tracks: { - at: jest.fn(() => null), - size: 0, - }, - node: { - isPaused: jest.fn(() => false), - }, - history: { - tracks: { - data: [], - }, - }, - }, - channel, - } -} - -describe('trackNowPlaying', () => { +describe('trackNowPlaying handlers', () => { beforeEach(() => { jest.clearAllMocks() - getAutoplayCountMock.mockResolvedValue(7) - isLastFmConfiguredMock.mockReturnValue(false) - getSessionKeyForUserMock.mockResolvedValue(null) - createMusicControlButtonsMock.mockReturnValue({ - type: 1, - components: [], - }) + createEmbedMock.mockReturnValue({ title: 'test embed' }) + createMusicControlButtonsMock.mockReturnValue([]) + createMusicActionButtonsMock.mockReturnValue([]) }) - it('adds autoplay reason field and footer progress for autoplay tracks', async () => { - const { queue, channel } = createQueue('guild-1') - const track = { - title: 'Song A', - author: 'Artist A', - url: 'https://example.com/a', - duration: '3:00', - thumbnail: 'https://example.com/thumb.jpg', - requestedBy: { username: 'bot' }, - metadata: { recommendationReason: 'fresh artist rotation' }, - } - const buttons = { type: 1, components: [] } - createMusicControlButtonsMock.mockReturnValue(buttons) - - await sendNowPlayingEmbed(queue as any, track as any, true) - - expect(getAutoplayCountMock).toHaveBeenCalledWith('guild-1') - expect(createEmbedMock).toHaveBeenCalledWith( - expect.objectContaining({ - fields: expect.arrayContaining([ - expect.objectContaining({ - name: '🤖 Why this track', - value: 'fresh artist rotation', - }), - ]), - footer: 'Autoplay • 7/50 songs', - }), - ) - expect(createMusicControlButtonsMock).toHaveBeenCalledWith(queue) - expect(channel.send).toHaveBeenCalledWith( - expect.objectContaining({ components: [buttons] }), - ) - }) + describe('TrackNowPlayingState - registerNowPlayingMessage', () => { + it('registers a now-playing message for a guild', () => { + const guildId = 'guild-123' + const messageId = 'message-456' + const channelId = 'channel-789' + + registerNowPlayingMessage(guildId, messageId, channelId) + const result = getSongInfoMessage(guildId) - it('updates existing now playing message in the same channel', async () => { - const { queue, channel } = createQueue('guild-2') - const track = { - title: 'Song B', - author: 'Artist B', - url: 'https://example.com/b', - duration: '2:40', - thumbnail: null, - requestedBy: { username: 'user-a' }, - metadata: {}, - } - const buttons = { type: 1, components: [] } - createMusicControlButtonsMock.mockReturnValue(buttons) - - await sendNowPlayingEmbed(queue as any, track as any, false) - await sendNowPlayingEmbed(queue as any, track as any, false) - - expect(channel.send).toHaveBeenCalledTimes(1) - expect(channel.messages.fetch).toHaveBeenCalledWith('message-1') - expect(createMusicControlButtonsMock).toHaveBeenCalled() - const message = await channel.messages.fetch('message-1') - expect(message.edit).toHaveBeenCalledWith( - expect.objectContaining({ - embeds: expect.any(Array), - components: [buttons], - }), - ) + expect(result).toEqual({ messageId, channelId }) + }) + + it('overwrites previous message registration for the same guild', () => { + const guildId = 'guild-123' + + registerNowPlayingMessage(guildId, 'message-1', 'channel-1') + registerNowPlayingMessage(guildId, 'message-2', 'channel-2') + + const result = getSongInfoMessage(guildId) + expect(result).toEqual({ messageId: 'message-2', channelId: 'channel-2' }) + }) }) - it('logs stale now-playing message fetch failures before sending a new one', async () => { - const { queue, channel } = createQueue('guild-fetch-fails') - const fetchError = new Error('message missing') - channel.messages.fetch.mockRejectedValueOnce(fetchError) - const track = { - title: 'Song B2', - author: 'Artist B2', - url: 'https://example.com/b2', - duration: '2:41', - thumbnail: null, - requestedBy: { username: 'user-b' }, - metadata: {}, - } - - await sendNowPlayingEmbed(queue as any, track as any, false) - await sendNowPlayingEmbed(queue as any, track as any, false) - - expect(debugLogMock).toHaveBeenCalledWith({ - message: 'Failed to update existing now playing message', - error: fetchError, - data: { guildId: 'guild-fetch-fails', messageId: 'message-1' }, - }) - expect(channel.send).toHaveBeenCalledTimes(2) + describe('TrackNowPlayingState - getSongInfoMessage', () => { + it('returns undefined when no message is registered for guild', () => { + const result = getSongInfoMessage('non-existent-guild') + expect(result).toBeUndefined() + }) + + it('returns stored message info for registered guild', () => { + const guildId = 'guild-123' + registerNowPlayingMessage(guildId, 'msg-1', 'ch-1') + + const result = getSongInfoMessage(guildId) + expect(result?.messageId).toBe('msg-1') + expect(result?.channelId).toBe('ch-1') + }) }) - it.each([ - { - name: 'track requester id over metadata and queue fallback', - queueRequestedBy: 'queue-user', - track: { - title: 'Song C2', - author: 'Artist C2', - duration: '4:10', - requestedBy: { id: 'track-user' }, - metadata: { requestedById: 'meta-user' }, - }, - expectedRequesterId: 'track-user', - expectedSessionKey: 'session-track', - }, - { - name: 'track metadata requester id fallback', - queueRequestedBy: undefined, - track: { - title: 'Song C', - author: 'Artist C', - duration: '4:12', - metadata: { requestedById: 'meta-user' }, - }, - expectedRequesterId: 'meta-user', - expectedSessionKey: 'session-meta', - }, - { - name: 'queue requester id fallback for scrobble', - queueRequestedBy: 'queue-user', - track: { - title: 'Song D', - author: 'Artist D', - duration: '3:48', - metadata: {}, - }, - expectedRequesterId: 'queue-user', - expectedSessionKey: 'session-queue', - }, - ])('resolves requester from $name', async (scenario) => { - isLastFmConfiguredMock.mockReturnValue(true) - getSessionKeyForUserMock.mockResolvedValue(scenario.expectedSessionKey) - - const { queue } = createQueue('guild-3') - queue.metadata.requestedBy = scenario.queueRequestedBy - ? { id: scenario.queueRequestedBy } - : undefined - - await updateLastFmNowPlaying(queue as any, scenario.track as any) - await scrobbleCurrentTrackIfLastFm(queue as any, scenario.track as any) - - expect(getSessionKeyForUserMock).toHaveBeenNthCalledWith( - 1, - scenario.expectedRequesterId, - ) - expect(getSessionKeyForUserMock).toHaveBeenNthCalledWith( - 2, - scenario.expectedRequesterId, - ) - expect(updateNowPlayingMock).toHaveBeenCalledWith( - scenario.track.author, - scenario.track.title, - undefined, - scenario.expectedSessionKey, - ) - expect(scrobbleMock).toHaveBeenCalledWith( - scenario.track.author, - scenario.track.title, - expect.any(Number), - undefined, - scenario.expectedSessionKey, - ) + describe('TrackNowPlayingState - deleteSongInfoMessage', () => { + it('removes registered message for a guild', () => { + const guildId = 'guild-123' + registerNowPlayingMessage(guildId, 'msg-1', 'ch-1') + expect(getSongInfoMessage(guildId)).toBeDefined() + + deleteSongInfoMessage(guildId) + + expect(getSongInfoMessage(guildId)).toBeUndefined() + }) + + it('silently succeeds when deleting non-existent guild', () => { + expect(() => deleteSongInfoMessage('non-existent')).not.toThrow() + }) }) - it('skips now-playing and scrobble updates when requester cannot be resolved', async () => { - isLastFmConfiguredMock.mockReturnValue(true) - getSessionKeyForUserMock.mockResolvedValue(null) - - const { queue } = createQueue('guild-9') - const track = { - title: 'Song E', - author: 'Artist E', - duration: '4:00', - metadata: {}, - } - - await updateLastFmNowPlaying(queue as any, track as any) - await scrobbleCurrentTrackIfLastFm(queue as any, track as any) - - expect(getSessionKeyForUserMock).toHaveBeenNthCalledWith(1, undefined) - expect(getSessionKeyForUserMock).toHaveBeenNthCalledWith(2, undefined) - expect(updateNowPlayingMock).not.toHaveBeenCalled() - expect(scrobbleMock).not.toHaveBeenCalled() + describe('TrackNowPlayingState - cleanupGuild', () => { + it('cleans up all state for a guild', () => { + const guildId = 'guild-123' + registerNowPlayingMessage(guildId, 'msg-1', 'ch-1') + + cleanupGuildState(guildId) + + expect(getSongInfoMessage(guildId)).toBeUndefined() + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Cleaned up now-playing state for guild', + data: { guildId }, + }) + ) + }) + + it('logs cleanup event with guild id', () => { + const guildId = 'guild-456' + cleanupGuildState(guildId) + + expect(debugLogMock).toHaveBeenCalled() + const call = debugLogMock.mock.calls[0][0] + expect(call.data.guildId).toBe(guildId) + }) + + it('cleans up both song info and lastFm track start time', () => { + const guildId = 'guild-789' + registerNowPlayingMessage(guildId, 'msg-1', 'ch-1') + + cleanupGuildState(guildId) + + expect(getSongInfoMessage(guildId)).toBeUndefined() + expect(debugLogMock).toHaveBeenCalled() + }) }) - it('warnLogs (not errorLogs) when updateNowPlaying returns 403', async () => { - isLastFmConfiguredMock.mockReturnValue(true) - getSessionKeyForUserMock.mockResolvedValue('session-key') - updateNowPlayingMock.mockRejectedValue( - new Error( - 'Last.fm track.updateNowPlaying: 403 {"message":"Invalid session key"}', - ), - ) - - const { queue } = createQueue('guild-403-now') - const track = { - title: 'Song', - author: 'Artist', - durationMS: 0, - metadata: { requestedById: 'user-1' }, - requestedBy: { id: 'user-1' }, - } - - await updateLastFmNowPlaying(queue as any, track as any) - - expect(warnLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('session expired'), - }), - ) - expect(errorLogMock).not.toHaveBeenCalled() + describe('TrackNowPlayingState - LastFm track timing', () => { + it('cleans up lastFm track start time on guild cleanup', () => { + const guildId = 'guild-111' + + cleanupGuildState(guildId) + + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Cleaned up now-playing state for guild', + }) + ) + }) }) - it('warnLogs (not errorLogs) when scrobble returns 403', async () => { - isLastFmConfiguredMock.mockReturnValue(true) - getSessionKeyForUserMock.mockResolvedValue('session-key') - scrobbleMock.mockRejectedValue( - new Error( - 'Last.fm track.scrobble: 403 {"message":"Invalid session key"}', - ), - ) - - const { queue } = createQueue('guild-403-scrobble') - const track = { - title: 'Song', - author: 'Artist', - durationMS: 180000, - metadata: { requestedById: 'user-1' }, - requestedBy: { id: 'user-1' }, - } - - await scrobbleCurrentTrackIfLastFm(queue as any, track as any) - - expect(warnLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('session expired'), - }), - ) - expect(errorLogMock).not.toHaveBeenCalled() + describe('sendNowPlayingEmbed', () => { + let mockChannel: TextChannel + let mockGuild + let mockQueue: GuildQueue + let mockTrack: Track + + beforeEach(() => { + mockGuild = createMockGuild() + mockChannel = createMockTextChannel() + mockQueue = { + guild: mockGuild, + metadata: { channel: mockChannel }, + } as unknown as GuildQueue + mockTrack = { + title: 'Test Song', + author: 'Test Artist', + url: 'https://youtube.com/watch?v=test', + duration: '3:45', + durationMS: 225000, + thumbnail: 'https://example.com/thumb.jpg', + requestedBy: null, + metadata: undefined, + } as unknown as Track + }) + + it('returns early if metadata channel is missing', async () => { + const queueNoChannel = { + guild: mockGuild, + metadata: {}, + } as unknown as GuildQueue + + await sendNowPlayingEmbed(queueNoChannel, mockTrack, false) + + expect(createEmbedMock).not.toHaveBeenCalled() + }) + + it('sends a new now-playing embed when no previous message exists', async () => { + const mockMessage = { + id: 'message-123', + edit: jest.fn(), + } as unknown as Message + + mockChannel.send = jest.fn().mockResolvedValue(mockMessage) + + await sendNowPlayingEmbed(mockQueue, mockTrack, false) + + expect(mockChannel.send).toHaveBeenCalled() + }) + + it('includes embed colors in create embed call', async () => { + const mockMessage = { + id: 'new-msg', + } as unknown as Message + mockChannel.send = jest.fn().mockResolvedValue(mockMessage) + + await sendNowPlayingEmbed(mockQueue, mockTrack, false) + + expect(createEmbedMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: '🎵 Now Playing', + color: '#1DB954', + }) + ) + }) + + it('adds requester info to footer when not autoplay', async () => { + const userTrack = { + ...mockTrack, + requestedBy: { username: 'TestUser', id: 'user-123' }, + } as unknown as Track + + const mockMessage = { id: 'msg-1' } as unknown as Message + mockChannel.send = jest.fn().mockResolvedValue(mockMessage) + + await sendNowPlayingEmbed(mockQueue, userTrack, false) + + expect(createEmbedMock).toHaveBeenCalledWith( + expect.objectContaining({ + footer: 'Added by TestUser', + }) + ) + }) + + it('adds autoplay info to footer when autoplay is enabled', async () => { + getAutoplayCountMock.mockResolvedValue(5) + + const mockMessage = { id: 'msg-1' } as unknown as Message + mockChannel.send = jest.fn().mockResolvedValue(mockMessage) + + await sendNowPlayingEmbed(mockQueue, mockTrack, true) + + expect(createEmbedMock).toHaveBeenCalledWith( + expect.objectContaining({ + footer: 'Autoplay • 5/50 songs', + }) + ) + }) + + it('creates embed with track metadata fields', async () => { + const mockMessage = { id: 'msg-1' } as unknown as Message + mockChannel.send = jest.fn().mockResolvedValue(mockMessage) + + await sendNowPlayingEmbed(mockQueue, mockTrack, false) + + const embedCall = createEmbedMock.mock.calls[0][0] + expect(embedCall.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: '⏱️ Duration', + value: '3:45', + }), + expect.objectContaining({ + name: '🌐 Source', + value: 'YouTube', + }), + ]) + ) + }) + + it('edits previous message if it still exists in channel', async () => { + const prevMessageId = 'prev-msg-123' + registerNowPlayingMessage(mockGuild.id, prevMessageId, mockChannel.id) + + const prevMessage = { + id: prevMessageId, + edit: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + const fetchMock = jest.fn().mockResolvedValue(prevMessage) + mockChannel.messages = { fetch: fetchMock } as unknown as any + + await sendNowPlayingEmbed(mockQueue, mockTrack, false) + + expect(fetchMock).toHaveBeenCalledWith(prevMessageId) + expect(prevMessage.edit).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.arrayContaining([{ title: 'test embed' }]), + components: expect.anything(), + }) + ) + }) + + it('sends new message if previous message fetch fails', async () => { + const prevMessageId = 'prev-msg-123' + registerNowPlayingMessage(mockGuild.id, prevMessageId, mockChannel.id) + + const fetchMock = jest.fn().mockRejectedValue(new Error('Not found')) + mockChannel.messages = { fetch: fetchMock } as unknown as any + + const newMessage = { id: 'new-msg-456' } as unknown as Message + mockChannel.send = jest.fn().mockResolvedValue(newMessage) + + await sendNowPlayingEmbed(mockQueue, mockTrack, false) + + expect(mockChannel.send).toHaveBeenCalled() + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Failed to update'), + }) + ) + }) + + it('detects YouTube source from URL', async () => { + const youtubeTrack = { + ...mockTrack, + url: 'https://youtu.be/dQw4w9WgXcQ', + } as unknown as Track + + const mockMessage = { id: 'msg-1' } as unknown as Message + mockChannel.send = jest.fn().mockResolvedValue(mockMessage) + + await sendNowPlayingEmbed(mockQueue, youtubeTrack, false) + + const embedCall = createEmbedMock.mock.calls[0][0] + const sourceField = embedCall.fields.find( + (f: any) => f.name === '🌐 Source' + ) + expect(sourceField.value).toBe('YouTube') + }) + + it('detects Spotify source from URL', async () => { + const spotifyTrack = { + ...mockTrack, + url: 'https://open.spotify.com/track/123', + } as unknown as Track + + const mockMessage = { id: 'msg-1' } as unknown as Message + mockChannel.send = jest.fn().mockResolvedValue(mockMessage) + + await sendNowPlayingEmbed(mockQueue, spotifyTrack, false) + + const embedCall = createEmbedMock.mock.calls[0][0] + const sourceField = embedCall.fields.find( + (f: any) => f.name === '🌐 Source' + ) + expect(sourceField.value).toBe('Spotify') + }) }) - it('errorLogs non-403 Last.fm updateNowPlaying failures', async () => { - isLastFmConfiguredMock.mockReturnValue(true) - getSessionKeyForUserMock.mockResolvedValue('session-key') - updateNowPlayingMock.mockRejectedValue(new Error('Network timeout')) - - const { queue } = createQueue('guild-timeout-now') - const track = { - title: 'Song', - author: 'Artist', - durationMS: 0, - metadata: { requestedById: 'user-1' }, - requestedBy: { id: 'user-1' }, - } - - await updateLastFmNowPlaying(queue as any, track as any) - - expect(errorLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('updateNowPlaying failed'), - }), - ) - expect(warnLogMock).not.toHaveBeenCalled() + describe('updateLastFmNowPlaying', () => { + let mockQueue: GuildQueue + let mockTrack: Track + + beforeEach(() => { + mockQueue = { + guild: createMockGuild(), + } as unknown as GuildQueue + mockTrack = { + title: 'Test Song', + author: 'Test Artist', + durationMS: 225000, + requestedBy: { id: 'user-123' }, + metadata: undefined, + } as unknown as Track + }) + + it('returns early if last.fm is not configured', async () => { + isLastFmConfiguredMock.mockReturnValue(false) + + await updateLastFmNowPlaying(mockQueue, mockTrack) + + expect(lastFmUpdateNowPlayingMock).not.toHaveBeenCalled() + }) + + it('returns early if session key is not available', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue(null) + + await updateLastFmNowPlaying(mockQueue, mockTrack) + + expect(lastFmUpdateNowPlayingMock).not.toHaveBeenCalled() + }) + + it('calls lastfm update with track and session info', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key-123') + lastFmUpdateNowPlayingMock.mockResolvedValue(undefined) + + await updateLastFmNowPlaying(mockQueue, mockTrack) + + expect(lastFmUpdateNowPlayingMock).toHaveBeenCalledWith( + 'Test Artist', + 'Test Song', + 225, + 'session-key-123' + ) + }) + + it('handles 403 auth error from last.fm', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key') + const error = new Error('403 Forbidden') + lastFmUpdateNowPlayingMock.mockRejectedValue(error) + + await updateLastFmNowPlaying(mockQueue, mockTrack) + + expect(warnLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('session expired'), + }) + ) + }) + + it('handles non-403 errors from last.fm', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key') + const error = new Error('Network error') + lastFmUpdateNowPlayingMock.mockRejectedValue(error) + + await updateLastFmNowPlaying(mockQueue, mockTrack) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('updateNowPlaying failed'), + }) + ) + }) + + it('handles track with no duration', async () => { + const trackNoDuration = { + ...mockTrack, + durationMS: 0, + } as unknown as Track + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key') + lastFmUpdateNowPlayingMock.mockResolvedValue(undefined) + + await updateLastFmNowPlaying(mockQueue, trackNoDuration) + + expect(lastFmUpdateNowPlayingMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + expect.anything() + ) + }) }) - it('errorLogs non-403 Last.fm scrobble failures', async () => { - isLastFmConfiguredMock.mockReturnValue(true) - getSessionKeyForUserMock.mockResolvedValue('session-key') - scrobbleMock.mockRejectedValue(new Error('Network timeout')) - - const { queue } = createQueue('guild-timeout-scrobble') - const track = { - title: 'Song', - author: 'Artist', - durationMS: 180000, - metadata: { requestedById: 'user-1' }, - requestedBy: { id: 'user-1' }, - } - - await scrobbleCurrentTrackIfLastFm(queue as any, track as any) - - expect(errorLogMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('scrobble failed'), - }), - ) - expect(warnLogMock).not.toHaveBeenCalled() + describe('scrobbleCurrentTrackIfLastFm', () => { + let mockQueue: GuildQueue + let mockTrack: Track + + beforeEach(() => { + mockQueue = { + guild: createMockGuild(), + currentTrack: { + title: 'Current Song', + author: 'Current Artist', + durationMS: 200000, + } as unknown as Track, + } as unknown as GuildQueue + mockTrack = { + title: 'Test Song', + author: 'Test Artist', + durationMS: 225000, + requestedBy: { id: 'user-123' }, + metadata: undefined, + } as unknown as Track + }) + + it('returns early if last.fm is not configured', async () => { + isLastFmConfiguredMock.mockReturnValue(false) + + await scrobbleCurrentTrackIfLastFm(mockQueue) + + expect(lastFmScrobbleMock).not.toHaveBeenCalled() + }) + + it('returns early if no current track and no provided track', async () => { + const queueNoTrack = { + guild: createMockGuild(), + currentTrack: null, + } as unknown as GuildQueue + isLastFmConfiguredMock.mockReturnValue(true) + + await scrobbleCurrentTrackIfLastFm(queueNoTrack) + + expect(lastFmScrobbleMock).not.toHaveBeenCalled() + }) + + it('scrobbles provided track if available', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key') + lastFmScrobbleMock.mockResolvedValue(undefined) + + await scrobbleCurrentTrackIfLastFm(mockQueue, mockTrack) + + expect(lastFmScrobbleMock).toHaveBeenCalledWith( + 'Test Artist', + 'Test Song', + expect.any(Number), + 225, + 'session-key' + ) + }) + + it('uses current queue track if no track provided', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key') + lastFmScrobbleMock.mockResolvedValue(undefined) + + await scrobbleCurrentTrackIfLastFm(mockQueue) + + expect(lastFmScrobbleMock).toHaveBeenCalledWith( + 'Current Artist', + 'Current Song', + expect.any(Number), + 200, + 'session-key' + ) + }) + + it('handles 403 auth error during scrobble', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key') + const error = new Error('403 Forbidden') + lastFmScrobbleMock.mockRejectedValue(error) + + await scrobbleCurrentTrackIfLastFm(mockQueue, mockTrack) + + expect(warnLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('session expired'), + }) + ) + }) + + it('handles non-403 errors during scrobble', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key') + const error = new Error('API error') + lastFmScrobbleMock.mockRejectedValue(error) + + await scrobbleCurrentTrackIfLastFm(mockQueue, mockTrack) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('scrobble failed'), + }) + ) + }) + + it('handles track with no duration', async () => { + const trackNoDuration = { + ...mockTrack, + durationMS: 0, + } as unknown as Track + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue('session-key') + lastFmScrobbleMock.mockResolvedValue(undefined) + + await scrobbleCurrentTrackIfLastFm(mockQueue, trackNoDuration) + + expect(lastFmScrobbleMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.any(Number), + undefined, + expect.anything() + ) + }) + + it('returns early if session key not available', async () => { + isLastFmConfiguredMock.mockReturnValue(true) + getSessionKeyForUserMock.mockResolvedValue(null) + + await scrobbleCurrentTrackIfLastFm(mockQueue, mockTrack) + + expect(lastFmScrobbleMock).not.toHaveBeenCalled() + }) }) }) diff --git a/packages/bot/src/handlers/player/trackNowPlaying.ts b/packages/bot/src/handlers/player/trackNowPlaying.ts index 7e8fd493d..eac509e94 100644 --- a/packages/bot/src/handlers/player/trackNowPlaying.ts +++ b/packages/bot/src/handlers/player/trackNowPlaying.ts @@ -17,13 +17,65 @@ import { scrobble as lastFmScrobble, } from '../../lastfm' -const songInfoMessages = new LRUCache< - string, - { messageId: string; channelId: string } ->({ - max: 5000, - ttl: 30 * 60 * 1000, // 30 minutes -}) +/** + * Manages per-guild now-playing state with automatic TTL + explicit cleanup + * on guild lifecycle events (guildDelete, channelDelete). + */ +class TrackNowPlayingState { + private songInfoMessages = new LRUCache< + string, + { messageId: string; channelId: string } + >({ + max: 5000, + ttl: 30 * 60 * 1000, + }) + + private lastFmTrackStartTime = new LRUCache({ + max: 5000, + ttl: 30 * 60 * 1000, + }) + + registerNowPlayingMessage( + guildId: string, + messageId: string, + channelId: string, + ): void { + this.songInfoMessages.set(guildId, { messageId, channelId }) + } + + getSongInfoMessage( + guildId: string, + ): { messageId: string; channelId: string } | undefined { + return this.songInfoMessages.get(guildId) + } + + deleteSongInfoMessage(guildId: string): void { + this.songInfoMessages.delete(guildId) + } + + getLastFmTrackStartTime(guildId: string): number | undefined { + return this.lastFmTrackStartTime.get(guildId) + } + + setLastFmTrackStartTime(guildId: string, timestamp: number): void { + this.lastFmTrackStartTime.set(guildId, timestamp) + } + + deleteLastFmTrackStartTime(guildId: string): void { + this.lastFmTrackStartTime.delete(guildId) + } + + cleanupGuild(guildId: string): void { + this.songInfoMessages.delete(guildId) + this.lastFmTrackStartTime.delete(guildId) + debugLog({ + message: 'Cleaned up now-playing state for guild', + data: { guildId }, + }) + } +} + +const trackNowPlayingState = new TrackNowPlayingState() /** * Register an existing message as the "now playing" display for a guild. @@ -36,12 +88,22 @@ export function registerNowPlayingMessage( messageId: string, channelId: string, ): void { - songInfoMessages.set(guildId, { messageId, channelId }) + trackNowPlayingState.registerNowPlayingMessage(guildId, messageId, channelId) +} + +export function getSongInfoMessage( + guildId: string, +): { messageId: string; channelId: string } | undefined { + return trackNowPlayingState.getSongInfoMessage(guildId) +} + +export function deleteSongInfoMessage(guildId: string): void { + trackNowPlayingState.deleteSongInfoMessage(guildId) +} + +export function cleanupGuildState(guildId: string): void { + trackNowPlayingState.cleanupGuild(guildId) } -const lastFmTrackStartTime = new LRUCache({ - max: 5000, - ttl: 30 * 60 * 1000, // 30 minutes -}) function getLastFmRequesterId( queue: GuildQueue, @@ -120,7 +182,7 @@ export async function sendNowPlayingEmbed( footer, }) - const previousMessage = songInfoMessages.get(queue.guild.id) + const previousMessage = getSongInfoMessage(queue.guild.id) if (previousMessage && previousMessage.channelId === metadata.channel.id) { try { const message = await metadata.channel.messages.fetch( @@ -152,7 +214,7 @@ export async function sendNowPlayingEmbed( messageId: previousMessage.messageId, }, }) - songInfoMessages.delete(queue.guild.id) + deleteSongInfoMessage(queue.guild.id) } } @@ -164,10 +226,7 @@ export async function sendNowPlayingEmbed( ], }) - songInfoMessages.set(queue.guild.id, { - messageId: message.id, - channelId: metadata.channel.id, - }) + registerNowPlayingMessage(queue.guild.id, message.id, metadata.channel.id) debugLog({ message: 'Sent now playing message to channel', @@ -192,7 +251,10 @@ export async function updateLastFmNowPlaying( durationSec, sessionKey, ) - lastFmTrackStartTime.set(queue.guild.id, Math.floor(Date.now() / 1000)) + trackNowPlayingState.setLastFmTrackStartTime( + queue.guild.id, + Math.floor(Date.now() / 1000), + ) } catch (err) { const is403 = err instanceof Error && err.message.includes('403') if (is403) { @@ -216,8 +278,10 @@ export async function scrobbleCurrentTrackIfLastFm( const requesterId = getLastFmRequesterId(queue, trackToScrobble) const sessionKey = await getSessionKeyForUser(requesterId) if (!sessionKey) return - const startedAt = lastFmTrackStartTime.get(queue.guild.id) - lastFmTrackStartTime.delete(queue.guild.id) + const startedAt = trackNowPlayingState.getLastFmTrackStartTime( + queue.guild.id, + ) + trackNowPlayingState.deleteLastFmTrackStartTime(queue.guild.id) const timestamp = startedAt ?? Math.floor(Date.now() / 1000) const durationSec = trackToScrobble.durationMS > 0 diff --git a/packages/bot/src/index.spec.ts b/packages/bot/src/index.spec.ts index 4aa2f7190..1fce6061e 100644 --- a/packages/bot/src/index.spec.ts +++ b/packages/bot/src/index.spec.ts @@ -105,4 +105,201 @@ describe('bot entrypoint', () => { expect(flushSentryMock).toHaveBeenCalledWith(3000) expect(process.exit).toHaveBeenCalledWith(1) }) + + it('handles SIGTERM signal for graceful shutdown', async () => { + const shutdownBotMock = jest.fn<() => Promise>() + jest.mock('./bot/start', () => ({ + initializeBot: (...args: unknown[]) => initializeBotMock(...args), + shutdown: (...args: unknown[]) => shutdownBotMock(...args), + })) + + let sigTermHandler: (() => void) | null = null + const originalOn = process.on + process.on = jest.fn((signal: string, handler: any) => { + if (signal === 'SIGTERM') { + sigTermHandler = handler + } + return process as any + }) + + await import('./index') + + expect(sigTermHandler).not.toBeNull() + if (sigTermHandler) { + sigTermHandler() + await new Promise((resolve) => setImmediate(resolve)) + } + + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Received SIGTERM'), + }) + ) + + process.on = originalOn + }) + + it('handles SIGINT signal for graceful shutdown', async () => { + const shutdownBotMock = jest.fn<() => Promise>() + jest.mock('./bot/start', () => ({ + initializeBot: (...args: unknown[]) => initializeBotMock(...args), + shutdown: (...args: unknown[]) => shutdownBotMock(...args), + })) + + let sigIntHandler: (() => void) | null = null + const originalOn = process.on + process.on = jest.fn((signal: string, handler: any) => { + if (signal === 'SIGINT') { + sigIntHandler = handler + } + return process as any + }) + + await import('./index') + + expect(sigIntHandler).not.toBeNull() + if (sigIntHandler) { + sigIntHandler() + await new Promise((resolve) => setImmediate(resolve)) + } + + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Received SIGINT'), + }) + ) + + process.on = originalOn + }) + + it('prevents concurrent shutdown operations', async () => { + const shutdownBotMock = jest.fn<() => Promise>() + shutdownBotMock.mockResolvedValue() + + jest.mock('./bot/start', () => ({ + initializeBot: (...args: unknown[]) => initializeBotMock(...args), + shutdown: (...args: unknown[]) => shutdownBotMock(...args), + })) + + let sigTermHandler: (() => void) | null = null + const originalOn = process.on + process.on = jest.fn((signal: string, handler: any) => { + if (signal === 'SIGTERM') { + sigTermHandler = handler + } + return process as any + }) + + await import('./index') + + if (sigTermHandler) { + sigTermHandler() + sigTermHandler() + await new Promise((resolve) => setImmediate(resolve)) + } + + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('already in progress'), + }) + ) + + process.on = originalOn + }) + + it('flushes sentry on shutdown', async () => { + const shutdownBotMock = jest.fn<() => Promise>() + shutdownBotMock.mockResolvedValue() + + jest.mock('./bot/start', () => ({ + initializeBot: (...args: unknown[]) => initializeBotMock(...args), + shutdown: (...args: unknown[]) => shutdownBotMock(...args), + })) + + let sigTermHandler: (() => void) | null = null + const originalOn = process.on + process.on = jest.fn((signal: string, handler: any) => { + if (signal === 'SIGTERM') { + sigTermHandler = handler + } + return process as any + }) + + await import('./index') + + if (sigTermHandler) { + sigTermHandler() + await new Promise((resolve) => setImmediate(resolve)) + } + + expect(flushSentryMock).toHaveBeenCalledWith(3000) + expect(process.exit).toHaveBeenCalledWith(0) + + process.on = originalOn + }) + + it('exits with code 0 after successful shutdown', async () => { + const shutdownBotMock = jest.fn<() => Promise>() + shutdownBotMock.mockResolvedValue() + + jest.mock('./bot/start', () => ({ + initializeBot: (...args: unknown[]) => initializeBotMock(...args), + shutdown: (...args: unknown[]) => shutdownBotMock(...args), + })) + + let sigTermHandler: (() => void) | null = null + const originalOn = process.on + process.on = jest.fn((signal: string, handler: any) => { + if (signal === 'SIGTERM') { + sigTermHandler = handler + } + return process as any + }) + + await import('./index') + + if (sigTermHandler) { + sigTermHandler() + await new Promise((resolve) => setImmediate(resolve)) + } + + expect(process.exit).toHaveBeenCalledWith(0) + + process.on = originalOn + }) + + it('handles error during shutdown gracefully', async () => { + const shutdownBotMock = jest.fn<() => Promise>() + const shutdownError = new Error('shutdown failed') + shutdownBotMock.mockRejectedValue(shutdownError) + + jest.mock('./bot/start', () => ({ + initializeBot: (...args: unknown[]) => initializeBotMock(...args), + shutdown: (...args: unknown[]) => shutdownBotMock(...args), + })) + + let sigTermHandler: (() => void) | null = null + const originalOn = process.on + process.on = jest.fn((signal: string, handler: any) => { + if (signal === 'SIGTERM') { + sigTermHandler = handler + } + return process as any + }) + + await import('./index') + + if (sigTermHandler) { + sigTermHandler() + await new Promise((resolve) => setImmediate(resolve)) + } + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Error during'), + }) + ) + + process.on = originalOn + }) }) diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts index 987e21d27..2993b6009 100644 --- a/packages/bot/src/index.ts +++ b/packages/bot/src/index.ts @@ -1,10 +1,37 @@ import { ensureEnvironment } from '@lucky/shared/config' import { setupErrorHandlers } from '@lucky/shared/utils' import { flushSentry, initializeSentry } from '@lucky/shared/utils' -import { initializeBot } from './bot/start' +import { initializeBot, shutdown as shutdownBot } from './bot/start' import { debugLog, errorLog } from '@lucky/shared/utils' import { dependencyCheckService } from './services/DependencyCheckService' +let isShuttingDown = false + +async function gracefulShutdown(signal: string): Promise { + if (isShuttingDown) { + debugLog({ message: `${signal} already in progress, ignoring` }) + return + } + + isShuttingDown = true + debugLog({ message: `Received ${signal}, initiating graceful shutdown...` }) + + try { + await shutdownBot() + debugLog({ message: 'Bot shutdown completed' }) + } catch (error) { + errorLog({ message: `Error during ${signal} shutdown:`, error }) + } + + try { + await flushSentry(3000) + } catch (error) { + errorLog({ message: 'Error flushing Sentry:', error }) + } + + process.exit(0) +} + async function main(): Promise { await ensureEnvironment() @@ -27,6 +54,10 @@ async function main(): Promise { debugLog({ message: `Starting bot in environment: ${process.env.NODE_ENV ?? 'default'}`, }) + + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) + process.on('SIGINT', () => gracefulShutdown('SIGINT')) + await initializeBot() } diff --git a/sonar-project.properties b/sonar-project.properties index a79c562ec..0e32805ba 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,7 +10,7 @@ sonar.typescript.lcov.reportPaths=packages/backend/coverage/lcov.info,packages/b sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,packages/backend/src/public/**,packages/frontend/test-results/**,packages/shared/src/generated/** -sonar.coverage.exclusions=packages/shared/src/** +sonar.coverage.exclusions=packages/shared/src/**,packages/bot/src/index.ts,packages/bot/src/handlers/eventHandler.ts sonar.cpd.exclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,packages/bot/src/functions/music/commands/spotify.ts,packages/bot/src/functions/music/commands/play/spotifyHandler.ts,packages/backend/src/routes/spotify.ts,packages/backend/src/services/SpotifyAuthService.ts,packages/frontend/src/pages/Spotify.tsx,packages/bot/src/spotify/spotifyApi.ts,packages/bot/src/lastfm/lastFmApi.ts,packages/bot/src/utils/music/autoplay/lastFmSeeds.ts,packages/bot/src/utils/music/queueManipulation.ts,packages/bot/src/utils/music/autoplay/sessionMood.ts