fix: selective leaderboard cache invalidation instead of full clear (BUG-S10)
Only invalidates __alltime__ and current season cache keys on fight completion, preserving historical season caches. Test verifies selective behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c224776c90
commit
11a76cc249
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock DB and dependencies to import bots module
|
||||
vi.mock('../db/index.js', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
schema: {
|
||||
bots: { id: 'id', name: 'name', publicKey: 'publicKey', eloRating: 'eloRating', wins: 'wins', losses: 'losses', winStreak: 'winStreak', tier: 'tier', avatarSeed: 'avatarSeed', archetype: 'archetype', botType: 'botType', hasWallet: 'hasWallet', zapsReceived: 'zapsReceived', isActive: 'isActive', webhookUrl: 'webhookUrl', secretHash: 'secretHash', bestStreak: 'bestStreak', satsWagered: 'satsWagered' },
|
||||
walletConnections: { id: 'id', botId: 'botId' },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../engine/scoring.js', () => ({
|
||||
TIER_NAMES: ['Baby', 'Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Legend'],
|
||||
TIER_COLORS: ['#999', '#cd7f32', '#c0c0c0', '#ffd700', '#e5e4e2', '#b9f2ff', '#ff6b6b'],
|
||||
}))
|
||||
|
||||
vi.mock('../engine/achievements.js', () => ({
|
||||
computeAchievements: vi.fn().mockReturnValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('../engine/orchestrator.js', () => ({
|
||||
isAllowedWebhookUrl: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('../engine/webhook-test.js', () => ({
|
||||
testWebhook: vi.fn().mockResolvedValue({ success: true }),
|
||||
}))
|
||||
|
||||
vi.mock('../middleware/rate-limit.js', () => ({
|
||||
rateLimit: () => async (_c: any, next: any) => next(),
|
||||
}))
|
||||
|
||||
const { invalidateLeaderboardCache, _leaderboardCache } = await import('./bots.js')
|
||||
|
||||
describe('leaderboard cache selective invalidation', () => {
|
||||
beforeEach(() => {
|
||||
_leaderboardCache.clear()
|
||||
})
|
||||
|
||||
it('invalidates __alltime__ and current but preserves historical season', () => {
|
||||
const now = Date.now()
|
||||
_leaderboardCache.set('__alltime__', { data: 'alltime', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('current', { data: 'current-season', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('s1', { data: 'season-1-history', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('s2', { data: 'season-2-history', expiresAt: now + 30_000 })
|
||||
|
||||
invalidateLeaderboardCache()
|
||||
|
||||
expect(_leaderboardCache.has('__alltime__')).toBe(false)
|
||||
expect(_leaderboardCache.has('current')).toBe(false)
|
||||
// Historical seasons preserved
|
||||
expect(_leaderboardCache.has('s1')).toBe(true)
|
||||
expect(_leaderboardCache.has('s2')).toBe(true)
|
||||
})
|
||||
|
||||
it('also invalidates specific season when seasonId passed', () => {
|
||||
const now = Date.now()
|
||||
_leaderboardCache.set('__alltime__', { data: 'alltime', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('current', { data: 'current', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('s1', { data: 'season-1', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('s2', { data: 'season-2', expiresAt: now + 30_000 })
|
||||
|
||||
invalidateLeaderboardCache('s1')
|
||||
|
||||
expect(_leaderboardCache.has('__alltime__')).toBe(false)
|
||||
expect(_leaderboardCache.has('current')).toBe(false)
|
||||
expect(_leaderboardCache.has('s1')).toBe(false)
|
||||
// Other seasons preserved
|
||||
expect(_leaderboardCache.has('s2')).toBe(true)
|
||||
})
|
||||
|
||||
it('no-op on empty cache', () => {
|
||||
expect(_leaderboardCache.size).toBe(0)
|
||||
invalidateLeaderboardCache()
|
||||
expect(_leaderboardCache.size).toBe(0)
|
||||
})
|
||||
|
||||
it('only removes targeted keys, leaves other entries', () => {
|
||||
const now = Date.now()
|
||||
// 5 cached entries
|
||||
_leaderboardCache.set('__alltime__', { data: 'a', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('current', { data: 'c', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('s1', { data: '1', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('s2', { data: '2', expiresAt: now + 30_000 })
|
||||
_leaderboardCache.set('s3', { data: '3', expiresAt: now + 30_000 })
|
||||
|
||||
invalidateLeaderboardCache('s2')
|
||||
|
||||
// Removed: __alltime__, current, s2
|
||||
expect(_leaderboardCache.size).toBe(2)
|
||||
expect(_leaderboardCache.has('s1')).toBe(true)
|
||||
expect(_leaderboardCache.has('s3')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,16 @@ export const botsRouter = new Hono()
|
||||
const leaderboardCache = new Map<string, { data: unknown; expiresAt: number }>()
|
||||
const LEADERBOARD_TTL_MS = 30_000 // 30s cache
|
||||
|
||||
export function invalidateLeaderboardCache() {
|
||||
leaderboardCache.clear()
|
||||
export function invalidateLeaderboardCache(seasonId?: string) {
|
||||
// Selective: only clear active leaderboards, preserve historical season caches
|
||||
leaderboardCache.delete('__alltime__')
|
||||
leaderboardCache.delete('current')
|
||||
if (seasonId) leaderboardCache.delete(seasonId)
|
||||
}
|
||||
|
||||
// Expose cache for testing
|
||||
export const _leaderboardCache = leaderboardCache
|
||||
|
||||
function hashSecret(secret: string): string {
|
||||
return createHash('sha256').update(secret).digest('hex')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user