diff --git a/packages/bot/src/spotify/spotifyUserSeeds.ts b/packages/bot/src/spotify/spotifyUserSeeds.ts index 8ad65e9e7..ee46659a8 100644 --- a/packages/bot/src/spotify/spotifyUserSeeds.ts +++ b/packages/bot/src/spotify/spotifyUserSeeds.ts @@ -22,7 +22,9 @@ const userSeedsCache = new LRUCache({ ttl: CACHE_TTL_MS, }) -export async function getUserSpotifySeeds(userId: string): Promise { +export async function getUserSpotifySeeds( + userId: string, +): Promise { const now = Date.now() const cached = userSeedsCache.get(userId) @@ -58,11 +60,15 @@ export async function getUserSpotifySeeds(userId: string): Promise [] as string[]) + const likedTrackIds = await getUserSavedTracks(token).catch( + () => [] as string[], + ) const seeds: UserSpotifySeeds = { artistIds: seedData.artists.map((a) => a.id), - artistNames: new Set(seedData.artists.map((a) => a.name.toLowerCase())), + artistNames: new Set( + seedData.artists.map((a) => a.name.toLowerCase()), + ), trackIds: seedData.tracks.map((t) => t.id), likedTrackIds, } @@ -100,3 +106,7 @@ export function clearUserSeedsCache(userId: string): void { data: { userId }, }) } + +export function clearAllSeedsCache(): void { + userSeedsCache.clear() +} diff --git a/packages/bot/tests/setup.ts b/packages/bot/tests/setup.ts index 2164ef173..31ea6e879 100644 --- a/packages/bot/tests/setup.ts +++ b/packages/bot/tests/setup.ts @@ -1,4 +1,4 @@ -import { jest } from '@jest/globals' +import { jest, afterAll } from '@jest/globals' // Neutralize the @snazzah/davey native addon (Discord DAVE E2E encryption). // It is pulled in transitively (discord-player → discord-voip → @snazzah/davey) @@ -9,3 +9,124 @@ import { jest } from '@jest/globals' // wraps the require in try/catch (DAVE is optional) and bot specs never exercise // it, so an empty module is safe. jest.mock('@snazzah/davey', () => ({})) + +// Patch setTimeout and setInterval to unref timers. LRUCache creates internal +// setTimeout handlers for TTL expiry, and these keep Node.js event loop alive. +// By unreferencing them, we make jest exit gracefully without waiting for +// cache TTL timers (issue #1553). +const originalSetTimeout = global.setTimeout +const originalSetInterval = global.setInterval +global.setTimeout = function ( + callback: TimerHandler, + delay?: number, + ...args: unknown[] +) { + const timeoutId = originalSetTimeout.call( + this, + callback, + delay, + ...args, + ) as NodeJS.Timeout + try { + timeoutId.unref?.() + } catch { + // Ignore; worst case timer stays ref'd + } + return timeoutId +} as typeof setTimeout + +global.setInterval = function ( + callback: TimerHandler, + delay?: number, + ...args: unknown[] +) { + const intervalId = originalSetInterval.call( + this, + callback, + delay, + ...args, + ) as NodeJS.Timeout + try { + intervalId.unref?.() + } catch { + // Ignore; worst case timer stays ref'd + } + return intervalId +} as typeof setInterval + +// Register a beforeExit handler to clear all LRUCache TTL timers before process +// exits. This is necessary because the timers are created at module load time +// and kept alive throughout the process, keeping jest workers from exiting +// gracefully (issue #1553). +process.on('beforeExit', clearAllCaches) + +async function clearAllCaches() { + try { + const { clearAllSeedsCache } = + await import('../src/spotify/spotifyUserSeeds') + clearAllSeedsCache() + } catch { + // May not be loaded + } + + try { + const { + recentlyPlayedTracks, + trackIdSet, + lastPlayedTracks, + artistGenreMap, + } = await import('../src/utils/music/duplicateDetection/types') + recentlyPlayedTracks.clear() + trackIdSet.clear() + lastPlayedTracks.clear() + artistGenreMap.clear() + } catch { + // May not be loaded + } + + try { + const { clearLastFmCaches } = await import('../src/lastfm/lastFmApi') + clearLastFmCaches() + } catch { + // May not be loaded + } + + try { + const { clearSpotifyApiCaches } = + await import('../src/spotify/spotifyApi') + clearSpotifyApiCaches() + } catch { + // May not be loaded + } + + try { + const { clearReplenishSuppressionCache } = + await import('../src/utils/music/replenishSuppressionStore') + clearReplenishSuppressionCache() + } catch { + // May not be loaded + } + + try { + const { clearAudioFeaturesCache } = + await import('../src/utils/music/autoplay/audioFeatures') + clearAudioFeaturesCache() + } catch { + // May not be loaded + } + + try { + const { + lastPlayedTracks, + recentlyPlayedTracks, + trackStartTimes, + guildRecentSkipCounts, + } = await import('../src/handlers/player/trackHandlers') + lastPlayedTracks.clear() + recentlyPlayedTracks.clear() + trackStartTimes.clear() + guildRecentSkipCounts.clear() + } catch { + // May not be loaded + } +}