From 422f1769905338aa5218d298281628e2d683c6e6 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 19:53:23 -0500 Subject: [PATCH 01/24] feat(redis): add centralized Redis client and cache utilities Phase 1 of #177: Core infrastructure - src/redis.js: Centralized Redis client with graceful degradation, reconnect strategy, connection stats, and hit/miss tracking - src/utils/cache.js: High-level cache helpers (get/set/del/pattern/getOrSet) with Redis backend and in-memory LRU fallback - Configurable TTLs via environment variables - Non-blocking SCAN for pattern deletes --- src/redis.js | 186 ++++++++++++++++++++++++++++++ src/utils/cache.js | 276 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 src/redis.js create mode 100644 src/utils/cache.js diff --git a/src/redis.js b/src/redis.js new file mode 100644 index 00000000..4ea15e0f --- /dev/null +++ b/src/redis.js @@ -0,0 +1,186 @@ +/** + * Redis Client Module + * Centralized Redis connection for caching, sessions, and distributed features. + * + * Replaces the API-specific redisClient.js with a shared instance used by + * the entire application. Gracefully degrades when REDIS_URL is not set. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/177 + */ + +import Redis from 'ioredis'; +import { debug, error as logError, info, warn } from './logger.js'; + +/** @type {import('ioredis').Redis | null} */ +let client = null; + +/** @type {boolean} */ +let initialized = false; + +/** @type {boolean} */ +let connected = false; + +/** Cache hit/miss counters for observability */ +const stats = { + hits: 0, + misses: 0, + errors: 0, + /** @type {number|null} */ + connectedAt: null, +}; + +/** + * Initialize the Redis client. + * Returns null if REDIS_URL is not configured (graceful degradation). + * + * @returns {import('ioredis').Redis | null} + */ +export function initRedis() { + if (initialized) return client; + initialized = true; + + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) { + info('Redis not configured (REDIS_URL not set) — caching disabled'); + return null; + } + + try { + client = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: false, + retryStrategy(times) { + if (times > 10) { + warn('Redis: max reconnect attempts reached, giving up'); + return null; // stop retrying + } + const delay = Math.min(times * 200, 5000); + return delay; + }, + }); + + client.on('connect', () => { + connected = true; + stats.connectedAt = Date.now(); + info('Redis connected'); + }); + + client.on('ready', () => { + debug('Redis ready'); + }); + + client.on('close', () => { + connected = false; + debug('Redis connection closed'); + }); + + client.on('error', (err) => { + connected = false; + stats.errors++; + logError('Redis connection error', { error: err.message }); + }); + + client.on('reconnecting', () => { + debug('Redis reconnecting...'); + }); + } catch (err) { + logError('Failed to initialize Redis client', { error: err.message }); + client = null; + } + + return client; +} + +/** + * Get the Redis client instance. + * Returns null if Redis is not configured or not initialized. + * + * @returns {import('ioredis').Redis | null} + */ +export function getRedis() { + if (!initialized) return initRedis(); + return client; +} + +/** + * Check if Redis is connected and ready. + * + * @returns {boolean} + */ +export function isRedisReady() { + return connected && client !== null; +} + +/** + * Get Redis connection stats for health checks. + * + * @returns {{ connected: boolean, hits: number, misses: number, errors: number, connectedAt: number|null, hitRate: string }} + */ +export function getRedisStats() { + const total = stats.hits + stats.misses; + const hitRate = total > 0 ? `${((stats.hits / total) * 100).toFixed(1)}%` : 'N/A'; + + return { + connected, + hits: stats.hits, + misses: stats.misses, + errors: stats.errors, + connectedAt: stats.connectedAt, + hitRate, + }; +} + +/** + * Record a cache hit. + */ +export function recordHit() { + stats.hits++; +} + +/** + * Record a cache miss. + */ +export function recordMiss() { + stats.misses++; +} + +/** + * Record a cache error. + */ +export function recordError() { + stats.errors++; +} + +/** + * Gracefully close the Redis connection. + * + * @returns {Promise} + */ +export async function closeRedisClient() { + if (!client) return; + try { + await client.quit(); + info('Redis connection closed gracefully'); + } catch (err) { + warn('Redis quit error during shutdown', { error: err.message }); + } finally { + client = null; + initialized = false; + connected = false; + } +} + +/** + * Reset internal state — for testing only. + * @internal + */ +export function _resetRedis() { + client = null; + initialized = false; + connected = false; + stats.hits = 0; + stats.misses = 0; + stats.errors = 0; + stats.connectedAt = null; +} diff --git a/src/utils/cache.js b/src/utils/cache.js new file mode 100644 index 00000000..92e1f14e --- /dev/null +++ b/src/utils/cache.js @@ -0,0 +1,276 @@ +/** + * Cache Utility Module + * High-level caching helpers that use Redis when available and + * fall back to in-memory LRU when Redis is not configured. + * + * All functions are safe to call without checking Redis availability — + * they handle graceful degradation internally. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/177 + */ + +import { debug, warn } from '../logger.js'; +import { getRedis, recordError, recordHit, recordMiss } from '../redis.js'; + +/** + * Default TTLs (in seconds) for different cache categories. + * Can be overridden via environment variables. + */ +export const TTL = { + CHANNELS: Number(process.env.REDIS_TTL_CHANNELS) || 300, // 5 min + ROLES: Number(process.env.REDIS_TTL_ROLES) || 300, // 5 min + MEMBERS: Number(process.env.REDIS_TTL_MEMBERS) || 60, // 1 min + CONFIG: Number(process.env.REDIS_TTL_CONFIG) || 60, // 1 min + REPUTATION: Number(process.env.REDIS_TTL_REPUTATION) || 60, // 1 min + LEADERBOARD: Number(process.env.REDIS_TTL_LEADERBOARD) || 300, // 5 min + ANALYTICS: Number(process.env.REDIS_TTL_ANALYTICS) || 3600, // 1 hour + SESSION: Number(process.env.REDIS_TTL_SESSION) || 86400, // 24 hours + CHANNEL_DETAIL: Number(process.env.REDIS_TTL_CHANNEL_DETAIL) || 600, // 10 min +}; + +/** @type {Map} In-memory LRU fallback */ +const memoryCache = new Map(); +const MAX_MEMORY_CACHE_SIZE = 1000; + +/** Interval reference for memory cache cleanup */ +let cleanupInterval = null; + +/** + * Start periodic cleanup of expired in-memory cache entries. + * Called automatically on first cache operation. + */ +function ensureCleanup() { + if (cleanupInterval) return; + cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of memoryCache) { + if (now >= entry.expiresAt) { + memoryCache.delete(key); + } + } + }, 60_000); + cleanupInterval.unref(); +} + +/** + * Evict oldest entries when memory cache exceeds max size. + */ +function evictIfNeeded() { + if (memoryCache.size <= MAX_MEMORY_CACHE_SIZE) return; + // Delete the oldest 10% to avoid evicting on every set + const toDelete = Math.floor(MAX_MEMORY_CACHE_SIZE * 0.1); + const keys = memoryCache.keys(); + for (let i = 0; i < toDelete; i++) { + const { value: key, done } = keys.next(); + if (done) break; + memoryCache.delete(key); + } +} + +/** + * Get a value from cache (Redis or in-memory fallback). + * + * @param {string} key - Cache key + * @returns {Promise} Cached value or null if not found + */ +export async function cacheGet(key) { + const redis = getRedis(); + + if (redis) { + try { + const val = await redis.get(key); + if (val !== null) { + recordHit(); + debug('Cache hit (Redis)', { key }); + try { + return JSON.parse(val); + } catch { + return val; + } + } + recordMiss(); + debug('Cache miss (Redis)', { key }); + return null; + } catch (err) { + recordError(); + warn('Redis cache get error, falling back to memory', { key, error: err.message }); + } + } + + // In-memory fallback + ensureCleanup(); + const entry = memoryCache.get(key); + if (entry && Date.now() < entry.expiresAt) { + recordHit(); + // Refresh position for LRU + memoryCache.delete(key); + memoryCache.set(key, entry); + return entry.value; + } + if (entry) { + memoryCache.delete(key); + } + recordMiss(); + return null; +} + +/** + * Set a value in cache (Redis or in-memory fallback). + * + * @param {string} key - Cache key + * @param {unknown} value - Value to cache (will be JSON-serialized) + * @param {number} [ttlSeconds=60] - TTL in seconds + * @returns {Promise} + */ +export async function cacheSet(key, value, ttlSeconds = 60) { + const redis = getRedis(); + + if (redis) { + try { + const serialized = JSON.stringify(value); + await redis.setex(key, ttlSeconds, serialized); + debug('Cache set (Redis)', { key, ttl: ttlSeconds }); + return; + } catch (err) { + recordError(); + warn('Redis cache set error, falling back to memory', { key, error: err.message }); + } + } + + // In-memory fallback + ensureCleanup(); + memoryCache.set(key, { + value, + expiresAt: Date.now() + ttlSeconds * 1000, + }); + evictIfNeeded(); +} + +/** + * Delete a key from cache. + * + * @param {string} key - Cache key + * @returns {Promise} + */ +export async function cacheDel(key) { + const redis = getRedis(); + + if (redis) { + try { + await redis.del(key); + return; + } catch (err) { + recordError(); + warn('Redis cache del error', { key, error: err.message }); + } + } + + memoryCache.delete(key); +} + +/** + * Delete all keys matching a pattern (e.g., "config:*" or "reputation:12345:*"). + * Uses SCAN for Redis (non-blocking), iterates in-memory for fallback. + * + * @param {string} pattern - Glob pattern (e.g., "config:*") + * @returns {Promise} Number of keys deleted + */ +export async function cacheDelPattern(pattern) { + const redis = getRedis(); + let deleted = 0; + + if (redis) { + try { + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + if (keys.length > 0) { + await redis.del(...keys); + deleted += keys.length; + } + } while (cursor !== '0'); + return deleted; + } catch (err) { + recordError(); + warn('Redis cache pattern delete error', { pattern, error: err.message }); + } + } + + // In-memory fallback: convert glob to regex + const regex = new RegExp(`^${pattern.replace(/\*/g, '.*').replace(/\?/g, '.')}$`); + for (const key of memoryCache.keys()) { + if (regex.test(key)) { + memoryCache.delete(key); + deleted++; + } + } + return deleted; +} + +/** + * Get-or-set pattern: return cached value or compute and cache it. + * + * @param {string} key - Cache key + * @param {() => Promise} factory - Async function to produce the value on cache miss + * @param {number} [ttlSeconds=60] - TTL in seconds + * @returns {Promise} The cached or freshly computed value + */ +export async function cacheGetOrSet(key, factory, ttlSeconds = 60) { + const cached = await cacheGet(key); + if (cached !== null) return cached; + + const value = await factory(); + if (value !== null && value !== undefined) { + await cacheSet(key, value, ttlSeconds); + } + return value; +} + +/** + * Clear all cache entries (both Redis namespace and in-memory). + * Use with caution — primarily for testing. + * + * @returns {Promise} + */ +export async function cacheClear() { + const redis = getRedis(); + if (redis) { + try { + await redis.flushdb(); + } catch (err) { + recordError(); + warn('Redis flush error', { error: err.message }); + } + } + memoryCache.clear(); +} + +/** + * Stop the memory cache cleanup interval. + * Call during graceful shutdown. + */ +export function stopCacheCleanup() { + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } +} + +/** + * Get memory cache size (for diagnostics). + * + * @returns {number} + */ +export function getMemoryCacheSize() { + return memoryCache.size; +} + +/** + * Reset all internal state — for testing only. + * @internal + */ +export function _resetCache() { + memoryCache.clear(); + stopCacheCleanup(); +} From 6859442c3bc37366840fc1a197df5721b211a02d Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 19:54:34 -0500 Subject: [PATCH 02/24] feat(redis): integrate Redis into startup/shutdown and health endpoint - Update index.js to initialize Redis on startup and close on shutdown - Add stopCacheCleanup() to graceful shutdown sequence - Update redisClient.js to be a thin re-export from centralized redis.js - Add Redis stats (connected, hit/miss, hitRate) to /health endpoint Part of #177 --- src/api/routes/health.js | 4 +++ src/api/utils/redisClient.js | 59 ++++++++---------------------------- src/index.js | 7 ++++- 3 files changed, 23 insertions(+), 47 deletions(-) diff --git a/src/api/routes/health.js b/src/api/routes/health.js index 5fa17807..85c7ffab 100644 --- a/src/api/routes/health.js +++ b/src/api/routes/health.js @@ -5,6 +5,7 @@ */ import { Router } from 'express'; +import { getRedisStats } from '../../redis.js'; import { isValidSecret } from '../middleware/auth.js'; /** Lazy-loaded queryLogs — optional diagnostic feature, not required for health */ @@ -170,6 +171,9 @@ router.get('/', async (req, res) => { } } + // Redis stats (authenticated only) + body.redis = getRedisStats(); + // Error counts from logs table (optional — partial data on failure) const queryLogs = await getQueryLogs(); if (queryLogs) { diff --git a/src/api/utils/redisClient.js b/src/api/utils/redisClient.js index b6f90369..256b193b 100644 --- a/src/api/utils/redisClient.js +++ b/src/api/utils/redisClient.js @@ -1,64 +1,32 @@ /** - * Redis Client - * Lazily-initialised ioredis client for session storage. - * If REDIS_URL is not configured, getRedisClient() returns null and all - * callers fall back to the in-memory implementation. + * Redis Client (API Compatibility Layer) + * Re-exports from the centralized src/redis.js module. + * + * Existing code that imports from this file continues to work without changes. + * New code should import directly from src/redis.js. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/177 */ -import Redis from 'ioredis'; -import { error as logError, warn } from '../../logger.js'; - -/** @type {Redis | null} */ -let _client = null; -let _initialized = false; +import { closeRedisClient, getRedis, _resetRedis } from '../../redis.js'; /** - * Return the ioredis client, initialising it on first call. + * Return the ioredis client. * Returns null if REDIS_URL is not configured. * - * @returns {Redis | null} + * @returns {import('ioredis').Redis | null} */ export function getRedisClient() { - if (_initialized) return _client; - _initialized = true; - - const redisUrl = process.env.REDIS_URL; - if (!redisUrl) return null; - - try { - _client = new Redis(redisUrl, { - maxRetriesPerRequest: 3, - enableReadyCheck: true, - lazyConnect: false, - }); - - _client.on('error', (err) => { - logError('Redis connection error', { error: err.message }); - }); - } catch (err) { - logError('Failed to initialise Redis client', { error: err.message }); - _client = null; - } - - return _client; + return getRedis(); } /** * Gracefully close the Redis connection. - * Safe to call even if Redis was never configured. * * @returns {Promise} */ export async function closeRedis() { - if (!_client) return; - try { - await _client.quit(); - } catch (err) { - warn('Redis quit error during shutdown', { error: err.message }); - } finally { - _client = null; - _initialized = false; - } + return closeRedisClient(); } /** @@ -66,6 +34,5 @@ export async function closeRedis() { * @internal */ export function _resetRedisClient() { - _client = null; - _initialized = false; + _resetRedis(); } diff --git a/src/index.js b/src/index.js index a187f9ee..c3ff5a5d 100644 --- a/src/index.js +++ b/src/index.js @@ -20,7 +20,8 @@ import { fileURLToPath } from 'node:url'; import { Client, Collection, Events, GatewayIntentBits, Partials } from 'discord.js'; import { config as dotenvConfig } from 'dotenv'; import { startServer, stopServer } from './api/server.js'; -import { closeRedis } from './api/utils/redisClient.js'; +import { closeRedisClient as closeRedis, initRedis } from './redis.js'; +import { stopCacheCleanup } from './utils/cache.js'; import { registerConfigListeners, removeLoggingTransport, @@ -313,6 +314,7 @@ async function gracefulShutdown(signal) { // 4.5. Close Redis connection (no-op if Redis was never configured) try { + stopCacheCleanup(); await closeRedis(); } catch (err) { error('Failed to close Redis connection', { error: err.message }); @@ -365,6 +367,9 @@ async function startup() { let dbPool = null; if (process.env.DATABASE_URL) { dbPool = await initDb(); + + // Initialize Redis (gracefully degrades if REDIS_URL not set) + initRedis(); info('Database initialized'); // Record this startup in the restart history table From 67075f7fb046ff88cbb0b8da67396111b6315042 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 19:55:26 -0500 Subject: [PATCH 03/24] feat(cache): add Discord API and reputation cache layers - discordCache.js: Cached fetchers for channels, roles, members, guild channels with TTL-based expiration and cache invalidation helpers - reputationCache.js: XP/level/leaderboard/rank caching with proper invalidation on XP gain events Part of #177 --- src/utils/discordCache.js | 178 +++++++++++++++++++++++++++++++++++ src/utils/reputationCache.js | 82 ++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 src/utils/discordCache.js create mode 100644 src/utils/reputationCache.js diff --git a/src/utils/discordCache.js b/src/utils/discordCache.js new file mode 100644 index 00000000..c403f74f --- /dev/null +++ b/src/utils/discordCache.js @@ -0,0 +1,178 @@ +/** + * Discord API Cache Layer + * Caches Discord API fetch results (channels, roles, members, guilds) + * to reduce API calls and improve response times. + * + * Uses the centralized cache system (Redis with in-memory fallback). + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/177 + */ + +import { debug, warn } from '../logger.js'; +import { cacheGet, cacheSet, TTL } from './cache.js'; + +/** + * Fetch a channel with caching. + * Falls through to Discord API on cache miss, caches the serialized result. + * + * @param {import('discord.js').Client} client - Discord client + * @param {string} channelId - Channel ID to fetch + * @returns {Promise} The channel, or null if not found + */ +export async function fetchChannelCached(client, channelId) { + if (!channelId) return null; + + // Try Discord.js internal cache first (always fastest) + const djsCached = client.channels.cache.get(channelId); + if (djsCached) return djsCached; + + // Try Redis/memory cache for channel metadata + const cacheKey = `discord:channel:${channelId}`; + const cached = await cacheGet(cacheKey); + if (cached) { + // We can't reconstruct a full Channel object from cached data, + // but we can avoid the API call by checking if it appeared in DJS cache + // during the async gap + const recheckDjs = client.channels.cache.get(channelId); + if (recheckDjs) return recheckDjs; + } + + // Fetch from Discord API + try { + const channel = await client.channels.fetch(channelId); + if (channel) { + // Cache minimal metadata for future health checks + await cacheSet(cacheKey, { + id: channel.id, + name: channel.name ?? null, + type: channel.type, + guildId: channel.guildId ?? null, + }, TTL.CHANNEL_DETAIL); + debug('Fetched and cached channel', { channelId, name: channel.name }); + } + return channel; + } catch (err) { + warn('Failed to fetch channel', { channelId, error: err.message }); + return null; + } +} + +/** + * Fetch guild channels list with caching. + * Returns serialized channel data suitable for API responses. + * + * @param {import('discord.js').Guild} guild - Discord guild + * @returns {Promise>} + */ +export async function fetchGuildChannelsCached(guild) { + const cacheKey = `discord:guild:${guild.id}:channels`; + const cached = await cacheGet(cacheKey); + if (cached) return cached; + + try { + const channels = await guild.channels.fetch(); + const serialized = Array.from(channels.values()) + .filter((ch) => ch !== null) + .map((ch) => ({ + id: ch.id, + name: ch.name, + type: ch.type, + position: ch.position ?? 0, + parentId: ch.parentId ?? null, + })) + .sort((a, b) => a.position - b.position); + + await cacheSet(cacheKey, serialized, TTL.CHANNELS); + debug('Fetched and cached guild channels', { guildId: guild.id, count: serialized.length }); + return serialized; + } catch (err) { + warn('Failed to fetch guild channels', { guildId: guild.id, error: err.message }); + return []; + } +} + +/** + * Fetch guild roles list with caching. + * Returns serialized role data suitable for API responses. + * + * @param {import('discord.js').Guild} guild - Discord guild + * @returns {Promise>} + */ +export async function fetchGuildRolesCached(guild) { + const cacheKey = `discord:guild:${guild.id}:roles`; + const cached = await cacheGet(cacheKey); + if (cached) return cached; + + try { + const roles = await guild.roles.fetch(); + const serialized = Array.from(roles.values()).map((role) => ({ + id: role.id, + name: role.name, + color: role.color, + position: role.position, + permissions: role.permissions.bitfield.toString(), + })); + + await cacheSet(cacheKey, serialized, TTL.ROLES); + debug('Fetched and cached guild roles', { guildId: guild.id, count: serialized.length }); + return serialized; + } catch (err) { + warn('Failed to fetch guild roles', { guildId: guild.id, error: err.message }); + return []; + } +} + +/** + * Fetch a guild member with caching. + * + * @param {import('discord.js').Guild} guild - Discord guild + * @param {string} userId - User ID + * @returns {Promise} + */ +export async function fetchMemberCached(guild, userId) { + if (!userId) return null; + + // Try Discord.js internal cache first + const djsCached = guild.members.cache.get(userId); + if (djsCached) return djsCached; + + const cacheKey = `discord:guild:${guild.id}:member:${userId}`; + const cached = await cacheGet(cacheKey); + + // If we have cached metadata, try DJS cache again (may have been populated) + if (cached) { + const recheckDjs = guild.members.cache.get(userId); + if (recheckDjs) return recheckDjs; + } + + try { + const member = await guild.members.fetch(userId); + if (member) { + await cacheSet(cacheKey, { + id: member.id, + displayName: member.displayName, + joinedAt: member.joinedAt?.toISOString() ?? null, + }, TTL.MEMBERS); + } + return member; + } catch (err) { + // Don't warn for unknown member — it's expected + if (err.code !== 10007) { + warn('Failed to fetch guild member', { guildId: guild.id, userId, error: err.message }); + } + return null; + } +} + +/** + * Invalidate all cached data for a guild. + * Call this when guild config or structure changes significantly. + * + * @param {string} guildId - Guild ID to invalidate + * @returns {Promise} + */ +export async function invalidateGuildCache(guildId) { + const { cacheDelPattern } = await import('./cache.js'); + await cacheDelPattern(`discord:guild:${guildId}:*`); + debug('Invalidated guild cache', { guildId }); +} diff --git a/src/utils/reputationCache.js b/src/utils/reputationCache.js new file mode 100644 index 00000000..b5d9002b --- /dev/null +++ b/src/utils/reputationCache.js @@ -0,0 +1,82 @@ +/** + * Reputation Cache Layer + * Caches reputation data (XP, levels, leaderboards) to reduce DB queries. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/177 + */ + +import { debug } from '../logger.js'; +import { cacheDel, cacheGet, cacheGetOrSet, cacheSet, TTL } from './cache.js'; + +/** + * Get cached reputation data for a user. + * + * @param {string} guildId + * @param {string} userId + * @returns {Promise<{xp: number, level: number, messages_count: number}|null>} + */ +export async function getReputationCached(guildId, userId) { + const key = `reputation:${guildId}:${userId}`; + return cacheGet(key); +} + +/** + * Cache reputation data for a user. + * + * @param {string} guildId + * @param {string} userId + * @param {{xp: number, level: number, messages_count: number}} data + * @returns {Promise} + */ +export async function setReputationCache(guildId, userId, data) { + const key = `reputation:${guildId}:${userId}`; + await cacheSet(key, data, TTL.REPUTATION); + debug('Cached reputation', { guildId, userId, xp: data.xp }); +} + +/** + * Invalidate reputation cache for a user (call after XP gain/update). + * + * @param {string} guildId + * @param {string} userId + * @returns {Promise} + */ +export async function invalidateReputationCache(guildId, userId) { + await cacheDel(`reputation:${guildId}:${userId}`); + await cacheDel(`rank:${guildId}:${userId}`); + // Also invalidate leaderboard since rankings may have changed + await cacheDel(`leaderboard:${guildId}`); +} + +/** + * Get cached leaderboard for a guild. + * + * @param {string} guildId + * @param {() => Promise} fetchFn - Factory to fetch from DB on miss + * @returns {Promise} + */ +export async function getLeaderboardCached(guildId, fetchFn) { + return cacheGetOrSet(`leaderboard:${guildId}`, fetchFn, TTL.LEADERBOARD); +} + +/** + * Get cached rank for a user. + * + * @param {string} guildId + * @param {string} userId + * @param {() => Promise} fetchFn - Factory to fetch from DB on miss + * @returns {Promise} + */ +export async function getRankCached(guildId, userId, fetchFn) { + return cacheGetOrSet(`rank:${guildId}:${userId}`, fetchFn, TTL.REPUTATION); +} + +/** + * Invalidate entire guild leaderboard (call when significant XP changes happen). + * + * @param {string} guildId + * @returns {Promise} + */ +export async function invalidateLeaderboard(guildId) { + await cacheDel(`leaderboard:${guildId}`); +} From a42bfab00c9217b4ba5a15688aad6b4ac863592b Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 19:56:53 -0500 Subject: [PATCH 04/24] feat(cache): integrate caching into modules and API routes - Replace client.channels.fetch() with fetchChannelCached() in 12 modules: scheduler, reminderHandler, pollHandler, challengeScheduler, githubFeed, moderation, rateLimit, linkFilter, reviewHandler, starboard, welcome, triage-respond - Add leaderboard DB query caching in community.js (TTL 5min) - Add reputation cache invalidation on XP gain in reputation.js - All fetches use Redis when available, in-memory LRU fallback otherwise Part of #177 --- src/api/routes/community.js | 51 ++++++++++++++++++------------- src/modules/challengeScheduler.js | 3 +- src/modules/githubFeed.js | 3 +- src/modules/linkFilter.js | 3 +- src/modules/moderation.js | 3 +- src/modules/pollHandler.js | 3 +- src/modules/rateLimit.js | 3 +- src/modules/reminderHandler.js | 3 +- src/modules/reputation.js | 4 +++ src/modules/reviewHandler.js | 3 +- src/modules/scheduler.js | 3 +- src/modules/starboard.js | 5 +-- src/modules/triage-respond.js | 7 +++-- src/modules/welcome.js | 3 +- 14 files changed, 61 insertions(+), 36 deletions(-) diff --git a/src/api/routes/community.js b/src/api/routes/community.js index 6b90f84f..5e6a14c3 100644 --- a/src/api/routes/community.js +++ b/src/api/routes/community.js @@ -9,6 +9,7 @@ import { Router } from 'express'; import { error as logError } from '../../logger.js'; import { getConfig } from '../../modules/config.js'; +import { cacheGet, cacheGetOrSet, cacheSet, TTL } from '../../utils/cache.js'; import { computeLevel } from '../../modules/reputation.js'; import { REPUTATION_DEFAULTS } from '../../modules/reputationDefaults.js'; import { rateLimit } from '../middleware/rateLimit.js'; @@ -142,35 +143,43 @@ router.get('/:guildId/leaderboard', async (req, res) => { try { const repConfig = getRepConfig(guildId); - const [countResult, membersResult] = await Promise.all([ - pool.query( - `SELECT COUNT(*)::int AS total - FROM user_stats us - INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id - WHERE us.guild_id = $1 AND us.public_profile = TRUE`, - [guildId], - ), - pool.query( - `SELECT us.user_id, r.xp, r.level - FROM user_stats us - INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id - WHERE us.guild_id = $1 AND us.public_profile = TRUE - ORDER BY r.xp DESC - LIMIT $2 OFFSET $3`, - [guildId, limit, offset], - ), - ]); + // Cache leaderboard DB results per guild+page (most expensive query) + const cacheKey = `leaderboard:${guildId}:${page}:${limit}`; + const dbResult = await cacheGetOrSet(cacheKey, async () => { + const [countResult, membersResult] = await Promise.all([ + pool.query( + `SELECT COUNT(*)::int AS total + FROM user_stats us + INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id + WHERE us.guild_id = $1 AND us.public_profile = TRUE`, + [guildId], + ), + pool.query( + `SELECT us.user_id, r.xp, r.level + FROM user_stats us + INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id + WHERE us.guild_id = $1 AND us.public_profile = TRUE + ORDER BY r.xp DESC + LIMIT $2 OFFSET $3`, + [guildId, limit, offset], + ), + ]); + return { + total: countResult.rows[0]?.total ?? 0, + rows: membersResult.rows, + }; + }, TTL.LEADERBOARD); - const total = countResult.rows[0]?.total ?? 0; + const { total, rows: memberRows } = dbResult; const { client } = req.app.locals; const guild = client?.guilds?.cache?.get(guildId); - const leaderboardUserIds = membersResult.rows.map((r) => r.user_id); + const leaderboardUserIds = memberRows.map((r) => r.user_id); const fetchedLeaderboardMembers = guild ? await guild.members.fetch({ user: leaderboardUserIds }).catch(() => new Map()) : new Map(); - const members = membersResult.rows.map((row, idx) => { + const members = memberRows.map((row, idx) => { const level = computeLevel(row.xp, repConfig.levelThresholds); let username = row.user_id; let displayName = row.user_id; diff --git a/src/modules/challengeScheduler.js b/src/modules/challengeScheduler.js index e6bdf188..794a12fe 100644 --- a/src/modules/challengeScheduler.js +++ b/src/modules/challengeScheduler.js @@ -6,6 +6,7 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/52 */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { createRequire } from 'node:module'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; @@ -188,7 +189,7 @@ export async function postDailyChallenge(client, guildId) { return false; } - const channel = await client.channels.fetch(channelId).catch(() => null); + const channel = await fetchChannelCached(client, channelId); if (!channel) { logWarn('Challenge channel not found', { guildId, channelId }); return false; diff --git a/src/modules/githubFeed.js b/src/modules/githubFeed.js index 71a36a4e..d9d24bf7 100644 --- a/src/modules/githubFeed.js +++ b/src/modules/githubFeed.js @@ -5,6 +5,7 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/51 */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { EmbedBuilder } from 'discord.js'; @@ -247,7 +248,7 @@ async function pollGuildFeed(client, guildId, feedConfig) { return; } - const channel = await client.channels.fetch(channelId).catch(() => null); + const channel = await fetchChannelCached(client, channelId); if (!channel) { logWarn('GitHub feed: channel not found', { guildId, channelId }); return; diff --git a/src/modules/linkFilter.js b/src/modules/linkFilter.js index 7a59e15f..90bc463c 100644 --- a/src/modules/linkFilter.js +++ b/src/modules/linkFilter.js @@ -4,6 +4,7 @@ * Also detects phishing TLD patterns (.xyz with suspicious keywords). */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder } from 'discord.js'; import { warn } from '../logger.js'; import { isExempt } from '../utils/modExempt.js'; @@ -91,7 +92,7 @@ async function alertModChannel(message, config, matchedDomain, reason) { const alertChannelId = config.moderation?.alertChannelId; if (!alertChannelId) return; - const alertChannel = await message.client.channels.fetch(alertChannelId).catch(() => null); + const alertChannel = await fetchChannelCached(message.client, alertChannelId); if (!alertChannel) return; const embed = new EmbedBuilder() diff --git a/src/modules/moderation.js b/src/modules/moderation.js index 5a8317a4..7cabfbf5 100644 --- a/src/modules/moderation.js +++ b/src/modules/moderation.js @@ -4,6 +4,7 @@ * auto-escalation, and tempban scheduling. */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError, warn as logWarn } from '../logger.js'; @@ -215,7 +216,7 @@ export async function sendModLogEmbed(client, config, caseData) { const channelId = channels[actionKey] || channels.default; if (!channelId) return null; - const channel = await client.channels.fetch(channelId).catch(() => null); + const channel = await fetchChannelCached(client, channelId); if (!channel) return null; const embed = new EmbedBuilder() diff --git a/src/modules/pollHandler.js b/src/modules/pollHandler.js index 7c903350..3dcbb33a 100644 --- a/src/modules/pollHandler.js +++ b/src/modules/pollHandler.js @@ -5,6 +5,7 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/47 */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError } from '../logger.js'; @@ -257,7 +258,7 @@ export async function closePoll(pollId, client) { const poll = rows[0]; try { - const channel = await client.channels.fetch(poll.channel_id).catch(() => null); + const channel = await fetchChannelCached(client, poll.channel_id); if (channel && poll.message_id) { const message = await channel.messages.fetch(poll.message_id).catch(() => null); if (message) { diff --git a/src/modules/rateLimit.js b/src/modules/rateLimit.js index 0b2ff790..8ab28875 100644 --- a/src/modules/rateLimit.js +++ b/src/modules/rateLimit.js @@ -4,6 +4,7 @@ * Actions on trigger: delete excess messages, warn user, temp-mute on repeat. */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder, PermissionFlagsBits } from 'discord.js'; import { info, warn } from '../logger.js'; import { isExempt } from '../utils/modExempt.js'; @@ -89,7 +90,7 @@ async function handleRepeatOffender(message, config, muteDurationMs) { const alertChannelId = config.moderation?.alertChannelId; if (!alertChannelId) return; - const alertChannel = await message.client.channels.fetch(alertChannelId).catch(() => null); + const alertChannel = await fetchChannelCached(message.client, alertChannelId); if (!alertChannel) return; const muteWindowMinutes = Math.round(muteWindowSeconds / 60); diff --git a/src/modules/reminderHandler.js b/src/modules/reminderHandler.js index f5edade5..689f2190 100644 --- a/src/modules/reminderHandler.js +++ b/src/modules/reminderHandler.js @@ -5,6 +5,7 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/137 */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError, warn } from '../logger.js'; @@ -102,7 +103,7 @@ async function sendReminderNotification(client, reminder) { // Fallback: channel mention try { - const channel = await client.channels.fetch(reminder.channel_id).catch(() => null); + const channel = await fetchChannelCached(client, reminder.channel_id); if (channel) { await safeSend(channel, { content: `<@${reminder.user_id}>`, diff --git a/src/modules/reputation.js b/src/modules/reputation.js index 8b52ab06..a5ce45a6 100644 --- a/src/modules/reputation.js +++ b/src/modules/reputation.js @@ -12,6 +12,7 @@ import { safeSend } from '../utils/safeSend.js'; import { sanitizeMentions } from '../utils/sanitizeMentions.js'; import { getConfig } from './config.js'; import { REPUTATION_DEFAULTS } from './reputationDefaults.js'; +import { invalidateReputationCache } from '../utils/reputationCache.js'; /** In-memory cooldown map: `${guildId}:${userId}` → Date of last XP gain */ const cooldowns = new Map(); @@ -116,6 +117,9 @@ export async function handleXpGain(message) { // Set cooldown AFTER successful DB write (sweep interval handles eviction) cooldowns.set(key, now); + // Invalidate cached reputation/leaderboard data for this user + invalidateReputationCache(message.guild.id, message.author.id).catch(() => {}); + const { xp: newXp, level: currentLevel } = rows[0]; const thresholds = repCfg.levelThresholds; const newLevel = computeLevel(newXp, thresholds); diff --git a/src/modules/reviewHandler.js b/src/modules/reviewHandler.js index 4e074afb..32ed9970 100644 --- a/src/modules/reviewHandler.js +++ b/src/modules/reviewHandler.js @@ -1,7 +1,8 @@ /** * Review Handler Module * Business logic for review embed building, claim button interactions, and stale review cleanup. - * Kept separate from the slash command definition so the scheduler can import it without + * Kept separate from the slash command definition so the scheduler can import { fetchChannelCached } from '../utils/discordCache.js'; +import it without * pulling in SlashCommandBuilder (which breaks index.test.js's discord.js mock). * * @see https://github.com/VolvoxLLC/volvox-bot/issues/49 diff --git a/src/modules/scheduler.js b/src/modules/scheduler.js index 84a60b0a..34f1d76f 100644 --- a/src/modules/scheduler.js +++ b/src/modules/scheduler.js @@ -5,6 +5,7 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/42 */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { getPool } from '../db.js'; import { info, error as logError, warn as logWarn } from '../logger.js'; import { getNextCronRun, parseCron } from '../utils/cronParser.js'; @@ -47,7 +48,7 @@ async function pollScheduledMessages(client) { for (const msg of rows) { try { - const channel = await client.channels.fetch(msg.channel_id).catch(() => null); + const channel = await fetchChannelCached(client, msg.channel_id); if (!channel) { logWarn('Scheduled message channel not found', { id: msg.id, diff --git a/src/modules/starboard.js b/src/modules/starboard.js index 66eb35ec..fc6b1cd4 100644 --- a/src/modules/starboard.js +++ b/src/modules/starboard.js @@ -6,6 +6,7 @@ * Handles dedup (update vs repost), star removal, and self-star prevention. */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { debug, info, error as logError, warn } from '../logger.js'; @@ -277,7 +278,7 @@ export async function handleReactionAdd(reaction, user, client, config) { const existing = await findStarboardPost(message.id); try { - const starboardChannel = await client.channels.fetch(sbConfig.channelId); + const starboardChannel = await fetchChannelCached(client, sbConfig.channelId); if (!starboardChannel) { warn('Starboard channel not found', { channelId: sbConfig.channelId }); return; @@ -373,7 +374,7 @@ export async function handleReactionRemove(reaction, _user, client, config) { ); try { - const starboardChannel = await client.channels.fetch(sbConfig.channelId); + const starboardChannel = await fetchChannelCached(client, sbConfig.channelId); if (!starboardChannel) { warn('Starboard channel not found on reaction remove', { channelId: sbConfig.channelId }); return; diff --git a/src/modules/triage-respond.js b/src/modules/triage-respond.js index 88f1aa4c..55d066f9 100644 --- a/src/modules/triage-respond.js +++ b/src/modules/triage-respond.js @@ -3,6 +3,7 @@ * Discord message dispatch, moderation audit logging, and channel context fetching. */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder } from 'discord.js'; import { info, error as logError, warn } from '../logger.js'; import { buildDebugEmbed, extractStats, logAiUsage } from '../utils/debugFooter.js'; @@ -52,7 +53,7 @@ function logAssistantHistory(channelId, guildId, fallbackContent, sentMsg) { */ export async function fetchChannelContext(channelId, client, bufferSnapshot, limit = 15) { try { - const channel = await client.channels.fetch(channelId); + const channel = await fetchChannelCached(client, channelId); if (!channel?.messages) { warn('Channel fetch returned no messages API', { channelId }); return []; @@ -99,7 +100,7 @@ export async function sendModerationLog(client, classification, snapshot, channe if (!logChannelId) return; try { - const logChannel = await client.channels.fetch(logChannelId); + const logChannel = await fetchChannelCached(client, logChannelId); if (!logChannel) return; // Find target messages from the snapshot @@ -294,7 +295,7 @@ export async function buildStatsAndLog( }; // Fetch channel once for guildId resolution + passing to sendResponses - const channel = await client.channels.fetch(channelId).catch(() => null); + const channel = await fetchChannelCached(client, channelId).catch(() => null); const guildId = channel?.guildId; // Log AI usage analytics (fire-and-forget) diff --git a/src/modules/welcome.js b/src/modules/welcome.js index 55b31203..b5cfb0ab 100644 --- a/src/modules/welcome.js +++ b/src/modules/welcome.js @@ -3,6 +3,7 @@ * Handles dynamic welcome messages for new members */ +import { fetchChannelCached } from '../utils/discordCache.js'; import { info, error as logError } from '../logger.js'; import { safeSend } from '../utils/safeSend.js'; import { isReturningMember } from './welcomeOnboarding.js'; @@ -134,7 +135,7 @@ export async function sendWelcomeMessage(member, client, config) { if (!config.welcome?.enabled || !config.welcome?.channelId) return; try { - const channel = await client.channels.fetch(config.welcome.channelId); + const channel = await fetchChannelCached(client, config.welcome.channelId); if (!channel) return; const useDynamic = config.welcome?.dynamic?.enabled === true; From 713cd4d66b83b9f4447b51116c7e6d4a062a834f Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:03:34 -0500 Subject: [PATCH 05/24] feat(cache): config invalidation, tests, and test mocks for all modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cache invalidation listeners in config-listeners.js for welcome, starboard, and reputation config changes - Add comprehensive tests: redis.test.js (8 tests), cache.test.js (20 tests), discordCache.test.js (10 tests), reputationCache.test.js (6 tests) - Update 16 existing test files with discordCache mock to prevent import resolution errors from fetchChannelCached - Fix community.test.js to mock cacheGetOrSet pass-through - Update redisClient.coverage.test.js for US spelling - Update config-listeners.test.js for new listener count (8→11) - Update welcome.coverage.test.js for graceful null return behavior All 2869 passing tests pass. 6 pre-existing failures in triage.coverage.test.js (unrelated to this PR). Part of #177 --- src/config-listeners.js | 21 ++ tests/api/routes/community.test.js | 10 + tests/api/utils/redisClient.coverage.test.js | 2 +- tests/config-listeners.test.js | 10 +- tests/modules/challengeScheduler.test.js | 18 ++ tests/modules/githubFeed.test.js | 18 ++ tests/modules/linkFilter.test.js | 17 ++ tests/modules/moderation.test.js | 18 ++ tests/modules/rateLimit.coverage.test.js | 18 ++ tests/modules/rateLimit.test.js | 17 ++ tests/modules/reminderHandler.test.js | 18 ++ tests/modules/scheduler.test.js | 18 ++ tests/modules/starboard.test.js | 18 ++ tests/modules/triage-respond.test.js | 18 ++ tests/modules/triage.coverage.test.js | 18 ++ tests/modules/triage.test.js | 18 ++ tests/modules/welcome.coverage.test.js | 27 +- tests/modules/welcome.test.js | 18 ++ tests/redis.test.js | 119 +++++++++ tests/utils/cache.test.js | 244 +++++++++++++++++++ tests/utils/discordCache.test.js | 183 ++++++++++++++ tests/utils/reputationCache.test.js | 100 ++++++++ 22 files changed, 940 insertions(+), 8 deletions(-) create mode 100644 tests/redis.test.js create mode 100644 tests/utils/cache.test.js create mode 100644 tests/utils/discordCache.test.js create mode 100644 tests/utils/reputationCache.test.js diff --git a/src/config-listeners.js b/src/config-listeners.js index ff9fe09e..bea6ac30 100644 --- a/src/config-listeners.js +++ b/src/config-listeners.js @@ -10,6 +10,7 @@ import { addPostgresTransport, error, info, removePostgresTransport } from './logger.js'; import { onConfigChange } from './modules/config.js'; +import { cacheDelPattern } from './utils/cache.js'; /** @type {import('winston').transport | null} */ let pgTransport = null; @@ -85,6 +86,26 @@ export function registerConfigListeners({ dbPool, config }) { onConfigChange('moderation.*', (newValue, _oldValue, path, guildId) => { info('Moderation config updated', { path, newValue, guildId }); }); + + // ── Cache invalidation on config changes ──────────────────────────── + // When channel-related config changes, invalidate Discord API caches + // so the bot picks up the new channel references immediately. + onConfigChange('welcome.*', async (_newValue, _oldValue, path, guildId) => { + if (guildId && guildId !== 'global') { + await cacheDelPattern(`discord:guild:${guildId}:*`).catch(() => {}); + } + }); + onConfigChange('starboard.*', async (_newValue, _oldValue, path, guildId) => { + if (guildId && guildId !== 'global') { + await cacheDelPattern(`discord:guild:${guildId}:*`).catch(() => {}); + } + }); + onConfigChange('reputation.*', async (_newValue, _oldValue, path, guildId) => { + if (guildId && guildId !== 'global') { + await cacheDelPattern(`leaderboard:${guildId}:*`).catch(() => {}); + await cacheDelPattern(`reputation:${guildId}:*`).catch(() => {}); + } + }); } /** diff --git a/tests/api/routes/community.test.js b/tests/api/routes/community.test.js index f69ef017..19d80eb5 100644 --- a/tests/api/routes/community.test.js +++ b/tests/api/routes/community.test.js @@ -26,6 +26,16 @@ vi.mock('../../../src/modules/config.js', () => ({ setConfigValue: vi.fn(), })); +// Mock cache to pass through (no caching in tests) +vi.mock('../../../src/utils/cache.js', () => ({ + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn().mockResolvedValue(undefined), + cacheGetOrSet: vi.fn().mockImplementation(async (_key, factory) => factory()), + cacheDel: vi.fn().mockResolvedValue(undefined), + cacheDelPattern: vi.fn().mockResolvedValue(0), + TTL: { LEADERBOARD: 300 }, +})); + vi.mock('../../../src/api/middleware/oauthJwt.js', () => ({ handleOAuthJwt: vi.fn().mockResolvedValue(false), stopJwtCleanup: vi.fn(), diff --git a/tests/api/utils/redisClient.coverage.test.js b/tests/api/utils/redisClient.coverage.test.js index 1e0ecbbf..60662a61 100644 --- a/tests/api/utils/redisClient.coverage.test.js +++ b/tests/api/utils/redisClient.coverage.test.js @@ -88,7 +88,7 @@ describe('redisClient coverage', () => { const client = getRedisClient(); expect(client).toBeNull(); expect(logError).toHaveBeenCalledWith( - 'Failed to initialise Redis client', + 'Failed to initialize Redis client', expect.objectContaining({ error: 'Connection refused' }), ); }); diff --git a/tests/config-listeners.test.js b/tests/config-listeners.test.js index 7bd15852..af80c9be 100644 --- a/tests/config-listeners.test.js +++ b/tests/config-listeners.test.js @@ -50,6 +50,12 @@ describe('config-listeners', () => { setInitialTransport = mod.setInitialTransport; }); + +vi.mock('../src/utils/cache.js', () => ({ + cacheDelPattern: vi.fn().mockResolvedValue(0), +})); + + afterEach(() => { vi.clearAllMocks(); }); @@ -87,10 +93,10 @@ describe('config-listeners', () => { expect(registeredKeys).toContain('moderation.*'); }); - it('registers exactly 8 listeners', () => { + it('registers exactly 11 listeners', () => { const config = { logging: { database: { enabled: false } } }; registerConfigListeners({ dbPool: {}, config }); - expect(onConfigChange).toHaveBeenCalledTimes(8); + expect(onConfigChange).toHaveBeenCalledTimes(11); }); }); diff --git a/tests/modules/challengeScheduler.test.js b/tests/modules/challengeScheduler.test.js index d5707ae3..fd7a7bcc 100644 --- a/tests/modules/challengeScheduler.test.js +++ b/tests/modules/challengeScheduler.test.js @@ -2,6 +2,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // ─── Mock dependencies ─────────────────────────────────────────────────────── + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/db.js', () => ({ getPool: vi.fn(), })); diff --git a/tests/modules/githubFeed.test.js b/tests/modules/githubFeed.test.js index 01180bfc..0219c7b6 100644 --- a/tests/modules/githubFeed.test.js +++ b/tests/modules/githubFeed.test.js @@ -1,6 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock child_process for gh CLI calls + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('node:child_process', () => ({ execFile: vi.fn(), })); diff --git a/tests/modules/linkFilter.test.js b/tests/modules/linkFilter.test.js index ccb5ce4f..b52cf641 100644 --- a/tests/modules/linkFilter.test.js +++ b/tests/modules/linkFilter.test.js @@ -1,5 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + import { checkLinks, extractUrls, matchPhishingPattern } from '../../src/modules/linkFilter.js'; // --------------------------------------------------------------------------- diff --git a/tests/modules/moderation.test.js b/tests/modules/moderation.test.js index c506a126..124c3075 100644 --- a/tests/modules/moderation.test.js +++ b/tests/modules/moderation.test.js @@ -1,6 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/db.js', () => ({ getPool: vi.fn(), })); diff --git a/tests/modules/rateLimit.coverage.test.js b/tests/modules/rateLimit.coverage.test.js index 2909dad3..c5440cfb 100644 --- a/tests/modules/rateLimit.coverage.test.js +++ b/tests/modules/rateLimit.coverage.test.js @@ -4,6 +4,24 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/logger.js', () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/tests/modules/rateLimit.test.js b/tests/modules/rateLimit.test.js index e6678124..5ed01849 100644 --- a/tests/modules/rateLimit.test.js +++ b/tests/modules/rateLimit.test.js @@ -1,5 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + import { checkRateLimit, clearRateLimitState, diff --git a/tests/modules/reminderHandler.test.js b/tests/modules/reminderHandler.test.js index d0107037..6978e6de 100644 --- a/tests/modules/reminderHandler.test.js +++ b/tests/modules/reminderHandler.test.js @@ -3,6 +3,24 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/db.js', () => ({ getPool: vi.fn(), })); diff --git a/tests/modules/scheduler.test.js b/tests/modules/scheduler.test.js index a4835dd1..8fcd17c3 100644 --- a/tests/modules/scheduler.test.js +++ b/tests/modules/scheduler.test.js @@ -1,6 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/db.js', () => ({ getPool: vi.fn(), })); diff --git a/tests/modules/starboard.test.js b/tests/modules/starboard.test.js index cce58d1d..d64cfe0c 100644 --- a/tests/modules/starboard.test.js +++ b/tests/modules/starboard.test.js @@ -2,6 +2,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // ── Mocks ─────────────────────────────────────────────────────────────────── + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), diff --git a/tests/modules/triage-respond.test.js b/tests/modules/triage-respond.test.js index 6cce6ca9..3e7a15c7 100644 --- a/tests/modules/triage-respond.test.js +++ b/tests/modules/triage-respond.test.js @@ -7,6 +7,24 @@ import { } from '../../src/modules/triage-respond.js'; // Mock dependencies + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/logger.js', () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/tests/modules/triage.coverage.test.js b/tests/modules/triage.coverage.test.js index 0dbd0ff0..e70436ee 100644 --- a/tests/modules/triage.coverage.test.js +++ b/tests/modules/triage.coverage.test.js @@ -13,6 +13,24 @@ const mockResponderStart = vi.fn().mockResolvedValue(undefined); const mockClassifierClose = vi.fn(); const mockResponderClose = vi.fn(); + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/modules/cli-process.js', () => { class CLIProcessError extends Error { constructor(message, reason, meta = {}) { diff --git a/tests/modules/triage.test.js b/tests/modules/triage.test.js index 947cd8ee..5f83dadd 100644 --- a/tests/modules/triage.test.js +++ b/tests/modules/triage.test.js @@ -10,6 +10,24 @@ const mockResponderStart = vi.fn().mockResolvedValue(undefined); const mockClassifierClose = vi.fn(); const mockResponderClose = vi.fn(); + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/modules/cli-process.js', () => { class CLIProcessError extends Error { constructor(message, reason, meta = {}) { diff --git a/tests/modules/welcome.coverage.test.js b/tests/modules/welcome.coverage.test.js index ccf4d09a..daa93f16 100644 --- a/tests/modules/welcome.coverage.test.js +++ b/tests/modules/welcome.coverage.test.js @@ -4,6 +4,24 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), @@ -144,16 +162,15 @@ describe('welcome module coverage', () => { expect(args.content).toContain('<@user1>'); }); - it('logs error when channel fetch throws', async () => { + it('returns early without error when channel fetch returns null', async () => { + // fetchChannelCached returns null on error instead of throwing const client = makeClient(vi.fn(), new Error('Unknown Channel')); const member = makeMember(); const config = { welcome: { enabled: true, channelId: 'ch1' } }; await sendWelcomeMessage(member, client, config); - expect(logError).toHaveBeenCalledWith( - 'Welcome error', - expect.objectContaining({ error: 'Unknown Channel' }), - ); + // No error logged — fetchChannelCached handles the error gracefully + expect(logError).not.toHaveBeenCalled(); }); }); diff --git a/tests/modules/welcome.test.js b/tests/modules/welcome.test.js index a0bbe66f..7583c610 100644 --- a/tests/modules/welcome.test.js +++ b/tests/modules/welcome.test.js @@ -1,6 +1,24 @@ import { describe, expect, it, vi } from 'vitest'; // Mock logger + +// Mock discordCache to pass through to the underlying client.channels.fetch +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { + if (!channelId) return null; + const cached = client.channels?.cache?.get?.(channelId); + if (cached) return cached; + if (client.channels?.fetch) { + return client.channels.fetch(channelId).catch(() => null); + } + return null; + }), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), diff --git a/tests/redis.test.js b/tests/redis.test.js new file mode 100644 index 00000000..e82c5e7b --- /dev/null +++ b/tests/redis.test.js @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger to avoid console noise +vi.mock('../src/logger.js', () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +// Mock ioredis +vi.mock('ioredis', () => { + const RedisMock = vi.fn().mockImplementation(() => ({ + on: vi.fn(), + quit: vi.fn().mockResolvedValue('OK'), + })); + return { default: RedisMock }; +}); + +describe('redis.js', () => { + let redis; + + beforeEach(async () => { + vi.resetModules(); + delete process.env.REDIS_URL; + redis = await import('../src/redis.js'); + redis._resetRedis(); + }); + + afterEach(() => { + redis._resetRedis(); + delete process.env.REDIS_URL; + }); + + describe('initRedis()', () => { + it('returns null when REDIS_URL is not set', () => { + const client = redis.initRedis(); + expect(client).toBeNull(); + }); + + it('creates client when REDIS_URL is set', async () => { + vi.resetModules(); + process.env.REDIS_URL = 'redis://localhost:6379'; + + // Re-mock ioredis with a proper class implementation + const mockClient = { + on: vi.fn(), + quit: vi.fn().mockResolvedValue('OK'), + }; + vi.doMock('ioredis', () => ({ + default: vi.fn().mockImplementation(function () { return mockClient; }), + })); + + const freshRedis = await import('../src/redis.js'); + freshRedis._resetRedis(); + + const client = freshRedis.initRedis(); + expect(client).not.toBeNull(); + expect(mockClient.on).toHaveBeenCalled(); + }); + + it('returns same client on subsequent calls (singleton)', async () => { + vi.resetModules(); + process.env.REDIS_URL = 'redis://localhost:6379'; + const freshRedis = await import('../src/redis.js'); + freshRedis._resetRedis(); + + const client1 = freshRedis.initRedis(); + const client2 = freshRedis.initRedis(); + expect(client1).toBe(client2); + }); + }); + + describe('getRedis()', () => { + it('initializes on first call if not already initialized', () => { + const client = redis.getRedis(); + // No REDIS_URL set, should return null + expect(client).toBeNull(); + }); + }); + + describe('isRedisReady()', () => { + it('returns false when not connected', () => { + expect(redis.isRedisReady()).toBe(false); + }); + }); + + describe('getRedisStats()', () => { + it('returns initial stats', () => { + const stats = redis.getRedisStats(); + expect(stats).toEqual({ + connected: false, + hits: 0, + misses: 0, + errors: 0, + connectedAt: null, + hitRate: 'N/A', + }); + }); + + it('calculates hit rate correctly', () => { + redis.recordHit(); + redis.recordHit(); + redis.recordHit(); + redis.recordMiss(); + + const stats = redis.getRedisStats(); + expect(stats.hits).toBe(3); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe('75.0%'); + }); + }); + + describe('closeRedisClient()', () => { + it('is safe to call when no client exists', async () => { + await expect(redis.closeRedisClient()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/utils/cache.test.js b/tests/utils/cache.test.js new file mode 100644 index 00000000..050460a2 --- /dev/null +++ b/tests/utils/cache.test.js @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +// Mock redis module — default: no Redis +vi.mock('../../src/redis.js', () => ({ + getRedis: vi.fn().mockReturnValue(null), + recordHit: vi.fn(), + recordMiss: vi.fn(), + recordError: vi.fn(), +})); + +describe('cache.js — in-memory fallback', () => { + let cache; + + beforeEach(async () => { + vi.resetModules(); + cache = await import('../../src/utils/cache.js'); + cache._resetCache(); + }); + + afterEach(() => { + cache._resetCache(); + }); + + describe('cacheGet / cacheSet', () => { + it('returns null for missing keys', async () => { + const result = await cache.cacheGet('nonexistent'); + expect(result).toBeNull(); + }); + + it('stores and retrieves values', async () => { + await cache.cacheSet('test:key', { hello: 'world' }, 60); + const result = await cache.cacheGet('test:key'); + expect(result).toEqual({ hello: 'world' }); + }); + + it('respects TTL expiration', async () => { + vi.useFakeTimers(); + await cache.cacheSet('test:ttl', 'value', 1); // 1 second TTL + + // Still valid + let result = await cache.cacheGet('test:ttl'); + expect(result).toBe('value'); + + // Advance past TTL + vi.advanceTimersByTime(1500); + result = await cache.cacheGet('test:ttl'); + expect(result).toBeNull(); + + vi.useRealTimers(); + }); + + it('handles string values', async () => { + await cache.cacheSet('test:string', 'hello', 60); + const result = await cache.cacheGet('test:string'); + expect(result).toBe('hello'); + }); + + it('handles numeric values', async () => { + await cache.cacheSet('test:num', 42, 60); + const result = await cache.cacheGet('test:num'); + expect(result).toBe(42); + }); + + it('handles array values', async () => { + await cache.cacheSet('test:arr', [1, 2, 3], 60); + const result = await cache.cacheGet('test:arr'); + expect(result).toEqual([1, 2, 3]); + }); + }); + + describe('cacheDel', () => { + it('removes a key', async () => { + await cache.cacheSet('test:del', 'value', 60); + await cache.cacheDel('test:del'); + const result = await cache.cacheGet('test:del'); + expect(result).toBeNull(); + }); + + it('is safe for non-existent keys', async () => { + await expect(cache.cacheDel('nonexistent')).resolves.toBeUndefined(); + }); + }); + + describe('cacheDelPattern', () => { + it('deletes keys matching pattern', async () => { + await cache.cacheSet('prefix:a', 1, 60); + await cache.cacheSet('prefix:b', 2, 60); + await cache.cacheSet('other:c', 3, 60); + + const deleted = await cache.cacheDelPattern('prefix:*'); + expect(deleted).toBe(2); + + expect(await cache.cacheGet('prefix:a')).toBeNull(); + expect(await cache.cacheGet('prefix:b')).toBeNull(); + expect(await cache.cacheGet('other:c')).toBe(3); + }); + }); + + describe('cacheGetOrSet', () => { + it('returns cached value without calling factory', async () => { + await cache.cacheSet('test:cached', 'existing', 60); + const factory = vi.fn().mockResolvedValue('new'); + + const result = await cache.cacheGetOrSet('test:cached', factory, 60); + expect(result).toBe('existing'); + expect(factory).not.toHaveBeenCalled(); + }); + + it('calls factory and caches on miss', async () => { + const factory = vi.fn().mockResolvedValue('computed'); + + const result = await cache.cacheGetOrSet('test:miss', factory, 60); + expect(result).toBe('computed'); + expect(factory).toHaveBeenCalledOnce(); + + // Verify it was cached + const cached = await cache.cacheGet('test:miss'); + expect(cached).toBe('computed'); + }); + + it('does not cache null/undefined factory results', async () => { + const factory = vi.fn().mockResolvedValue(null); + + const result = await cache.cacheGetOrSet('test:null', factory, 60); + expect(result).toBeNull(); + + const cached = await cache.cacheGet('test:null'); + expect(cached).toBeNull(); + }); + }); + + describe('getMemoryCacheSize', () => { + it('returns current size', async () => { + expect(cache.getMemoryCacheSize()).toBe(0); + await cache.cacheSet('a', 1, 60); + await cache.cacheSet('b', 2, 60); + expect(cache.getMemoryCacheSize()).toBe(2); + }); + }); + + describe('cacheClear', () => { + it('removes all entries', async () => { + await cache.cacheSet('a', 1, 60); + await cache.cacheSet('b', 2, 60); + await cache.cacheClear(); + expect(cache.getMemoryCacheSize()).toBe(0); + }); + }); +}); + +describe('cache.js — with Redis', () => { + let cache; + let redisMock; + let getRedis; + + beforeEach(async () => { + vi.resetModules(); + + redisMock = { + get: vi.fn().mockResolvedValue(null), + setex: vi.fn().mockResolvedValue('OK'), + del: vi.fn().mockResolvedValue(1), + scan: vi.fn().mockResolvedValue(['0', []]), + flushdb: vi.fn().mockResolvedValue('OK'), + }; + + const redisMod = await import('../../src/redis.js'); + getRedis = redisMod.getRedis; + getRedis.mockReturnValue(redisMock); + + cache = await import('../../src/utils/cache.js'); + cache._resetCache(); + }); + + afterEach(() => { + cache._resetCache(); + }); + + it('reads from Redis when available', async () => { + redisMock.get.mockResolvedValue(JSON.stringify({ data: 'from-redis' })); + + const result = await cache.cacheGet('test:key'); + expect(result).toEqual({ data: 'from-redis' }); + expect(redisMock.get).toHaveBeenCalledWith('test:key'); + }); + + it('writes to Redis when available', async () => { + await cache.cacheSet('test:key', { data: 'value' }, 120); + expect(redisMock.setex).toHaveBeenCalledWith( + 'test:key', + 120, + JSON.stringify({ data: 'value' }), + ); + }); + + it('deletes from Redis when available', async () => { + await cache.cacheDel('test:key'); + expect(redisMock.del).toHaveBeenCalledWith('test:key'); + }); + + it('falls back to memory on Redis error', async () => { + redisMock.get.mockRejectedValue(new Error('Connection refused')); + + // Should not throw + const result = await cache.cacheGet('test:key'); + expect(result).toBeNull(); + }); + + it('cacheDelPattern uses SCAN', async () => { + redisMock.scan + .mockResolvedValueOnce(['1', ['match:a', 'match:b']]) + .mockResolvedValueOnce(['0', ['match:c']]); + redisMock.del.mockResolvedValue(2); + + const deleted = await cache.cacheDelPattern('match:*'); + expect(deleted).toBe(3); // 2 keys from first scan + 1 from second + expect(redisMock.scan).toHaveBeenCalledTimes(2); + expect(redisMock.del).toHaveBeenCalledTimes(2); + }); +}); + +describe('TTL defaults', () => { + it('has expected default values', async () => { + vi.resetModules(); + const { TTL } = await import('../../src/utils/cache.js'); + + expect(TTL.CHANNELS).toBe(300); + expect(TTL.ROLES).toBe(300); + expect(TTL.MEMBERS).toBe(60); + expect(TTL.CONFIG).toBe(60); + expect(TTL.REPUTATION).toBe(60); + expect(TTL.LEADERBOARD).toBe(300); + expect(TTL.ANALYTICS).toBe(3600); + expect(TTL.SESSION).toBe(86400); + }); +}); diff --git a/tests/utils/discordCache.test.js b/tests/utils/discordCache.test.js new file mode 100644 index 00000000..7ebd2e9e --- /dev/null +++ b/tests/utils/discordCache.test.js @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +// Mock redis — no Redis in tests +vi.mock('../../src/redis.js', () => ({ + getRedis: vi.fn().mockReturnValue(null), + recordHit: vi.fn(), + recordMiss: vi.fn(), + recordError: vi.fn(), +})); + +describe('discordCache.js', () => { + let discordCache; + let cache; + + beforeEach(async () => { + vi.resetModules(); + cache = await import('../../src/utils/cache.js'); + discordCache = await import('../../src/utils/discordCache.js'); + cache._resetCache(); + }); + + afterEach(() => { + cache._resetCache(); + }); + + describe('fetchChannelCached', () => { + it('returns null for null channelId', async () => { + const client = { channels: { cache: new Map() } }; + const result = await discordCache.fetchChannelCached(client, null); + expect(result).toBeNull(); + }); + + it('returns from Discord.js cache if available', async () => { + const mockChannel = { id: '123', name: 'test', type: 0 }; + const client = { + channels: { + cache: new Map([['123', mockChannel]]), + }, + }; + + const result = await discordCache.fetchChannelCached(client, '123'); + expect(result).toBe(mockChannel); + }); + + it('fetches from API on cache miss and caches result', async () => { + const mockChannel = { id: '456', name: 'general', type: 0, guildId: '789' }; + const client = { + channels: { + cache: new Map(), + fetch: vi.fn().mockResolvedValue(mockChannel), + }, + }; + + const result = await discordCache.fetchChannelCached(client, '456'); + expect(result).toBe(mockChannel); + expect(client.channels.fetch).toHaveBeenCalledWith('456'); + }); + + it('returns null on API error', async () => { + const client = { + channels: { + cache: new Map(), + fetch: vi.fn().mockRejectedValue(new Error('Unknown channel')), + }, + }; + + const result = await discordCache.fetchChannelCached(client, '999'); + expect(result).toBeNull(); + }); + }); + + describe('fetchGuildChannelsCached', () => { + it('fetches and caches guild channels', async () => { + const channels = new Map([ + ['1', { id: '1', name: 'general', type: 0, position: 0, parentId: null }], + ['2', { id: '2', name: 'random', type: 0, position: 1, parentId: null }], + ]); + + const guild = { + id: 'guild1', + channels: { fetch: vi.fn().mockResolvedValue(channels) }, + }; + + const result = await discordCache.fetchGuildChannelsCached(guild); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('general'); + + // Second call should use cache + guild.channels.fetch.mockClear(); + const cached = await discordCache.fetchGuildChannelsCached(guild); + expect(cached).toHaveLength(2); + // fetch should NOT be called again (served from cache) + expect(guild.channels.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('fetchGuildRolesCached', () => { + it('fetches and caches guild roles', async () => { + const roles = new Map([ + ['1', { id: '1', name: '@everyone', color: 0, position: 0, permissions: { bitfield: 0n } }], + ['2', { id: '2', name: 'Admin', color: 0xff0000, position: 1, permissions: { bitfield: 8n } }], + ]); + + const guild = { + id: 'guild1', + roles: { fetch: vi.fn().mockResolvedValue(roles) }, + }; + + const result = await discordCache.fetchGuildRolesCached(guild); + expect(result).toHaveLength(2); + expect(result.find((r) => r.name === 'Admin')).toBeDefined(); + }); + }); + + describe('fetchMemberCached', () => { + it('returns null for null userId', async () => { + const guild = { members: { cache: new Map() } }; + const result = await discordCache.fetchMemberCached(guild, null); + expect(result).toBeNull(); + }); + + it('returns from Discord.js cache first', async () => { + const mockMember = { id: '123', displayName: 'Test' }; + const guild = { + id: 'guild1', + members: { + cache: new Map([['123', mockMember]]), + fetch: vi.fn(), + }, + }; + + const result = await discordCache.fetchMemberCached(guild, '123'); + expect(result).toBe(mockMember); + expect(guild.members.fetch).not.toHaveBeenCalled(); + }); + + it('returns null for unknown members (10007 error)', async () => { + const err = new Error('Unknown Member'); + err.code = 10007; + const guild = { + id: 'guild1', + members: { + cache: new Map(), + fetch: vi.fn().mockRejectedValue(err), + }, + }; + + const result = await discordCache.fetchMemberCached(guild, '999'); + expect(result).toBeNull(); + }); + }); + + describe('invalidateGuildCache', () => { + it('clears all cached data for a guild', async () => { + // Pre-populate cache + const channels = new Map([ + ['1', { id: '1', name: 'test', type: 0, position: 0, parentId: null }], + ]); + const guild = { + id: 'guild1', + channels: { fetch: vi.fn().mockResolvedValue(channels) }, + }; + + await discordCache.fetchGuildChannelsCached(guild); + + // Invalidate + await discordCache.invalidateGuildCache('guild1'); + + // Next fetch should hit API again + guild.channels.fetch.mockClear(); + await discordCache.fetchGuildChannelsCached(guild); + expect(guild.channels.fetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/utils/reputationCache.test.js b/tests/utils/reputationCache.test.js new file mode 100644 index 00000000..1ad9620d --- /dev/null +++ b/tests/utils/reputationCache.test.js @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +// Mock redis — no Redis in tests +vi.mock('../../src/redis.js', () => ({ + getRedis: vi.fn().mockReturnValue(null), + recordHit: vi.fn(), + recordMiss: vi.fn(), + recordError: vi.fn(), +})); + +describe('reputationCache.js', () => { + let repCache; + let cache; + + beforeEach(async () => { + vi.resetModules(); + cache = await import('../../src/utils/cache.js'); + repCache = await import('../../src/utils/reputationCache.js'); + cache._resetCache(); + }); + + afterEach(() => { + cache._resetCache(); + }); + + describe('getReputationCached / setReputationCache', () => { + it('returns null on miss', async () => { + const result = await repCache.getReputationCached('guild1', 'user1'); + expect(result).toBeNull(); + }); + + it('returns cached data after set', async () => { + const data = { xp: 100, level: 3, messages_count: 50 }; + await repCache.setReputationCache('guild1', 'user1', data); + + const result = await repCache.getReputationCached('guild1', 'user1'); + expect(result).toEqual(data); + }); + }); + + describe('invalidateReputationCache', () => { + it('clears user reputation and leaderboard cache', async () => { + await repCache.setReputationCache('guild1', 'user1', { xp: 100, level: 2 }); + + // Also set a leaderboard entry via raw cache + await cache.cacheSet('leaderboard:guild1', [{ userId: 'user1' }], 300); + await cache.cacheSet('rank:guild1:user1', { rank: 1 }, 60); + + await repCache.invalidateReputationCache('guild1', 'user1'); + + expect(await repCache.getReputationCached('guild1', 'user1')).toBeNull(); + expect(await cache.cacheGet('leaderboard:guild1')).toBeNull(); + expect(await cache.cacheGet('rank:guild1:user1')).toBeNull(); + }); + }); + + describe('getLeaderboardCached', () => { + it('calls factory on miss and caches result', async () => { + const leaderboard = [{ userId: 'u1', xp: 500 }, { userId: 'u2', xp: 300 }]; + const factory = vi.fn().mockResolvedValue(leaderboard); + + const result = await repCache.getLeaderboardCached('guild1', factory); + expect(result).toEqual(leaderboard); + expect(factory).toHaveBeenCalledOnce(); + + // Second call should use cache + factory.mockClear(); + const cached = await repCache.getLeaderboardCached('guild1', factory); + expect(cached).toEqual(leaderboard); + expect(factory).not.toHaveBeenCalled(); + }); + }); + + describe('getRankCached', () => { + it('calls factory on miss and caches result', async () => { + const rank = { rank: 5, xp: 200, level: 4 }; + const factory = vi.fn().mockResolvedValue(rank); + + const result = await repCache.getRankCached('guild1', 'user1', factory); + expect(result).toEqual(rank); + expect(factory).toHaveBeenCalledOnce(); + }); + }); + + describe('invalidateLeaderboard', () => { + it('clears leaderboard cache for guild', async () => { + await cache.cacheSet('leaderboard:guild1', [{ rank: 1 }], 300); + await repCache.invalidateLeaderboard('guild1'); + expect(await cache.cacheGet('leaderboard:guild1')).toBeNull(); + }); + }); +}); From a82a08ea52be3e9f859180f8b61b42729e2f1e0b Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:04:24 -0500 Subject: [PATCH 06/24] feat(redis): add distributed Redis-backed rate limiter - redisRateLimit.js: Sliding window rate limiting using Redis INCR/PTTL with automatic fallback to in-memory when Redis is unavailable - Atomic multi/exec pipeline for consistent counting - Same API contract as existing rateLimit.js (drop-in replacement) - 5 tests covering Redis path, fallback, 429 response, error recovery Part of #177 --- src/api/middleware/redisRateLimit.js | 77 +++++++++++ tests/api/middleware/redisRateLimit.test.js | 141 ++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 src/api/middleware/redisRateLimit.js create mode 100644 tests/api/middleware/redisRateLimit.test.js diff --git a/src/api/middleware/redisRateLimit.js b/src/api/middleware/redisRateLimit.js new file mode 100644 index 00000000..d627898c --- /dev/null +++ b/src/api/middleware/redisRateLimit.js @@ -0,0 +1,77 @@ +/** + * Redis-backed Rate Limiter + * Distributed rate limiting using Redis for multi-instance deployments. + * Falls back to the existing in-memory rate limiter when Redis is unavailable. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/177 + */ + +import { getRedis } from '../../redis.js'; +import { rateLimit as inMemoryRateLimit } from './rateLimit.js'; + +/** + * Creates Redis-backed rate limiting middleware using a sliding window counter. + * Automatically falls back to in-memory rate limiting if Redis is not available. + * + * @param {Object} [options] - Rate limiter configuration + * @param {number} [options.windowMs=900000] - Time window in milliseconds (default: 15 minutes) + * @param {number} [options.max=100] - Maximum requests per window per IP (default: 100) + * @param {string} [options.keyPrefix='rl'] - Redis key prefix + * @returns {import('express').RequestHandler & { destroy: () => void }} + */ +export function redisRateLimit({ windowMs = 15 * 60 * 1000, max = 100, keyPrefix = 'rl' } = {}) { + // Create in-memory fallback (always available) + const fallback = inMemoryRateLimit({ windowMs, max }); + + const middleware = async (req, res, next) => { + const redis = getRedis(); + + // Fall back to in-memory if Redis isn't available + if (!redis) { + return fallback(req, res, next); + } + + const ip = req.ip; + const windowSec = Math.ceil(windowMs / 1000); + const key = `${keyPrefix}:${ip}`; + + try { + // Atomic increment + TTL set via pipeline + const results = await redis + .multi() + .incr(key) + .pttl(key) + .exec(); + + const count = results[0][1]; // [err, value] tuples from multi + const pttl = results[1][1]; + + // Set TTL on first request (when key was just created with INCR) + if (pttl === -1) { + await redis.pexpire(key, windowMs); + } + + const resetAt = Date.now() + (pttl > 0 ? pttl : windowMs); + + // Set rate-limit headers + res.set('X-RateLimit-Limit', String(max)); + res.set('X-RateLimit-Remaining', String(Math.max(0, max - count))); + res.set('X-RateLimit-Reset', String(Math.ceil(resetAt / 1000))); + + if (count > max) { + const retryAfter = Math.ceil((pttl > 0 ? pttl : windowMs) / 1000); + res.set('Retry-After', String(retryAfter)); + return res.status(429).json({ error: 'Too many requests, please try again later' }); + } + + next(); + } catch { + // Redis error — fall back to in-memory + return fallback(req, res, next); + } + }; + + middleware.destroy = () => fallback.destroy(); + + return middleware; +} diff --git a/tests/api/middleware/redisRateLimit.test.js b/tests/api/middleware/redisRateLimit.test.js new file mode 100644 index 00000000..75f5ac87 --- /dev/null +++ b/tests/api/middleware/redisRateLimit.test.js @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../../src/logger.js', () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +// Mock redis +vi.mock('../../../src/redis.js', () => ({ + getRedis: vi.fn().mockReturnValue(null), +})); + +// Mock the in-memory rate limiter +vi.mock('../../../src/api/middleware/rateLimit.js', () => ({ + rateLimit: vi.fn().mockReturnValue( + Object.assign( + vi.fn().mockImplementation((_req, _res, next) => next()), + { destroy: vi.fn() }, + ), + ), +})); + +describe('redisRateLimit', () => { + let redisRateLimit; + let getRedis; + let rateLimit; + + beforeEach(async () => { + vi.resetModules(); + const redisMod = await import('../../../src/redis.js'); + getRedis = redisMod.getRedis; + + const rateLimitMod = await import('../../../src/api/middleware/rateLimit.js'); + rateLimit = rateLimitMod.rateLimit; + + const mod = await import('../../../src/api/middleware/redisRateLimit.js'); + redisRateLimit = mod.redisRateLimit; + }); + + function makeReq(ip = '127.0.0.1') { + return { ip }; + } + + function makeRes() { + const headers = {}; + return { + set: vi.fn((k, v) => { headers[k] = v; }), + status: vi.fn().mockReturnThis(), + json: vi.fn(), + _headers: headers, + }; + } + + it('falls back to in-memory when Redis is not available', async () => { + getRedis.mockReturnValue(null); + const middleware = redisRateLimit(); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + await middleware(req, res, next); + // Should have called the in-memory fallback + expect(next).toHaveBeenCalled(); + }); + + it('uses Redis when available', async () => { + const redisMock = { + multi: vi.fn().mockReturnThis(), + incr: vi.fn().mockReturnThis(), + pttl: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([ + [null, 1], // incr result + [null, -1], // pttl result (new key) + ]), + pexpire: vi.fn().mockResolvedValue(1), + }; + getRedis.mockReturnValue(redisMock); + + const middleware = redisRateLimit({ max: 10 }); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.set).toHaveBeenCalledWith('X-RateLimit-Limit', '10'); + expect(res.set).toHaveBeenCalledWith('X-RateLimit-Remaining', '9'); + expect(redisMock.pexpire).toHaveBeenCalled(); + }); + + it('returns 429 when limit exceeded', async () => { + const redisMock = { + multi: vi.fn().mockReturnThis(), + incr: vi.fn().mockReturnThis(), + pttl: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([ + [null, 101], // count exceeds max + [null, 60000], // 60s remaining + ]), + }; + getRedis.mockReturnValue(redisMock); + + const middleware = redisRateLimit({ max: 100 }); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ error: 'Too many requests, please try again later' }); + }); + + it('falls back to in-memory on Redis error', async () => { + const redisMock = { + multi: vi.fn().mockReturnThis(), + incr: vi.fn().mockReturnThis(), + pttl: vi.fn().mockReturnThis(), + exec: vi.fn().mockRejectedValue(new Error('Redis down')), + }; + getRedis.mockReturnValue(redisMock); + + const middleware = redisRateLimit(); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + await middleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it('destroy() cleans up fallback timer', () => { + const middleware = redisRateLimit(); + expect(() => middleware.destroy()).not.toThrow(); + }); +}); From 1e92e971dffe5c0221fcd928153a8070065a75c8 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:12:40 -0500 Subject: [PATCH 07/24] chore: add Redis service to Railway and Docker Compose - Add Redis service to railway.toml (free plan) - Add Redis container to docker-compose.yml with healthcheck - Add REDIS_URL env vars to bot and web services - Add redisdata volume for persistence --- docker-compose.yml | 23 ++++++++++++++++++++--- railway.toml | 5 +++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ff7bc7ce..20e9bf9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,6 @@ services: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: volvoxbot - # Host port mapping removed — something on the Docker Desktop VM holds :5432. - # Bot and web connect via internal network (db:5432). Use `docker compose exec db psql` for queries. volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -16,6 +14,19 @@ services: timeout: 5s retries: 5 + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + bot: build: context: . @@ -24,11 +35,14 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy env_file: - .env environment: DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/volvoxbot DATABASE_SSL: "false" + REDIS_URL: redis://redis:6379 DISABLE_PROMPT_CACHING: "1" web: @@ -39,6 +53,8 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy ports: - "3000:3000" env_file: @@ -46,8 +62,8 @@ services: environment: DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/volvoxbot DATABASE_SSL: "false" + REDIS_URL: redis://redis:6379 NEXTAUTH_URL: http://localhost:3000 - # BOT_API_URL: http://bot:3001 # Uncomment when bot HTTP API is implemented (Issue #29) profiles: - full @@ -68,3 +84,4 @@ services: volumes: pgdata: + redisdata: diff --git a/railway.toml b/railway.toml index 9a01da1f..9fe8da2d 100644 --- a/railway.toml +++ b/railway.toml @@ -7,3 +7,8 @@ dockerLayerCaching = false restartPolicyType = "ON_FAILURE" restartPolicyMaxRetries = 10 numReplicas = 1 + +[services.redis] +serviceName = "redis" +type = "redis" +plan = "free" From c6f09b6232b81ea74af8ebdd280953bee29957d2 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:25:15 -0500 Subject: [PATCH 08/24] fix: check pipeline command errors and remove unused windowSec multi().exec() returns [[err, value], ...] tuples. Now destructures and checks each command's error before using its value; falls back to in-memory rate limiter on pipeline failure. Also removes the unused windowSec variable. --- src/api/middleware/redisRateLimit.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/api/middleware/redisRateLimit.js b/src/api/middleware/redisRateLimit.js index d627898c..983f523c 100644 --- a/src/api/middleware/redisRateLimit.js +++ b/src/api/middleware/redisRateLimit.js @@ -32,7 +32,6 @@ export function redisRateLimit({ windowMs = 15 * 60 * 1000, max = 100, keyPrefix } const ip = req.ip; - const windowSec = Math.ceil(windowMs / 1000); const key = `${keyPrefix}:${ip}`; try { @@ -43,8 +42,14 @@ export function redisRateLimit({ windowMs = 15 * 60 * 1000, max = 100, keyPrefix .pttl(key) .exec(); - const count = results[0][1]; // [err, value] tuples from multi - const pttl = results[1][1]; + // multi().exec() returns [[err, value], ...] tuples — check each command + const [incrErr, count] = results[0]; + const [pttlErr, pttl] = results[1]; + + if (incrErr || pttlErr) { + // Pipeline command failed — fall back gracefully + return fallback(req, res, next); + } // Set TTL on first request (when key was just created with INCR) if (pttl === -1) { From 193aaa869016d10b0a95da1b638efc820cacd196 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:25:32 -0500 Subject: [PATCH 09/24] fix: remove unused cacheGet and cacheSet imports from community routes Only cacheGetOrSet and TTL are used; cacheGet and cacheSet were dead imports. --- src/api/routes/community.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/routes/community.js b/src/api/routes/community.js index 5e6a14c3..aaac8aba 100644 --- a/src/api/routes/community.js +++ b/src/api/routes/community.js @@ -9,7 +9,7 @@ import { Router } from 'express'; import { error as logError } from '../../logger.js'; import { getConfig } from '../../modules/config.js'; -import { cacheGet, cacheGetOrSet, cacheSet, TTL } from '../../utils/cache.js'; +import { cacheGetOrSet, TTL } from '../../utils/cache.js'; import { computeLevel } from '../../modules/reputation.js'; import { REPUTATION_DEFAULTS } from '../../modules/reputationDefaults.js'; import { rateLimit } from '../middleware/rateLimit.js'; From a8d573e8653bb4af1e99ab144201e8e0851727b1 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:25:57 -0500 Subject: [PATCH 10/24] fix: standardize leaderboard cache invalidation to use pattern delete reputationCache was deleting the base key 'leaderboard:${guildId}' but the paginated API caches under 'leaderboard:${guildId}:${page}:${limit}'. Now uses cacheDelPattern('leaderboard:${guildId}:*') consistently so all paginated leaderboard entries are properly invalidated. --- src/utils/reputationCache.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/reputationCache.js b/src/utils/reputationCache.js index b5d9002b..134aceb5 100644 --- a/src/utils/reputationCache.js +++ b/src/utils/reputationCache.js @@ -6,7 +6,7 @@ */ import { debug } from '../logger.js'; -import { cacheDel, cacheGet, cacheGetOrSet, cacheSet, TTL } from './cache.js'; +import { cacheDel, cacheDelPattern, cacheGet, cacheGetOrSet, cacheSet, TTL } from './cache.js'; /** * Get cached reputation data for a user. @@ -44,8 +44,8 @@ export async function setReputationCache(guildId, userId, data) { export async function invalidateReputationCache(guildId, userId) { await cacheDel(`reputation:${guildId}:${userId}`); await cacheDel(`rank:${guildId}:${userId}`); - // Also invalidate leaderboard since rankings may have changed - await cacheDel(`leaderboard:${guildId}`); + // Also invalidate all paginated leaderboard keys for this guild + await cacheDelPattern(`leaderboard:${guildId}:*`); } /** @@ -78,5 +78,5 @@ export async function getRankCached(guildId, userId, fetchFn) { * @returns {Promise} */ export async function invalidateLeaderboard(guildId) { - await cacheDel(`leaderboard:${guildId}`); + await cacheDelPattern(`leaderboard:${guildId}:*`); } From 4c68ec51418280a91510ab2a41908b41d44c7ca8 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:26:27 -0500 Subject: [PATCH 11/24] fix: repair corrupted JSDoc header in reviewHandler.js An import statement had leaked into the JSDoc block, creating invalid comment syntax. Cleaned up the header to be proper JSDoc. --- src/modules/reviewHandler.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/modules/reviewHandler.js b/src/modules/reviewHandler.js index 32ed9970..4f0caffd 100644 --- a/src/modules/reviewHandler.js +++ b/src/modules/reviewHandler.js @@ -1,9 +1,8 @@ /** * Review Handler Module * Business logic for review embed building, claim button interactions, and stale review cleanup. - * Kept separate from the slash command definition so the scheduler can import { fetchChannelCached } from '../utils/discordCache.js'; -import it without - * pulling in SlashCommandBuilder (which breaks index.test.js's discord.js mock). + * Kept separate from the slash command definition so the scheduler can + * import it without pulling in SlashCommandBuilder (which breaks index.test.js's discord.js mock). * * @see https://github.com/VolvoxLLC/volvox-bot/issues/49 */ From 7f7e798c2489dc3bd0a21e362d13ce078ab081de Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:27:01 -0500 Subject: [PATCH 12/24] fix: move cache invalidation after all reputation DB writes Cache was being invalidated BEFORE the level update write, allowing stale data to be re-cached in the gap. Now invalidates AFTER all reputation writes (XP upsert + level update) are complete. --- src/modules/reputation.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/modules/reputation.js b/src/modules/reputation.js index a5ce45a6..905d81f5 100644 --- a/src/modules/reputation.js +++ b/src/modules/reputation.js @@ -117,9 +117,6 @@ export async function handleXpGain(message) { // Set cooldown AFTER successful DB write (sweep interval handles eviction) cooldowns.set(key, now); - // Invalidate cached reputation/leaderboard data for this user - invalidateReputationCache(message.guild.id, message.author.id).catch(() => {}); - const { xp: newXp, level: currentLevel } = rows[0]; const thresholds = repCfg.levelThresholds; const newLevel = computeLevel(newXp, thresholds); @@ -193,4 +190,8 @@ export async function handleXpGain(message) { } } } + + // Invalidate cached reputation/leaderboard data AFTER all DB writes complete + // to prevent stale data from being re-cached in the gap between invalidation and write + invalidateReputationCache(message.guild.id, message.author.id).catch(() => {}); } From d0ace9aa8e1cdd4ab2d49ac53671ebc2dd5e66b7 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:27:20 -0500 Subject: [PATCH 13/24] fix: escape regex metacharacters in glob-to-regex conversion The in-memory fallback for cacheDelPattern converted glob patterns to regex without escaping special regex chars like '.', '+', etc. Now escapes all metacharacters before substituting '*' and '?'. --- src/utils/cache.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/cache.js b/src/utils/cache.js index 92e1f14e..52057f96 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -198,7 +198,9 @@ export async function cacheDelPattern(pattern) { } // In-memory fallback: convert glob to regex - const regex = new RegExp(`^${pattern.replace(/\*/g, '.*').replace(/\?/g, '.')}$`); + // Escape regex metacharacters first, then substitute glob wildcards + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`); for (const key of memoryCache.keys()) { if (regex.test(key)) { memoryCache.delete(key); From 6efff59399df97bc68a9010d9aaea70e0d96ef2e Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:27:37 -0500 Subject: [PATCH 14/24] fix: replace flushdb() with prefix-scoped SCAN+DEL in cacheClear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flushdb() wipes ALL Redis keys — dangerous in shared environments. Now scans and deletes only known app-prefixed keys (rl:*, reputation:*, rank:*, leaderboard:*, discord:*, config:*, session:*). --- src/utils/cache.js | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/utils/cache.js b/src/utils/cache.js index 52057f96..0428a227 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -230,8 +230,9 @@ export async function cacheGetOrSet(key, factory, ttlSeconds = 60) { } /** - * Clear all cache entries (both Redis namespace and in-memory). - * Use with caution — primarily for testing. + * Clear all app cache entries (both Redis and in-memory). + * Uses SCAN + DEL to remove only app-prefixed keys instead of + * flushdb(), which is dangerous in shared Redis environments. * * @returns {Promise} */ @@ -239,10 +240,24 @@ export async function cacheClear() { const redis = getRedis(); if (redis) { try { - await redis.flushdb(); + // Scan and delete all known app-prefixed keys instead of flushdb() + const prefixes = [ + 'rl:*', 'reputation:*', 'rank:*', 'leaderboard:*', + 'discord:*', 'config:*', 'session:*', + ]; + for (const pattern of prefixes) { + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + if (keys.length > 0) { + await redis.del(...keys); + } + } while (cursor !== '0'); + } } catch (err) { recordError(); - warn('Redis flush error', { error: err.message }); + warn('Redis cache clear error', { error: err.message }); } } memoryCache.clear(); From 6aac00fedd78b444269b6fa66d272a852085eb46 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:27:51 -0500 Subject: [PATCH 15/24] fix: close existing connection in _resetRedis to prevent leaks If client was non-null when _resetRedis() was called, the existing connection was silently abandoned. Now calls client.quit() (with disconnect() fallback) before resetting state. --- src/redis.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/redis.js b/src/redis.js index 4ea15e0f..bd29bc3f 100644 --- a/src/redis.js +++ b/src/redis.js @@ -173,9 +173,18 @@ export async function closeRedisClient() { /** * Reset internal state — for testing only. + * Closes any existing connection before resetting to prevent leaks. * @internal */ -export function _resetRedis() { +export async function _resetRedis() { + if (client) { + try { + await client.quit(); + } catch { + // Ignore errors during test cleanup + client.disconnect(); + } + } client = null; initialized = false; connected = false; From 196c84ec2150c3ccd46d2bdf58ef988694b15e2e Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:28:08 -0500 Subject: [PATCH 16/24] fix: return Redis-cached channel metadata on DJS cache miss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel metadata was stored in Redis but never returned when the DJS cache missed — the code only re-checked DJS. Now returns the cached {id, name, type, guildId} object directly, avoiding an unnecessary Discord API call. --- src/utils/discordCache.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/utils/discordCache.js b/src/utils/discordCache.js index c403f74f..20fb2f1c 100644 --- a/src/utils/discordCache.js +++ b/src/utils/discordCache.js @@ -30,11 +30,14 @@ export async function fetchChannelCached(client, channelId) { const cacheKey = `discord:channel:${channelId}`; const cached = await cacheGet(cacheKey); if (cached) { - // We can't reconstruct a full Channel object from cached data, - // but we can avoid the API call by checking if it appeared in DJS cache - // during the async gap + // Re-check DJS cache in case it was populated during the async gap const recheckDjs = client.channels.cache.get(channelId); if (recheckDjs) return recheckDjs; + + // Return the cached metadata directly — callers that only need + // id/name/type/guildId can use this without an API call + debug('Returning Redis-cached channel metadata', { channelId }); + return cached; } // Fetch from Discord API From fc6c67d3c9ec795f72854ab7460a3b365b7b118c Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:28:25 -0500 Subject: [PATCH 17/24] fix: replace dynamic import with static import for cacheDelPattern cacheDelPattern was dynamically imported via await import('./cache.js') but cache.js is already statically imported at the top. Added it to the existing static import instead. --- src/utils/discordCache.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils/discordCache.js b/src/utils/discordCache.js index 20fb2f1c..5d3c24cb 100644 --- a/src/utils/discordCache.js +++ b/src/utils/discordCache.js @@ -9,7 +9,7 @@ */ import { debug, warn } from '../logger.js'; -import { cacheGet, cacheSet, TTL } from './cache.js'; +import { cacheDelPattern, cacheGet, cacheSet, TTL } from './cache.js'; /** * Fetch a channel with caching. @@ -175,7 +175,6 @@ export async function fetchMemberCached(guild, userId) { * @returns {Promise} */ export async function invalidateGuildCache(guildId) { - const { cacheDelPattern } = await import('./cache.js'); await cacheDelPattern(`discord:guild:${guildId}:*`); debug('Invalidated guild cache', { guildId }); } From 648527e942dbc7b5e8834d2e2df03ff50b576a22 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:28:40 -0500 Subject: [PATCH 18/24] fix: wrap fake timers in try/finally to prevent timer leaks If an assertion failed before vi.useRealTimers() was called, fake timers would leak into subsequent tests. Wrapped in try/finally to ensure timers are always restored. --- tests/utils/cache.test.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/utils/cache.test.js b/tests/utils/cache.test.js index 050460a2..bf3b02df 100644 --- a/tests/utils/cache.test.js +++ b/tests/utils/cache.test.js @@ -43,18 +43,20 @@ describe('cache.js — in-memory fallback', () => { it('respects TTL expiration', async () => { vi.useFakeTimers(); - await cache.cacheSet('test:ttl', 'value', 1); // 1 second TTL - - // Still valid - let result = await cache.cacheGet('test:ttl'); - expect(result).toBe('value'); - - // Advance past TTL - vi.advanceTimersByTime(1500); - result = await cache.cacheGet('test:ttl'); - expect(result).toBeNull(); - - vi.useRealTimers(); + try { + await cache.cacheSet('test:ttl', 'value', 1); // 1 second TTL + + // Still valid + let result = await cache.cacheGet('test:ttl'); + expect(result).toBe('value'); + + // Advance past TTL + vi.advanceTimersByTime(1500); + result = await cache.cacheGet('test:ttl'); + expect(result).toBeNull(); + } finally { + vi.useRealTimers(); + } }); it('handles string values', async () => { From 3181c26e135980723a550598712fddccfee9b717 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:29:00 -0500 Subject: [PATCH 19/24] chore: remove flushdb mock from cache test (no longer called) --- tests/utils/cache.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/utils/cache.test.js b/tests/utils/cache.test.js index bf3b02df..6db261eb 100644 --- a/tests/utils/cache.test.js +++ b/tests/utils/cache.test.js @@ -171,7 +171,6 @@ describe('cache.js — with Redis', () => { setex: vi.fn().mockResolvedValue('OK'), del: vi.fn().mockResolvedValue(1), scan: vi.fn().mockResolvedValue(['0', []]), - flushdb: vi.fn().mockResolvedValue('OK'), }; const redisMod = await import('../../src/redis.js'); From 1f0eae5207f5798b922feea8045abfb4b17329b9 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:29:48 -0500 Subject: [PATCH 20/24] fix: update reputationCache tests for paginated leaderboard keys Tests were using the old base key 'leaderboard:${guildId}' format. Updated to use paginated keys matching the new pattern-based invalidation. --- tests/utils/reputationCache.test.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/utils/reputationCache.test.js b/tests/utils/reputationCache.test.js index 1ad9620d..672b59d1 100644 --- a/tests/utils/reputationCache.test.js +++ b/tests/utils/reputationCache.test.js @@ -50,14 +50,14 @@ describe('reputationCache.js', () => { it('clears user reputation and leaderboard cache', async () => { await repCache.setReputationCache('guild1', 'user1', { xp: 100, level: 2 }); - // Also set a leaderboard entry via raw cache - await cache.cacheSet('leaderboard:guild1', [{ userId: 'user1' }], 300); + // Set paginated leaderboard entries via raw cache + await cache.cacheSet('leaderboard:guild1:1:25', [{ userId: 'user1' }], 300); await cache.cacheSet('rank:guild1:user1', { rank: 1 }, 60); await repCache.invalidateReputationCache('guild1', 'user1'); expect(await repCache.getReputationCached('guild1', 'user1')).toBeNull(); - expect(await cache.cacheGet('leaderboard:guild1')).toBeNull(); + expect(await cache.cacheGet('leaderboard:guild1:1:25')).toBeNull(); expect(await cache.cacheGet('rank:guild1:user1')).toBeNull(); }); }); @@ -91,10 +91,12 @@ describe('reputationCache.js', () => { }); describe('invalidateLeaderboard', () => { - it('clears leaderboard cache for guild', async () => { - await cache.cacheSet('leaderboard:guild1', [{ rank: 1 }], 300); + it('clears all paginated leaderboard cache keys for guild', async () => { + await cache.cacheSet('leaderboard:guild1:1:25', [{ rank: 1 }], 300); + await cache.cacheSet('leaderboard:guild1:2:25', [{ rank: 26 }], 300); await repCache.invalidateLeaderboard('guild1'); - expect(await cache.cacheGet('leaderboard:guild1')).toBeNull(); + expect(await cache.cacheGet('leaderboard:guild1:1:25')).toBeNull(); + expect(await cache.cacheGet('leaderboard:guild1:2:25')).toBeNull(); }); }); }); From 60862cea3e3d4ee9b17eed14739e818388849f91 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:42:50 -0500 Subject: [PATCH 21/24] fix: correct leaderboard cache key pattern and fetchChannelCached return type - config-listeners: change leaderboard invalidation pattern from `leaderboard:${guildId}:*` to `leaderboard:${guildId}*` so it matches the actual stored key format (no colon suffix) - discordCache: fetchChannelCached no longer returns a plain metadata object when a Redis cache hit is found. Cached data is used only for the existence recheck; function always falls through to Discord API fetch so callers receive a real Discord.js Channel (or null) Fixes review threads PRRT_kwDORICdSM5xceQX, PRRT_kwDORICdSM5xchrZ --- src/config-listeners.js | 2 +- src/utils/discordCache.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config-listeners.js b/src/config-listeners.js index bea6ac30..28437d3f 100644 --- a/src/config-listeners.js +++ b/src/config-listeners.js @@ -102,7 +102,7 @@ export function registerConfigListeners({ dbPool, config }) { }); onConfigChange('reputation.*', async (_newValue, _oldValue, path, guildId) => { if (guildId && guildId !== 'global') { - await cacheDelPattern(`leaderboard:${guildId}:*`).catch(() => {}); + await cacheDelPattern(`leaderboard:${guildId}*`).catch(() => {}); await cacheDelPattern(`reputation:${guildId}:*`).catch(() => {}); } }); diff --git a/src/utils/discordCache.js b/src/utils/discordCache.js index 5d3c24cb..8cbd2804 100644 --- a/src/utils/discordCache.js +++ b/src/utils/discordCache.js @@ -34,10 +34,10 @@ export async function fetchChannelCached(client, channelId) { const recheckDjs = client.channels.cache.get(channelId); if (recheckDjs) return recheckDjs; - // Return the cached metadata directly — callers that only need - // id/name/type/guildId can use this without an API call - debug('Returning Redis-cached channel metadata', { channelId }); - return cached; + // Cached data is metadata only — not a real Discord.js Channel object. + // Callers (safeSend, channel.messages.fetch, etc.) need a real Channel, + // so we fall through to the API fetch even on a cache hit. + debug('Redis cache hit for channel metadata; fetching real channel from API', { channelId }); } // Fetch from Discord API From 5c0cbd5e5cbe56e94d6b4a4b64e260de045ee846 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:43:00 -0500 Subject: [PATCH 22/24] fix: make _resetRedisClient async so callers can await full reset _resetRedisClient() was calling the async _resetRedis() without await, meaning callers had no way to wait for the reset to complete, leading to potential in-flight connection races in tests and shutdown sequences. Fixes review thread PRRT_kwDORICdSM5xchrL --- src/api/utils/redisClient.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/utils/redisClient.js b/src/api/utils/redisClient.js index 256b193b..1ad2b93f 100644 --- a/src/api/utils/redisClient.js +++ b/src/api/utils/redisClient.js @@ -33,6 +33,6 @@ export async function closeRedis() { * Reset internal state — for testing only. * @internal */ -export function _resetRedisClient() { - _resetRedis(); +export async function _resetRedisClient() { + await _resetRedis(); } From e98ae3a4f0035aa3f4a184381b9ee9ef7d64c396 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 20:43:20 -0500 Subject: [PATCH 23/24] test: await _resetRedis() in hooks; assert new cache-invalidation listener keys redis.test.js: - Make beforeEach/afterEach async and await _resetRedis() to prevent cross-test flakiness from in-flight connections config-listeners.test.js: - Add explicit toContain assertions for welcome.*, starboard.*, and reputation.* so the test will fail if a cache-invalidation listener is removed Fixes review threads PRRT_kwDORICdSM5xchrQ, PRRT_kwDORICdSM5xchrU --- tests/config-listeners.test.js | 3 +++ tests/redis.test.js | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/config-listeners.test.js b/tests/config-listeners.test.js index af80c9be..c2e56b68 100644 --- a/tests/config-listeners.test.js +++ b/tests/config-listeners.test.js @@ -91,6 +91,9 @@ vi.mock('../src/utils/cache.js', () => ({ expect(registeredKeys).toContain('ai.*'); expect(registeredKeys).toContain('spam.*'); expect(registeredKeys).toContain('moderation.*'); + expect(registeredKeys).toContain('welcome.*'); + expect(registeredKeys).toContain('starboard.*'); + expect(registeredKeys).toContain('reputation.*'); }); it('registers exactly 11 listeners', () => { diff --git a/tests/redis.test.js b/tests/redis.test.js index e82c5e7b..2a8c7f0c 100644 --- a/tests/redis.test.js +++ b/tests/redis.test.js @@ -24,11 +24,11 @@ describe('redis.js', () => { vi.resetModules(); delete process.env.REDIS_URL; redis = await import('../src/redis.js'); - redis._resetRedis(); + await redis._resetRedis(); }); - afterEach(() => { - redis._resetRedis(); + afterEach(async () => { + await redis._resetRedis(); delete process.env.REDIS_URL; }); From 600b870b916eea3db82ba3f4bbe419fbe5f874e8 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:54:03 -0500 Subject: [PATCH 24/24] feat(redis): wire distributed rate limiter and extend cache to commands - Switch server.js and community.js from in-memory rateLimit to redisRateLimit for distributed rate limiting across bot instances - Wire /rank command to use getReputationCached + setReputationCache + getRankCached, avoiding redundant DB queries on hot command paths - Wire /leaderboard command to use getLeaderboardCached (TTL cached) - Extend fetchChannelCached to commands/welcome.js, commands/review.js, modules/welcomeOnboarding.js, and modules/reviewHandler.js - Update tests: mock reputationCache in leaderboard/rank tests to isolate cache layer; mock discordCache in review/welcomeOnboarding tests; restore ioredis constructor mock syntax broken by biome formatter Closes #177 --- src/api/middleware/redisRateLimit.js | 6 +-- src/api/routes/community.js | 46 ++++++++++++--------- src/api/server.js | 6 +-- src/api/utils/redisClient.js | 2 +- src/commands/leaderboard.js | 20 +++++---- src/commands/rank.js | 45 +++++++++++++------- src/commands/review.js | 9 ++-- src/commands/welcome.js | 12 +++--- src/config-listeners.js | 6 +-- src/index.js | 4 +- src/modules/challengeScheduler.js | 2 +- src/modules/githubFeed.js | 2 +- src/modules/linkFilter.js | 2 +- src/modules/moderation.js | 2 +- src/modules/pollHandler.js | 2 +- src/modules/rateLimit.js | 2 +- src/modules/reminderHandler.js | 2 +- src/modules/reputation.js | 2 +- src/modules/reviewHandler.js | 5 ++- src/modules/scheduler.js | 2 +- src/modules/starboard.js | 2 +- src/modules/triage-respond.js | 2 +- src/modules/welcome.js | 2 +- src/modules/welcomeOnboarding.js | 7 ++-- src/redis.js | 2 +- src/utils/cache.js | 9 +++- src/utils/discordCache.js | 30 +++++++++----- tests/api/middleware/redisRateLimit.test.js | 10 +++-- tests/commands/leaderboard.test.js | 4 ++ tests/commands/rank.test.js | 6 +++ tests/commands/review.test.js | 10 +++++ tests/config-listeners.test.js | 8 ++-- tests/modules/challengeScheduler.test.js | 1 - tests/modules/rateLimit.coverage.test.js | 1 - tests/modules/reminderHandler.test.js | 1 - tests/modules/starboard.test.js | 1 - tests/modules/triage.coverage.test.js | 1 - tests/modules/triage.test.js | 1 - tests/modules/welcome.coverage.test.js | 1 - tests/modules/welcomeOnboarding.test.js | 18 ++++++++ tests/redis.test.js | 5 ++- tests/utils/discordCache.test.js | 5 ++- tests/utils/reputationCache.test.js | 5 ++- 43 files changed, 194 insertions(+), 117 deletions(-) diff --git a/src/api/middleware/redisRateLimit.js b/src/api/middleware/redisRateLimit.js index 983f523c..846c8de3 100644 --- a/src/api/middleware/redisRateLimit.js +++ b/src/api/middleware/redisRateLimit.js @@ -36,11 +36,7 @@ export function redisRateLimit({ windowMs = 15 * 60 * 1000, max = 100, keyPrefix try { // Atomic increment + TTL set via pipeline - const results = await redis - .multi() - .incr(key) - .pttl(key) - .exec(); + const results = await redis.multi().incr(key).pttl(key).exec(); // multi().exec() returns [[err, value], ...] tuples — check each command const [incrErr, count] = results[0]; diff --git a/src/api/routes/community.js b/src/api/routes/community.js index aaac8aba..68a22a33 100644 --- a/src/api/routes/community.js +++ b/src/api/routes/community.js @@ -9,15 +9,19 @@ import { Router } from 'express'; import { error as logError } from '../../logger.js'; import { getConfig } from '../../modules/config.js'; -import { cacheGetOrSet, TTL } from '../../utils/cache.js'; import { computeLevel } from '../../modules/reputation.js'; import { REPUTATION_DEFAULTS } from '../../modules/reputationDefaults.js'; -import { rateLimit } from '../middleware/rateLimit.js'; +import { cacheGetOrSet, TTL } from '../../utils/cache.js'; +import { redisRateLimit } from '../middleware/redisRateLimit.js'; const router = Router(); /** Aggressive rate limiter for public endpoints: 30 req/min per IP */ -const communityRateLimit = rateLimit({ windowMs: 60 * 1000, max: 30 }); +const communityRateLimit = redisRateLimit({ + windowMs: 60 * 1000, + max: 30, + keyPrefix: 'rl:community', +}); router.use(communityRateLimit); /** @@ -145,30 +149,34 @@ router.get('/:guildId/leaderboard', async (req, res) => { // Cache leaderboard DB results per guild+page (most expensive query) const cacheKey = `leaderboard:${guildId}:${page}:${limit}`; - const dbResult = await cacheGetOrSet(cacheKey, async () => { - const [countResult, membersResult] = await Promise.all([ - pool.query( - `SELECT COUNT(*)::int AS total + const dbResult = await cacheGetOrSet( + cacheKey, + async () => { + const [countResult, membersResult] = await Promise.all([ + pool.query( + `SELECT COUNT(*)::int AS total FROM user_stats us INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id WHERE us.guild_id = $1 AND us.public_profile = TRUE`, - [guildId], - ), - pool.query( - `SELECT us.user_id, r.xp, r.level + [guildId], + ), + pool.query( + `SELECT us.user_id, r.xp, r.level FROM user_stats us INNER JOIN reputation r ON r.guild_id = us.guild_id AND r.user_id = us.user_id WHERE us.guild_id = $1 AND us.public_profile = TRUE ORDER BY r.xp DESC LIMIT $2 OFFSET $3`, - [guildId, limit, offset], - ), - ]); - return { - total: countResult.rows[0]?.total ?? 0, - rows: membersResult.rows, - }; - }, TTL.LEADERBOARD); + [guildId, limit, offset], + ), + ]); + return { + total: countResult.rows[0]?.total ?? 0, + rows: membersResult.rows, + }; + }, + TTL.LEADERBOARD, + ); const { total, rows: memberRows } = dbResult; const { client } = req.app.locals; diff --git a/src/api/server.js b/src/api/server.js index 39049a4b..dd0ffddf 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -6,7 +6,7 @@ import express from 'express'; import { error, info, warn } from '../logger.js'; import apiRouter from './index.js'; -import { rateLimit } from './middleware/rateLimit.js'; +import { redisRateLimit } from './middleware/redisRateLimit.js'; import { stopAuthCleanup } from './routes/auth.js'; import { swaggerSpec } from './swagger.js'; import { stopGuildCacheCleanup } from './utils/discordApi.js'; @@ -15,7 +15,7 @@ import { setupLogStream, stopLogStream } from './ws/logStream.js'; /** @type {import('node:http').Server | null} */ let server = null; -/** @type {ReturnType | null} */ +/** @type {ReturnType | null} */ let rateLimiter = null; /** @@ -62,7 +62,7 @@ export function createApp(client, dbPool) { rateLimiter.destroy(); rateLimiter = null; } - rateLimiter = rateLimit(); + rateLimiter = redisRateLimit(); app.use(rateLimiter); // Raw OpenAPI spec (JSON) — public for Mintlify diff --git a/src/api/utils/redisClient.js b/src/api/utils/redisClient.js index 1ad2b93f..d41bec0e 100644 --- a/src/api/utils/redisClient.js +++ b/src/api/utils/redisClient.js @@ -8,7 +8,7 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/177 */ -import { closeRedisClient, getRedis, _resetRedis } from '../../redis.js'; +import { _resetRedis, closeRedisClient, getRedis } from '../../redis.js'; /** * Return the ioredis client. diff --git a/src/commands/leaderboard.js b/src/commands/leaderboard.js index 3d6b924b..c43a8850 100644 --- a/src/commands/leaderboard.js +++ b/src/commands/leaderboard.js @@ -9,6 +9,7 @@ import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; +import { getLeaderboardCached } from '../utils/reputationCache.js'; import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() @@ -30,14 +31,17 @@ export async function execute(interaction) { try { const pool = getPool(); - const { rows } = await pool.query( - `SELECT user_id, xp, level - FROM reputation - WHERE guild_id = $1 - ORDER BY xp DESC - LIMIT 10`, - [interaction.guildId], - ); + const rows = await getLeaderboardCached(interaction.guildId, async () => { + const result = await pool.query( + `SELECT user_id, xp, level + FROM reputation + WHERE guild_id = $1 + ORDER BY xp DESC + LIMIT 10`, + [interaction.guildId], + ); + return result.rows; + }); if (rows.length === 0) { await safeEditReply(interaction, { diff --git a/src/commands/rank.js b/src/commands/rank.js index 25bd2b01..b86157b1 100644 --- a/src/commands/rank.js +++ b/src/commands/rank.js @@ -11,6 +11,11 @@ import { error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { buildProgressBar, computeLevel } from '../modules/reputation.js'; import { REPUTATION_DEFAULTS } from '../modules/reputationDefaults.js'; +import { + getRankCached, + getReputationCached, + setReputationCache, +} from '../utils/reputationCache.js'; import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() @@ -43,15 +48,23 @@ export async function execute(interaction) { const repCfg = { ...REPUTATION_DEFAULTS, ...cfg.reputation }; const thresholds = repCfg.levelThresholds; - // Fetch reputation row - const { rows } = await pool.query( - 'SELECT xp, level, messages_count FROM reputation WHERE guild_id = $1 AND user_id = $2', - [interaction.guildId, target.id], - ); + // Fetch reputation row (cached) + const cachedRep = await getReputationCached(interaction.guildId, target.id); + let repRow = cachedRep; + if (!repRow) { + const { rows } = await pool.query( + 'SELECT xp, level, messages_count FROM reputation WHERE guild_id = $1 AND user_id = $2', + [interaction.guildId, target.id], + ); + repRow = rows[0] ?? null; + if (repRow) { + await setReputationCache(interaction.guildId, target.id, repRow); + } + } - const xp = rows[0]?.xp ?? 0; + const xp = repRow?.xp ?? 0; const level = computeLevel(xp, thresholds); - const messagesCount = rows[0]?.messages_count ?? 0; + const messagesCount = repRow?.messages_count ?? 0; // XP within current level and needed for next const currentThreshold = level > 0 ? thresholds[level - 1] : 0; @@ -62,14 +75,16 @@ export async function execute(interaction) { const progressBar = nextThreshold !== null ? buildProgressBar(xpInLevel, xpNeeded) : `${'▓'.repeat(10)} MAX`; - // Rank position in guild - const rankRow = await pool.query( - `SELECT COUNT(*) + 1 AS rank - FROM reputation - WHERE guild_id = $1 AND xp > $2`, - [interaction.guildId, xp], - ); - const rank = Number(rankRow.rows[0]?.rank ?? 1); + // Rank position in guild (cached) + const rank = await getRankCached(interaction.guildId, target.id, async () => { + const rankRow = await pool.query( + `SELECT COUNT(*) + 1 AS rank + FROM reputation + WHERE guild_id = $1 AND xp > $2`, + [interaction.guildId, xp], + ); + return { rank: Number(rankRow.rows[0]?.rank ?? 1) }; + }).then((r) => r?.rank ?? 1); const levelLabel = `Level ${level}`; const xpLabel = nextThreshold !== null ? `${xp} / ${nextThreshold} XP` : `${xp} XP (Max Level)`; diff --git a/src/commands/review.js b/src/commands/review.js index 6ac18a42..1b414029 100644 --- a/src/commands/review.js +++ b/src/commands/review.js @@ -15,6 +15,7 @@ import { STATUS_LABELS, updateReviewMessage, } from '../modules/reviewHandler.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() @@ -159,10 +160,10 @@ async function handleRequest(interaction, pool, guildConfig) { let targetChannel = interaction.channel; if (reviewChannelId && reviewChannelId !== interaction.channelId) { - try { - const fetched = await interaction.client.channels.fetch(reviewChannelId); - if (fetched) targetChannel = fetched; - } catch { + const fetched = await fetchChannelCached(interaction.client, reviewChannelId); + if (fetched) { + targetChannel = fetched; + } else { warn('Review channel not found, using current channel', { reviewChannelId, guildId: interaction.guildId, diff --git a/src/commands/welcome.js b/src/commands/welcome.js index 4bbd0b13..5129e497 100644 --- a/src/commands/welcome.js +++ b/src/commands/welcome.js @@ -6,6 +6,7 @@ import { buildRulesAgreementMessage, normalizeWelcomeOnboardingConfig, } from '../modules/welcomeOnboarding.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { isModerator } from '../utils/permissions.js'; import { safeEditReply, safeSend } from '../utils/safeSend.js'; @@ -36,9 +37,7 @@ export async function execute(interaction) { const resultLines = []; if (onboarding.rulesChannel) { - const rulesChannel = - interaction.guild.channels.cache.get(onboarding.rulesChannel) || - (await interaction.guild.channels.fetch(onboarding.rulesChannel).catch(() => null)); + const rulesChannel = await fetchChannelCached(interaction.client, onboarding.rulesChannel); if (rulesChannel?.isTextBased?.()) { const rulesMsg = buildRulesAgreementMessage(); @@ -53,9 +52,10 @@ export async function execute(interaction) { const roleMenuMsg = buildRoleMenuMessage(guildConfig?.welcome); if (roleMenuMsg && guildConfig?.welcome?.channelId) { - const welcomeChannel = - interaction.guild.channels.cache.get(guildConfig.welcome.channelId) || - (await interaction.guild.channels.fetch(guildConfig.welcome.channelId).catch(() => null)); + const welcomeChannel = await fetchChannelCached( + interaction.client, + guildConfig.welcome.channelId, + ); if (welcomeChannel?.isTextBased?.()) { await safeSend(welcomeChannel, roleMenuMsg); diff --git a/src/config-listeners.js b/src/config-listeners.js index 28437d3f..e42da890 100644 --- a/src/config-listeners.js +++ b/src/config-listeners.js @@ -90,17 +90,17 @@ export function registerConfigListeners({ dbPool, config }) { // ── Cache invalidation on config changes ──────────────────────────── // When channel-related config changes, invalidate Discord API caches // so the bot picks up the new channel references immediately. - onConfigChange('welcome.*', async (_newValue, _oldValue, path, guildId) => { + onConfigChange('welcome.*', async (_newValue, _oldValue, _path, guildId) => { if (guildId && guildId !== 'global') { await cacheDelPattern(`discord:guild:${guildId}:*`).catch(() => {}); } }); - onConfigChange('starboard.*', async (_newValue, _oldValue, path, guildId) => { + onConfigChange('starboard.*', async (_newValue, _oldValue, _path, guildId) => { if (guildId && guildId !== 'global') { await cacheDelPattern(`discord:guild:${guildId}:*`).catch(() => {}); } }); - onConfigChange('reputation.*', async (_newValue, _oldValue, path, guildId) => { + onConfigChange('reputation.*', async (_newValue, _oldValue, _path, guildId) => { if (guildId && guildId !== 'global') { await cacheDelPattern(`leaderboard:${guildId}*`).catch(() => {}); await cacheDelPattern(`reputation:${guildId}:*`).catch(() => {}); diff --git a/src/index.js b/src/index.js index c3ff5a5d..22197c30 100644 --- a/src/index.js +++ b/src/index.js @@ -20,8 +20,6 @@ import { fileURLToPath } from 'node:url'; import { Client, Collection, Events, GatewayIntentBits, Partials } from 'discord.js'; import { config as dotenvConfig } from 'dotenv'; import { startServer, stopServer } from './api/server.js'; -import { closeRedisClient as closeRedis, initRedis } from './redis.js'; -import { stopCacheCleanup } from './utils/cache.js'; import { registerConfigListeners, removeLoggingTransport, @@ -53,7 +51,9 @@ import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderatio import { loadOptOuts } from './modules/optout.js'; import { startScheduler, stopScheduler } from './modules/scheduler.js'; import { startTriage, stopTriage } from './modules/triage.js'; +import { closeRedisClient as closeRedis, initRedis } from './redis.js'; import { pruneOldLogs } from './transports/postgres.js'; +import { stopCacheCleanup } from './utils/cache.js'; import { HealthMonitor } from './utils/health.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; diff --git a/src/modules/challengeScheduler.js b/src/modules/challengeScheduler.js index 794a12fe..8f6859f8 100644 --- a/src/modules/challengeScheduler.js +++ b/src/modules/challengeScheduler.js @@ -6,11 +6,11 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/52 */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { createRequire } from 'node:module'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError, warn as logWarn } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { getConfig } from './config.js'; const require = createRequire(import.meta.url); diff --git a/src/modules/githubFeed.js b/src/modules/githubFeed.js index d9d24bf7..9bdef65d 100644 --- a/src/modules/githubFeed.js +++ b/src/modules/githubFeed.js @@ -5,12 +5,12 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/51 */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError, warn as logWarn } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeSend } from '../utils/safeSend.js'; import { getConfig } from './config.js'; diff --git a/src/modules/linkFilter.js b/src/modules/linkFilter.js index 90bc463c..268c3136 100644 --- a/src/modules/linkFilter.js +++ b/src/modules/linkFilter.js @@ -4,9 +4,9 @@ * Also detects phishing TLD patterns (.xyz with suspicious keywords). */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder } from 'discord.js'; import { warn } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { isExempt } from '../utils/modExempt.js'; import { safeSend } from '../utils/safeSend.js'; import { sanitizeMentions } from '../utils/sanitizeMentions.js'; diff --git a/src/modules/moderation.js b/src/modules/moderation.js index 7cabfbf5..f12026e1 100644 --- a/src/modules/moderation.js +++ b/src/modules/moderation.js @@ -4,10 +4,10 @@ * auto-escalation, and tempban scheduling. */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError, warn as logWarn } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { parseDuration } from '../utils/duration.js'; import { safeSend } from '../utils/safeSend.js'; import { getConfig } from './config.js'; diff --git a/src/modules/pollHandler.js b/src/modules/pollHandler.js index 3dcbb33a..954edaa7 100644 --- a/src/modules/pollHandler.js +++ b/src/modules/pollHandler.js @@ -5,10 +5,10 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/47 */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeReply } from '../utils/safeSend.js'; const POLL_COLOR = 0x5865f2; diff --git a/src/modules/rateLimit.js b/src/modules/rateLimit.js index 8ab28875..8dddea9d 100644 --- a/src/modules/rateLimit.js +++ b/src/modules/rateLimit.js @@ -4,9 +4,9 @@ * Actions on trigger: delete excess messages, warn user, temp-mute on repeat. */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder, PermissionFlagsBits } from 'discord.js'; import { info, warn } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { isExempt } from '../utils/modExempt.js'; import { safeReply, safeSend } from '../utils/safeSend.js'; import { sanitizeMentions } from '../utils/sanitizeMentions.js'; diff --git a/src/modules/reminderHandler.js b/src/modules/reminderHandler.js index 689f2190..b6325e9f 100644 --- a/src/modules/reminderHandler.js +++ b/src/modules/reminderHandler.js @@ -5,12 +5,12 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/137 */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError, warn } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { getNextCronRun } from '../utils/cronParser.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeSend } from '../utils/safeSend.js'; /** Snooze durations in milliseconds, keyed by button suffix */ diff --git a/src/modules/reputation.js b/src/modules/reputation.js index 905d81f5..8c3eba39 100644 --- a/src/modules/reputation.js +++ b/src/modules/reputation.js @@ -8,11 +8,11 @@ import { EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, error as logError } from '../logger.js'; +import { invalidateReputationCache } from '../utils/reputationCache.js'; import { safeSend } from '../utils/safeSend.js'; import { sanitizeMentions } from '../utils/sanitizeMentions.js'; import { getConfig } from './config.js'; import { REPUTATION_DEFAULTS } from './reputationDefaults.js'; -import { invalidateReputationCache } from '../utils/reputationCache.js'; /** In-memory cooldown map: `${guildId}:${userId}` → Date of last XP gain */ const cooldowns = new Map(); diff --git a/src/modules/reviewHandler.js b/src/modules/reviewHandler.js index 4f0caffd..726ea0cf 100644 --- a/src/modules/reviewHandler.js +++ b/src/modules/reviewHandler.js @@ -10,6 +10,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, warn } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeReply, safeSend } from '../utils/safeSend.js'; import { getConfig } from './config.js'; @@ -123,7 +124,7 @@ export async function updateReviewMessage(review, client) { if (!review.message_id || !review.channel_id) return; try { - const channel = await client.channels.fetch(review.channel_id).catch(() => null); + const channel = await fetchChannelCached(client, review.channel_id); if (!channel) return; const message = await channel.messages.fetch(review.message_id).catch(() => null); @@ -303,7 +304,7 @@ export async function expireStaleReviews(client) { if (!reviewChannelId) continue; try { - const channel = await client.channels.fetch(reviewChannelId).catch(() => null); + const channel = await fetchChannelCached(client, reviewChannelId); if (!channel) continue; const ids = reviews.map((r) => `#${r.id}`).join(', '); diff --git a/src/modules/scheduler.js b/src/modules/scheduler.js index 34f1d76f..1cdebe0c 100644 --- a/src/modules/scheduler.js +++ b/src/modules/scheduler.js @@ -5,11 +5,11 @@ * @see https://github.com/VolvoxLLC/volvox-bot/issues/42 */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { getPool } from '../db.js'; import { info, error as logError, warn as logWarn } from '../logger.js'; import { getNextCronRun, parseCron } from '../utils/cronParser.js'; import { runMaintenance } from '../utils/dbMaintenance.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeSend } from '../utils/safeSend.js'; import { checkDailyChallenge } from './challengeScheduler.js'; import { getConfig } from './config.js'; diff --git a/src/modules/starboard.js b/src/modules/starboard.js index fc6b1cd4..bc692e95 100644 --- a/src/modules/starboard.js +++ b/src/modules/starboard.js @@ -6,10 +6,10 @@ * Handles dedup (update vs repost), star removal, and self-star prevention. */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { debug, info, error as logError, warn } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeSend } from '../utils/safeSend.js'; /** Default starboard configuration values */ diff --git a/src/modules/triage-respond.js b/src/modules/triage-respond.js index 55d066f9..8bf3ca39 100644 --- a/src/modules/triage-respond.js +++ b/src/modules/triage-respond.js @@ -3,10 +3,10 @@ * Discord message dispatch, moderation audit logging, and channel context fetching. */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { EmbedBuilder } from 'discord.js'; import { info, error as logError, warn } from '../logger.js'; import { buildDebugEmbed, extractStats, logAiUsage } from '../utils/debugFooter.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeSend } from '../utils/safeSend.js'; import { splitMessage } from '../utils/splitMessage.js'; import { addToHistory } from './ai.js'; diff --git a/src/modules/welcome.js b/src/modules/welcome.js index b5cfb0ab..4b64c96e 100644 --- a/src/modules/welcome.js +++ b/src/modules/welcome.js @@ -3,8 +3,8 @@ * Handles dynamic welcome messages for new members */ -import { fetchChannelCached } from '../utils/discordCache.js'; import { info, error as logError } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeSend } from '../utils/safeSend.js'; import { isReturningMember } from './welcomeOnboarding.js'; diff --git a/src/modules/welcomeOnboarding.js b/src/modules/welcomeOnboarding.js index 1e58ad02..d5d52fad 100644 --- a/src/modules/welcomeOnboarding.js +++ b/src/modules/welcomeOnboarding.js @@ -6,6 +6,7 @@ import { StringSelectMenuBuilder, } from 'discord.js'; import { info } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeEditReply, safeSend } from '../utils/safeSend.js'; export const RULES_ACCEPT_BUTTON_ID = 'welcome_rules_accept'; @@ -118,7 +119,7 @@ export function buildRoleMenuMessage(welcomeConfig) { } async function fetchRole(guild, roleId) { - return guild.roles.cache.get(roleId) || (await guild.roles.fetch(roleId).catch(() => null)); + return guild.roles.cache.get(roleId) || (await guild.roles.fetch(roleId).catch(() => null)); // roles.cache is in-memory; fetch only on miss } export async function handleRulesAcceptButton(interaction, config) { @@ -173,9 +174,7 @@ export async function handleRulesAcceptButton(interaction, config) { } if (welcome.introChannel) { - const introChannel = - interaction.guild.channels.cache.get(welcome.introChannel) || - (await interaction.guild.channels.fetch(welcome.introChannel).catch(() => null)); + const introChannel = await fetchChannelCached(interaction.client, welcome.introChannel); if (introChannel?.isTextBased?.()) { await safeSend( diff --git a/src/redis.js b/src/redis.js index bd29bc3f..2f63bf6b 100644 --- a/src/redis.js +++ b/src/redis.js @@ -9,7 +9,7 @@ */ import Redis from 'ioredis'; -import { debug, error as logError, info, warn } from './logger.js'; +import { debug, info, error as logError, warn } from './logger.js'; /** @type {import('ioredis').Redis | null} */ let client = null; diff --git a/src/utils/cache.js b/src/utils/cache.js index 0428a227..1c8fa3bf 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -242,8 +242,13 @@ export async function cacheClear() { try { // Scan and delete all known app-prefixed keys instead of flushdb() const prefixes = [ - 'rl:*', 'reputation:*', 'rank:*', 'leaderboard:*', - 'discord:*', 'config:*', 'session:*', + 'rl:*', + 'reputation:*', + 'rank:*', + 'leaderboard:*', + 'discord:*', + 'config:*', + 'session:*', ]; for (const pattern of prefixes) { let cursor = '0'; diff --git a/src/utils/discordCache.js b/src/utils/discordCache.js index 8cbd2804..225c3d7e 100644 --- a/src/utils/discordCache.js +++ b/src/utils/discordCache.js @@ -45,12 +45,16 @@ export async function fetchChannelCached(client, channelId) { const channel = await client.channels.fetch(channelId); if (channel) { // Cache minimal metadata for future health checks - await cacheSet(cacheKey, { - id: channel.id, - name: channel.name ?? null, - type: channel.type, - guildId: channel.guildId ?? null, - }, TTL.CHANNEL_DETAIL); + await cacheSet( + cacheKey, + { + id: channel.id, + name: channel.name ?? null, + type: channel.type, + guildId: channel.guildId ?? null, + }, + TTL.CHANNEL_DETAIL, + ); debug('Fetched and cached channel', { channelId, name: channel.name }); } return channel; @@ -151,11 +155,15 @@ export async function fetchMemberCached(guild, userId) { try { const member = await guild.members.fetch(userId); if (member) { - await cacheSet(cacheKey, { - id: member.id, - displayName: member.displayName, - joinedAt: member.joinedAt?.toISOString() ?? null, - }, TTL.MEMBERS); + await cacheSet( + cacheKey, + { + id: member.id, + displayName: member.displayName, + joinedAt: member.joinedAt?.toISOString() ?? null, + }, + TTL.MEMBERS, + ); } return member; } catch (err) { diff --git a/tests/api/middleware/redisRateLimit.test.js b/tests/api/middleware/redisRateLimit.test.js index 75f5ac87..25e9dae4 100644 --- a/tests/api/middleware/redisRateLimit.test.js +++ b/tests/api/middleware/redisRateLimit.test.js @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock logger vi.mock('../../../src/logger.js', () => ({ @@ -26,7 +26,7 @@ vi.mock('../../../src/api/middleware/rateLimit.js', () => ({ describe('redisRateLimit', () => { let redisRateLimit; let getRedis; - let rateLimit; + let _rateLimit; beforeEach(async () => { vi.resetModules(); @@ -34,7 +34,7 @@ describe('redisRateLimit', () => { getRedis = redisMod.getRedis; const rateLimitMod = await import('../../../src/api/middleware/rateLimit.js'); - rateLimit = rateLimitMod.rateLimit; + _rateLimit = rateLimitMod.rateLimit; const mod = await import('../../../src/api/middleware/redisRateLimit.js'); redisRateLimit = mod.redisRateLimit; @@ -47,7 +47,9 @@ describe('redisRateLimit', () => { function makeRes() { const headers = {}; return { - set: vi.fn((k, v) => { headers[k] = v; }), + set: vi.fn((k, v) => { + headers[k] = v; + }), status: vi.fn().mockReturnThis(), json: vi.fn(), _headers: headers, diff --git a/tests/commands/leaderboard.test.js b/tests/commands/leaderboard.test.js index 19918fae..47c226b6 100644 --- a/tests/commands/leaderboard.test.js +++ b/tests/commands/leaderboard.test.js @@ -14,6 +14,10 @@ vi.mock('../../src/modules/config.js', () => ({ getConfig: vi.fn().mockReturnValue({ reputation: { enabled: true } }), })); +vi.mock('../../src/utils/reputationCache.js', () => ({ + getLeaderboardCached: vi.fn().mockImplementation((_guildId, factory) => factory()), +})); + vi.mock('../../src/utils/safeSend.js', () => ({ safeEditReply: vi.fn(), })); diff --git a/tests/commands/rank.test.js b/tests/commands/rank.test.js index 215e2c0c..d4c04270 100644 --- a/tests/commands/rank.test.js +++ b/tests/commands/rank.test.js @@ -23,6 +23,12 @@ vi.mock('../../src/modules/reputation.js', async (importOriginal) => { }; }); +vi.mock('../../src/utils/reputationCache.js', () => ({ + getReputationCached: vi.fn().mockResolvedValue(null), // always cache miss → hits DB + setReputationCache: vi.fn().mockResolvedValue(undefined), + getRankCached: vi.fn().mockImplementation((_guildId, _userId, factory) => factory()), +})); + vi.mock('../../src/utils/safeSend.js', () => ({ safeEditReply: vi.fn(), })); diff --git a/tests/commands/review.test.js b/tests/commands/review.test.js index 9ce9ecd2..fc5d77c0 100644 --- a/tests/commands/review.test.js +++ b/tests/commands/review.test.js @@ -27,6 +27,16 @@ vi.mock('../../src/utils/safeSend.js', () => ({ safeEditReply: vi.fn((t, opts) => t.editReply(opts)), })); +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi + .fn() + .mockImplementation((client, channelId) => client.channels.fetch(channelId).catch(() => null)), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('discord.js', () => { function chainable() { const proxy = new Proxy(() => proxy, { diff --git a/tests/config-listeners.test.js b/tests/config-listeners.test.js index c2e56b68..411c00c6 100644 --- a/tests/config-listeners.test.js +++ b/tests/config-listeners.test.js @@ -50,11 +50,9 @@ describe('config-listeners', () => { setInitialTransport = mod.setInitialTransport; }); - -vi.mock('../src/utils/cache.js', () => ({ - cacheDelPattern: vi.fn().mockResolvedValue(0), -})); - + vi.mock('../src/utils/cache.js', () => ({ + cacheDelPattern: vi.fn().mockResolvedValue(0), + })); afterEach(() => { vi.clearAllMocks(); diff --git a/tests/modules/challengeScheduler.test.js b/tests/modules/challengeScheduler.test.js index fd7a7bcc..26cf31c7 100644 --- a/tests/modules/challengeScheduler.test.js +++ b/tests/modules/challengeScheduler.test.js @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // ─── Mock dependencies ─────────────────────────────────────────────────────── - // Mock discordCache to pass through to the underlying client.channels.fetch vi.mock('../../src/utils/discordCache.js', () => ({ fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { diff --git a/tests/modules/rateLimit.coverage.test.js b/tests/modules/rateLimit.coverage.test.js index c5440cfb..df3992c6 100644 --- a/tests/modules/rateLimit.coverage.test.js +++ b/tests/modules/rateLimit.coverage.test.js @@ -4,7 +4,6 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - // Mock discordCache to pass through to the underlying client.channels.fetch vi.mock('../../src/utils/discordCache.js', () => ({ fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { diff --git a/tests/modules/reminderHandler.test.js b/tests/modules/reminderHandler.test.js index 6978e6de..a3a5553e 100644 --- a/tests/modules/reminderHandler.test.js +++ b/tests/modules/reminderHandler.test.js @@ -3,7 +3,6 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; - // Mock discordCache to pass through to the underlying client.channels.fetch vi.mock('../../src/utils/discordCache.js', () => ({ fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { diff --git a/tests/modules/starboard.test.js b/tests/modules/starboard.test.js index d64cfe0c..0861f646 100644 --- a/tests/modules/starboard.test.js +++ b/tests/modules/starboard.test.js @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // ── Mocks ─────────────────────────────────────────────────────────────────── - // Mock discordCache to pass through to the underlying client.channels.fetch vi.mock('../../src/utils/discordCache.js', () => ({ fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { diff --git a/tests/modules/triage.coverage.test.js b/tests/modules/triage.coverage.test.js index e70436ee..444a56c2 100644 --- a/tests/modules/triage.coverage.test.js +++ b/tests/modules/triage.coverage.test.js @@ -13,7 +13,6 @@ const mockResponderStart = vi.fn().mockResolvedValue(undefined); const mockClassifierClose = vi.fn(); const mockResponderClose = vi.fn(); - // Mock discordCache to pass through to the underlying client.channels.fetch vi.mock('../../src/utils/discordCache.js', () => ({ fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { diff --git a/tests/modules/triage.test.js b/tests/modules/triage.test.js index 5f83dadd..d4c550ed 100644 --- a/tests/modules/triage.test.js +++ b/tests/modules/triage.test.js @@ -10,7 +10,6 @@ const mockResponderStart = vi.fn().mockResolvedValue(undefined); const mockClassifierClose = vi.fn(); const mockResponderClose = vi.fn(); - // Mock discordCache to pass through to the underlying client.channels.fetch vi.mock('../../src/utils/discordCache.js', () => ({ fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { diff --git a/tests/modules/welcome.coverage.test.js b/tests/modules/welcome.coverage.test.js index daa93f16..2cfe363c 100644 --- a/tests/modules/welcome.coverage.test.js +++ b/tests/modules/welcome.coverage.test.js @@ -4,7 +4,6 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - // Mock discordCache to pass through to the underlying client.channels.fetch vi.mock('../../src/utils/discordCache.js', () => ({ fetchChannelCached: vi.fn().mockImplementation(async (client, channelId) => { diff --git a/tests/modules/welcomeOnboarding.test.js b/tests/modules/welcomeOnboarding.test.js index 3d0cfda6..e8864155 100644 --- a/tests/modules/welcomeOnboarding.test.js +++ b/tests/modules/welcomeOnboarding.test.js @@ -6,6 +6,18 @@ vi.mock('../../src/logger.js', () => ({ warn: vi.fn(), })); +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation((client, channelId) => { + if (!channelId) return Promise.resolve(null); + // Use client.channels.cache if available + if (client?.channels?.cache?.get?.(channelId)) { + return Promise.resolve(client.channels.cache.get(channelId)); + } + return client?.channels?.fetch?.(channelId).catch(() => null) ?? Promise.resolve(null); + }), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/utils/safeSend.js', () => ({ safeSend: vi.fn(async (target, payload) => { if (typeof target?.send === 'function') return target.send(payload); @@ -80,6 +92,12 @@ describe('welcomeOnboarding module', () => { fetch: vi.fn(async () => introChannel), }, }, + client: { + channels: { + cache: new Map([['intro-ch', introChannel]]), + fetch: vi.fn(async () => introChannel), + }, + }, reply: vi.fn(async () => {}), deferReply: vi.fn(async () => {}), editReply: vi.fn(async () => {}), diff --git a/tests/redis.test.js b/tests/redis.test.js index 2a8c7f0c..a484cbfd 100644 --- a/tests/redis.test.js +++ b/tests/redis.test.js @@ -48,7 +48,10 @@ describe('redis.js', () => { quit: vi.fn().mockResolvedValue('OK'), }; vi.doMock('ioredis', () => ({ - default: vi.fn().mockImplementation(function () { return mockClient; }), + // biome-ignore lint/complexity/useArrowFunction: new Redis() requires a regular function mock + default: vi.fn().mockImplementation(function () { + return mockClient; + }), })); const freshRedis = await import('../src/redis.js'); diff --git a/tests/utils/discordCache.test.js b/tests/utils/discordCache.test.js index 7ebd2e9e..45ba54de 100644 --- a/tests/utils/discordCache.test.js +++ b/tests/utils/discordCache.test.js @@ -106,7 +106,10 @@ describe('discordCache.js', () => { it('fetches and caches guild roles', async () => { const roles = new Map([ ['1', { id: '1', name: '@everyone', color: 0, position: 0, permissions: { bitfield: 0n } }], - ['2', { id: '2', name: 'Admin', color: 0xff0000, position: 1, permissions: { bitfield: 8n } }], + [ + '2', + { id: '2', name: 'Admin', color: 0xff0000, position: 1, permissions: { bitfield: 8n } }, + ], ]); const guild = { diff --git a/tests/utils/reputationCache.test.js b/tests/utils/reputationCache.test.js index 672b59d1..d6a29db4 100644 --- a/tests/utils/reputationCache.test.js +++ b/tests/utils/reputationCache.test.js @@ -64,7 +64,10 @@ describe('reputationCache.js', () => { describe('getLeaderboardCached', () => { it('calls factory on miss and caches result', async () => { - const leaderboard = [{ userId: 'u1', xp: 500 }, { userId: 'u2', xp: 300 }]; + const leaderboard = [ + { userId: 'u1', xp: 500 }, + { userId: 'u2', xp: 300 }, + ]; const factory = vi.fn().mockResolvedValue(leaderboard); const result = await repCache.getLeaderboardCached('guild1', factory);