test: add comprehensive unit tests for session cleanup and activity tracking

Addresses Copilot code review feedback on PR #135 regarding missing test coverage
for session cleanup and activity tracking functionality.

Test Coverage:
- session-cleanup.test.ts (14 test cases):
  - LID orphan cleanup with 24h threshold
  - Secondary device cleanup with 15 day threshold
  - Primary device cleanup with 30 day threshold
  - Boundary conditions (exact thresholds)
  - Mixed scenarios (multiple session types)
  - Configuration handling (custom thresholds, disabled cleanup)
  - Edge cases (empty sessions, null tracker)

- session-activity-tracker.test.ts (20 test cases):
  - Activity recording to in-memory cache
  - Cache hits and disk fallback
  - Batch flushing to disk
  - Start/stop lifecycle management
  - Performance tests (1000 messages, batch operations)
  - Edge cases (special characters, rapid updates, disk errors)
  - Statistics tracking

All tests passing (47 tests total for session management).

https://claude.ai/code/session_01SoNUGBEWbJwWWws3F2fuzh
This commit is contained in:
Claude
2026-02-10 10:00:52 +00:00
parent 0232a805ae
commit 6f91152b2a
2 changed files with 1073 additions and 0 deletions
@@ -0,0 +1,544 @@
import { jest } from '@jest/globals'
import P from 'pino'
import { makeSessionActivityTracker } from '../../Signal/session-activity-tracker'
import type { SessionActivityMetadata } from '../../Signal/session-activity-tracker'
import type { SignalKeyStoreWithTransaction } from '../../Types'
const mockKeys: jest.Mocked<SignalKeyStoreWithTransaction> = {
get: jest.fn() as any,
set: jest.fn() as any,
transaction: jest.fn(async (work: () => any) => await work()) as any,
isInTransaction: jest.fn() as any
}
const logger = P({ level: 'silent' })
describe('SessionActivityTracker', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
describe('recordActivity', () => {
it('should record activity in memory cache', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const beforeTime = Date.now()
tracker.recordActivity(jid)
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(1)
expect(stats.cacheSize).toBe(1)
})
it('should update existing activity timestamp', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
tracker.recordActivity(jid)
jest.advanceTimersByTime(5000) // 5 seconds later
tracker.recordActivity(jid)
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(2)
expect(stats.cacheSize).toBe(1) // Still one unique JID
})
it('should not record activity when disabled', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(0)
expect(stats.cacheSize).toBe(0)
})
it('should handle multiple JIDs', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.recordActivity('5511888888888@s.whatsapp.net')
tracker.recordActivity('123456789@lid')
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(3)
expect(stats.cacheSize).toBe(3)
})
})
describe('getLastActivity', () => {
it('should return activity from cache (cache hit)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const beforeTime = Date.now()
tracker.recordActivity(jid)
const lastActivity = await tracker.getLastActivity(jid)
expect(lastActivity).toBeGreaterThanOrEqual(beforeTime)
expect(mockKeys.get).not.toHaveBeenCalled() // Cache hit, no DB call
})
it('should fallback to disk when not in cache (cache miss)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const diskTimestamp = Date.now() - 10000
// @ts-ignore
mockKeys.get.mockResolvedValue({
'session-activity:5511999999999@s.whatsapp.net': {
lastActivityAt: diskTimestamp,
createdAt: diskTimestamp
} as SessionActivityMetadata
})
const lastActivity = await tracker.getLastActivity(jid)
expect(lastActivity).toBe(diskTimestamp)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should return undefined when activity not found', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// @ts-ignore
mockKeys.get.mockResolvedValue({})
const lastActivity = await tracker.getLastActivity('nonexistent@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
it('should return undefined when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
it('should handle disk read errors gracefully', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// @ts-ignore
mockKeys.get.mockRejectedValue(new Error('Disk read error'))
const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
})
describe('getAllActivities', () => {
it('should return all activities from disk and cache', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const now = Date.now()
// Simulate disk data
// @ts-ignore
mockKeys.get.mockResolvedValue({
'session-activity:5511999999999@s.whatsapp.net': {
lastActivityAt: now - 10000,
createdAt: now - 20000
} as SessionActivityMetadata,
'session-activity:5511888888888@s.whatsapp.net': {
lastActivityAt: now - 5000,
createdAt: now - 15000
} as SessionActivityMetadata
})
// Add new activity to cache
tracker.recordActivity('123456789@lid')
const activities = await tracker.getAllActivities()
expect(activities.size).toBe(3)
expect(activities.has('5511999999999@s.whatsapp.net')).toBe(true)
expect(activities.has('5511888888888@s.whatsapp.net')).toBe(true)
expect(activities.has('123456789@lid')).toBe(true)
})
it('should prioritize cache over disk (cache is more recent)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const diskTimestamp = Date.now() - 10000
// Simulate old disk data
// @ts-ignore
mockKeys.get.mockResolvedValue({
[`session-activity:${jid}`]: {
lastActivityAt: diskTimestamp,
createdAt: diskTimestamp
} as SessionActivityMetadata
})
// Record new activity in cache
const beforeCache = Date.now()
tracker.recordActivity(jid)
const activities = await tracker.getAllActivities()
const cacheTimestamp = activities.get(jid)
expect(cacheTimestamp).toBeGreaterThanOrEqual(beforeCache)
expect(cacheTimestamp).not.toBe(diskTimestamp) // Cache wins
})
it('should return empty map when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const activities = await tracker.getAllActivities()
expect(activities.size).toBe(0)
})
it('should handle disk read errors gracefully', async () => {
// Use real timers for this test
jest.useRealTimers()
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Simulate disk error
// @ts-ignore
mockKeys.get.mockRejectedValue(new Error('Disk read error'))
// getAllActivities should return empty map (logs warning but doesn't throw)
const activities = await tracker.getAllActivities()
expect(activities).toBeInstanceOf(Map)
expect(activities.size).toBe(0) // Current implementation doesn't return cache on disk error
// Restore fake timers
jest.useFakeTimers()
})
})
describe('flush', () => {
it('should flush cache to disk in batch', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid1 = '5511999999999@s.whatsapp.net'
const jid2 = '5511888888888@s.whatsapp.net'
tracker.recordActivity(jid1)
tracker.recordActivity(jid2)
await tracker.flush()
// Should call transaction once for batch write
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalledTimes(1)
// Verify batch update structure
const setCall = mockKeys.set.mock.calls[0]?.[0]
// @ts-ignore
expect(setCall['session-activity']).toBeDefined()
// @ts-ignore
expect(setCall['session-activity'][`session-activity:${jid1}`]).toBeDefined()
// @ts-ignore
expect(setCall['session-activity'][`session-activity:${jid2}`]).toBeDefined()
})
it('should clear cache after successful flush', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
await tracker.flush()
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(0) // Cache cleared
expect(stats.totalFlushes).toBe(1)
})
it('should not flush when cache is empty', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
await tracker.flush()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should not flush when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
await tracker.flush()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should keep cache on flush failure (retry on next flush)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Simulate flush error
mockKeys.transaction.mockRejectedValueOnce(new Error('Flush failed'))
await tracker.flush()
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(1) // Cache NOT cleared
expect(stats.totalFlushes).toBe(0) // Flush failed
})
it('should update statistics after successful flush', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
const beforeFlush = Date.now()
await tracker.flush()
const afterFlush = Date.now()
const stats = tracker.getStats()
expect(stats.totalFlushes).toBe(1)
expect(stats.lastFlushAt).toBeGreaterThanOrEqual(beforeFlush)
expect(stats.lastFlushAt).toBeLessThanOrEqual(afterFlush)
expect(stats.lastFlushDuration).toBeGreaterThanOrEqual(0)
})
})
describe('start/stop lifecycle', () => {
it('should start periodic flush', async () => {
// Use real timers for this test
jest.useRealTimers()
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record activity so there's something to flush
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.start()
// Wait for initial flush to complete
await new Promise(resolve => setTimeout(resolve, 50))
// Should attempt initial flush
expect(mockKeys.transaction).toHaveBeenCalled()
// Cleanup
await tracker.stop()
// Restore fake timers
jest.useFakeTimers()
})
it('should flush periodically after start', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Advance time to trigger flush
jest.advanceTimersByTime(60000)
await Promise.resolve() // Let flush promise resolve
expect(mockKeys.transaction).toHaveBeenCalled()
})
it('should not start when disabled', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should not start twice (guard against multiple start calls)', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Second start should be ignored
tracker.start()
// Should not trigger another initial flush
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should stop periodic flush and flush pending data', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Stop should flush pending data
await tracker.stop()
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalled()
})
it('should stop periodic flush without error when no pending data', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
await tracker.stop()
// No flush needed (no pending data)
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
})
describe('getStats', () => {
it('should return current statistics', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.recordActivity('5511888888888@s.whatsapp.net')
const stats = tracker.getStats()
expect(stats.enabled).toBe(true)
expect(stats.totalUpdates).toBe(2)
expect(stats.cacheSize).toBe(2)
expect(stats.totalFlushes).toBe(0) // No flush yet
expect(stats.lastFlushAt).toBe(0)
expect(stats.lastFlushDuration).toBe(0)
})
it('should show disabled status', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const stats = tracker.getStats()
expect(stats.enabled).toBe(false)
})
})
describe('Performance', () => {
it('should handle high-volume activity recording (1000 messages)', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const startTime = Date.now()
// Simulate 1000 messages
for (let i = 0; i < 1000; i++) {
tracker.recordActivity(`5511${i.toString().padStart(9, '0')}@s.whatsapp.net`)
}
const duration = Date.now() - startTime
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(1000)
expect(stats.cacheSize).toBe(1000)
// Should be very fast (<100ms for 1000 updates)
expect(duration).toBeLessThan(100)
})
it('should batch flush multiple activities in single transaction', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record 100 activities
for (let i = 0; i < 100; i++) {
tracker.recordActivity(`5511${i.toString().padStart(9, '0')}@s.whatsapp.net`)
}
await tracker.flush()
// Should use single transaction for all 100
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalledTimes(1)
const stats = tracker.getStats()
expect(stats.totalFlushes).toBe(1)
})
})
describe('Edge Cases', () => {
it('should handle JID with special characters', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const specialJids = [
'5511999999999:1@s.whatsapp.net', // Device ID
'123456789@lid', // LID
'5511999999999:99@hosted', // Hosted device
'123456789:5@hosted.lid' // Hosted LID
]
specialJids.forEach(jid => tracker.recordActivity(jid))
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(4)
})
it('should handle rapid updates to same JID', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
// Rapid updates (10 in quick succession)
for (let i = 0; i < 10; i++) {
tracker.recordActivity(jid)
}
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(10)
expect(stats.cacheSize).toBe(1) // Still one JID
})
it('should handle flush during active recording', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Flush clears the cache
await tracker.flush()
// Record new activity after flush
tracker.recordActivity('5511888888888@s.whatsapp.net')
// New activity should be in cache
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(1) // Only the new one
})
})
})
@@ -0,0 +1,529 @@
import { jest } from '@jest/globals'
import P from 'pino'
import { makeSessionCleanup } from '../../Signal/session-cleanup'
import type { SessionActivityTracker } from '../../Signal/session-activity-tracker'
import type { LIDMappingStore } from '../../Signal/lid-mapping'
import type { SignalKeyStoreWithTransaction } from '../../Types'
const mockKeys: jest.Mocked<SignalKeyStoreWithTransaction> = {
get: jest.fn<SignalKeyStoreWithTransaction['get']>() as any,
set: jest.fn<SignalKeyStoreWithTransaction['set']>(),
transaction: jest.fn<SignalKeyStoreWithTransaction['transaction']>(async (work: () => any) => await work()) as any,
isInTransaction: jest.fn<SignalKeyStoreWithTransaction['isInTransaction']>()
}
const mockLidMapping: jest.Mocked<Pick<LIDMappingStore, 'getPNForLID'>> = {
getPNForLID: jest.fn<LIDMappingStore['getPNForLID']>() as any
}
const mockActivityTracker: jest.Mocked<Pick<SessionActivityTracker, 'getAllActivities'>> = {
getAllActivities: jest.fn<SessionActivityTracker['getAllActivities']>() as any
}
const logger = P({ level: 'silent' })
describe('SessionCleanup', () => {
const HOUR_MS = 3600000
const DAY_MS = 86400000
beforeEach(() => {
jest.clearAllMocks()
})
describe('LID Orphan Cleanup (24h threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should delete LID orphan after 24h of inactivity', async () => {
const now = Date.now()
const lastActivity = now - (25 * HOUR_MS) // 25 hours ago
// Mock sessions: 1 LID orphan
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
// Mock: No PN mapping (orphan)
mockLidMapping.getPNForLID.mockResolvedValue(null)
// Mock: Activity 25h ago
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['123456789@lid', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
expect(mockKeys.set).toHaveBeenCalledWith({
session: { '123456789_2.0': null }
})
})
it('should NOT delete LID orphan before 24h', async () => {
const now = Date.now()
const lastActivity = now - (23 * HOUR_MS) // 23 hours ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['123456789@lid', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
expect(mockKeys.set).not.toHaveBeenCalled()
})
it('should delete LID orphan with no activity tracking', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
// No activity tracked
mockActivityTracker.getAllActivities.mockResolvedValue(new Map())
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete LID with valid PN mapping', async () => {
const now = Date.now()
const lastActivity = now - (25 * HOUR_MS)
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
// Has PN mapping - not orphan
mockLidMapping.getPNForLID.mockResolvedValue('5511999999999@s.whatsapp.net')
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['123456789@lid', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Secondary Device Cleanup (15 days threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should delete secondary device (Web/Desktop) after 15 days', async () => {
const now = Date.now()
const lastActivity = now - (16 * DAY_MS) // 16 days ago
// Mock: Secondary device (device ID = 1)
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999:1@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete secondary device before 15 days', async () => {
const now = Date.now()
const lastActivity = now - (14 * DAY_MS) // 14 days ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999:1@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
it('should NOT delete secondary device without activity tracking', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
// No activity tracked - grace period
mockActivityTracker.getAllActivities.mockResolvedValue(new Map())
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Primary Device Cleanup (30 days threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should delete primary device after 30 days', async () => {
const now = Date.now()
const lastActivity = now - (31 * DAY_MS) // 31 days ago
// Mock: Primary device (device ID = 0)
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.0': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.primaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete primary device before 30 days', async () => {
const now = Date.now()
const lastActivity = now - (29 * DAY_MS) // 29 days ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.0': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.primaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Boundary Conditions', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should handle exactly 24h for LID orphan (boundary)', async () => {
const now = Date.now()
const lastActivity = now - (24 * HOUR_MS) // Exactly 24h
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['123456789@lid', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
// Exactly 24h should NOT delete (> threshold, not >=)
expect(stats.lidOrphansDeleted).toBe(0)
})
it('should handle empty session list', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({})
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
it('should handle null sessionActivityTracker gracefully', async () => {
const now = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
// Pass null tracker
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
null,
logger,
config
)
const stats = await cleanup.runCleanup()
// Should still work, but with no activity data
expect(stats.totalScanned).toBe(1)
// LID orphan with no activity tracking gets deleted
expect(stats.lidOrphansDeleted).toBe(1)
})
})
describe('Mixed Scenarios', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should delete multiple sessions of different types', async () => {
const now = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('lid-orphan'),
'5511999999999_0.1': Buffer.from('secondary-inactive'),
'5511888888888_0.0': Buffer.from('primary-inactive'),
'5511777777777_0.0': Buffer.from('primary-active')
})
mockLidMapping.getPNForLID.mockImplementation(async (lid: string) => {
if (lid === '123456789@lid') return null // Orphan
return '5511999999999@s.whatsapp.net'
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([
['123456789@lid', now - (25 * HOUR_MS)], // LID orphan: 25h ago
['5511999999999:1@s.whatsapp.net', now - (16 * DAY_MS)], // Secondary: 16 days ago
['5511888888888@s.whatsapp.net', now - (31 * DAY_MS)], // Primary: 31 days ago
['5511777777777@s.whatsapp.net', now - (5 * DAY_MS)] // Primary: 5 days ago (active)
])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(4)
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.secondaryDevicesDeleted).toBe(1)
expect(stats.primaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(3)
})
})
describe('Configuration', () => {
it('should respect disabled cleanup', async () => {
const config = {
enabled: false,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(0)
expect(mockKeys.get).not.toHaveBeenCalled()
})
it('should respect custom thresholds', async () => {
const customConfig = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 5, // Custom: 5 days
primaryDeviceInactiveDays: 10, // Custom: 10 days
lidOrphanHours: 12 // Custom: 12 hours
}
const now = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('lid-orphan'),
'5511999999999_0.1': Buffer.from('secondary')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([
['123456789@lid', now - (13 * HOUR_MS)], // 13h ago
['5511999999999:1@s.whatsapp.net', now - (6 * DAY_MS)] // 6 days ago
])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
customConfig
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1) // 13h > 12h threshold
expect(stats.secondaryDevicesDeleted).toBe(1) // 6d > 5d threshold
expect(stats.totalDeleted).toBe(2)
})
})
})