import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Logger } from '@nestjs/common'; import type { QueueHandle } from '@mosaicstack/queue'; import type { LogService } from '@mosaicstack/log'; import { SessionGCService } from './session-gc.service.js'; import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; type MockRedis = { scan: ReturnType; del: ReturnType; }; describe('SessionGCService', () => { let service: SessionGCService; let mockRedis: MockRedis; let mockLogService: { logs: { promoteSessionToWarm: ReturnType; promoteToWarm: ReturnType; }; }; /** * Helper: build a scan mock that returns all provided keys in a single * cursor iteration (cursor '0' in → ['0', keys] out). */ function makeScanMock(keys: string[]): ReturnType { return vi.fn().mockResolvedValue(['0', keys]); } beforeEach(() => { mockRedis = { scan: makeScanMock([]), del: vi.fn().mockResolvedValue(0), }; mockLogService = { logs: { promoteSessionToWarm: vi.fn().mockResolvedValue(0), promoteToWarm: vi.fn().mockResolvedValue(0), }, }; // Suppress logger output in tests vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); service = new SessionGCService( mockRedis as unknown as QueueHandle['redis'], mockLogService as unknown as LogService, ); }); it('collect() deletes Valkey keys for session', async () => { mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:abc:foo']); const result = await service.collect('abc'); expect(mockRedis.del).toHaveBeenCalledWith( 'mosaic:session:abc:system', 'mosaic:session:abc:foo', ); expect(result.cleaned.valkeyKeys).toBe(2); }); it('collect() with no keys returns empty cleaned valkeyKeys', async () => { mockRedis.scan = makeScanMock([]); const result = await service.collect('abc'); expect(result.cleaned.valkeyKeys).toBeUndefined(); }); it('escapes glob metacharacters in a session identifier', async () => { await service.collect('abc*?[tenant]\\escape'); expect(mockRedis.scan).toHaveBeenCalledWith( '0', 'MATCH', 'mosaic:session:abc\\*\\?\\[tenant\\]\\\\escape:*', 'COUNT', 100, ); }); it('preserves a valid durable approval after session GC', async () => { const entries = new Map(); const redis = { scan: vi.fn().mockResolvedValue(['0', ['mosaic:session:owned:state']]), get: vi.fn(async (key: string) => entries.get(key) ?? null), set: vi.fn(async (key: string, value: string) => entries.set(key, value)), del: vi.fn(async (...keys: string[]) => { let deleted = 0; for (const key of keys) deleted += Number(entries.delete(key)); return deleted; }), }; const authorization = new CommandAuthorizationService( { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }), }), } as never, redis, ); const command = { name: 'gc', description: 'System-wide garbage collection', aliases: [], scope: 'admin', execution: 'socket', available: true, } as never; const payload = { command: 'gc', conversationId: 'owned' }; const approval = await authorization.createApproval(command, payload, 'admin-1'); const approvalKey = `interaction:command-approval:${approval!.approvalId}`; const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService); await gc.collect('owned'); expect(entries.has(approvalKey)).toBe(true); await expect( authorization.authorize(command, payload, 'admin-1', approval!.approvalId), ).resolves.toEqual({ allowed: true }); }); it('collect() returns sessionId in result', async () => { const result = await service.collect('test-session-id'); expect(result.sessionId).toBe('test-session-id'); }); it('collect() demotes logs only for the requested session', async () => { await service.collect('owned-session'); expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith( 'owned-session', expect.any(Date), ); expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled(); }); it('does not expose automatic global GC entry points', () => { expect('fullCollect' in service).toBe(false); expect('sweepOrphans' in service).toBe(false); }); });