142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
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<typeof vi.fn>;
|
|
del: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
describe('SessionGCService', () => {
|
|
let service: SessionGCService;
|
|
let mockRedis: MockRedis;
|
|
let mockLogService: {
|
|
logs: {
|
|
promoteSessionToWarm: ReturnType<typeof vi.fn>;
|
|
promoteToWarm: ReturnType<typeof vi.fn>;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 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<typeof vi.fn> {
|
|
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<string, string>();
|
|
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);
|
|
});
|
|
});
|