/* * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors * SPDX-License-Identifier: AGPL-3.0-only */ import type Redis from 'ioredis'; import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { BucketRateLimit, Keyed, LegacyRateLimit } from '@/misc/rate-limit-utils.js'; /* eslint-disable @typescript-eslint/no-non-null-assertion */ describe(SkRateLimiterService, () => { let mockTimeService: { now: number, date: Date } = null!; let mockRedis: Array<(command: [string, ...unknown[]]) => [Error | null, unknown] | null> = null!; let mockRedisExec: (batch: [string, ...unknown[]][]) => Promise<[Error | null, unknown][] | null> = null!; let mockEnvironment: Record = null!; let serviceUnderTest: () => SkRateLimiterService = null!; beforeEach(() => { mockTimeService = { now: 0, get date() { return new Date(mockTimeService.now); }, }; mockRedis = []; mockRedisExec = (batch) => { const results: [Error | null, unknown][] = batch.map(command => { const handlerResults = mockRedis.map(handler => handler(command)); const finalResult = handlerResults.findLast(result => result != null); return finalResult ?? [new Error('test error: no handler'), null]; }); return Promise.resolve(results); }; const mockRedisClient = { multi(batch: [string, ...unknown[]][]) { return { watch() { return { exec() { return mockRedisExec(batch); }, }; }, }; }, } as unknown as Redis.Redis; mockEnvironment = Object.create(process.env); mockEnvironment.NODE_ENV = 'production'; const mockEnvService = { env: mockEnvironment, }; let service: SkRateLimiterService | undefined = undefined; serviceUnderTest = () => { return service ??= new SkRateLimiterService(mockTimeService, mockRedisClient, mockEnvService); }; }); describe('limit', () => { const actor = 'actor'; const key = 'test'; let limitCounter: number | undefined = undefined; let limitTimestamp: number | undefined = undefined; let minCounter: number | undefined = undefined; let minTimestamp: number | undefined = undefined; beforeEach(() => { limitCounter = undefined; limitTimestamp = undefined; minCounter = undefined; minTimestamp = undefined; mockRedis.push(([command, ...args]) => { if (command === 'set' && args[0] === 'rl_actor_test_bucket_t') { limitTimestamp = parseInt(args[1] as string); return [null, args[1]]; } if (command === 'get' && args[0] === 'rl_actor_test_bucket_t') { return [null, limitTimestamp?.toString() ?? null]; } // if (command === 'incr' && args[0] === 'rl_actor_test_bucket_c') { // limitCounter = (limitCounter ?? 0) + 1; // return [null, null]; // } if (command === 'set' && args[0] === 'rl_actor_test_bucket_c') { limitCounter = parseInt(args[1] as string); return [null, args[1]]; } if (command === 'get' && args[0] === 'rl_actor_test_bucket_c') { return [null, limitCounter?.toString() ?? null]; } if (command === 'set' && args[0] === 'rl_actor_test_min_t') { minTimestamp = parseInt(args[1] as string); return [null, args[1]]; } if (command === 'get' && args[0] === 'rl_actor_test_min_t') { return [null, minTimestamp?.toString() ?? null]; } // if (command === 'incr' && args[0] === 'rl_actor_test_min_c') { // minCounter = (minCounter ?? 0) + 1; // return [null, null]; // } if (command === 'set' && args[0] === 'rl_actor_test_min_c') { minCounter = parseInt(args[1] as string); return [null, args[1]]; } if (command === 'get' && args[0] === 'rl_actor_test_min_c') { return [null, minCounter?.toString() ?? null]; } // if (command === 'expire') { // return [null, null]; // } return null; }); }); it('should bypass in test environment', async () => { mockEnvironment.NODE_ENV = 'test'; const info = await serviceUnderTest().limit({ key: 'l', type: undefined, max: 0 }, actor); expect(info.blocked).toBeFalsy(); expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); expect(info.resetSec).toBe(0); expect(info.resetMs).toBe(0); expect(info.fullResetSec).toBe(0); expect(info.fullResetMs).toBe(0); }); describe('with bucket limit', () => { let limit: Keyed = null!; beforeEach(() => { limit = { type: 'bucket', key: 'test', size: 1, }; }); it('should allow when limit is not reached', async () => { const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); }); it('should return correct info when allowed', async () => { limit.size = 2; limitCounter = 1; limitTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.remaining).toBe(0); expect(info.resetSec).toBe(1); expect(info.resetMs).toBe(1000); expect(info.fullResetSec).toBe(2); expect(info.fullResetMs).toBe(2000); }); it('should increment counter when called', async () => { await serviceUnderTest().limit(limit, actor); expect(limitCounter).toBe(1); }); it('should set timestamp when called', async () => { mockTimeService.now = 1000; await serviceUnderTest().limit(limit, actor); expect(limitTimestamp).toBe(1000); }); it('should decrement counter when dripRate has passed', async () => { limitCounter = 2; limitTimestamp = 0; mockTimeService.now = 2000; await serviceUnderTest().limit(limit, actor); expect(limitCounter).toBe(1); // 2 (starting) - 2 (2x1 drip) + 1 (call) = 1 }); it('should decrement counter by dripSize', async () => { limitCounter = 2; limitTimestamp = 0; limit.dripSize = 2; mockTimeService.now = 1000; await serviceUnderTest().limit(limit, actor); expect(limitCounter).toBe(1); // 2 (starting) - 2 (1x2 drip) + 1 (call) = 1 }); it('should maintain counter between calls over time', async () => { limit.size = 5; await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 mockTimeService.now += 1000; // 1 - 1 = 0 await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2 await serviceUnderTest().limit(limit, actor); // 2 + 1 = 3 mockTimeService.now += 1000; // 3 - 1 = 2 mockTimeService.now += 1000; // 2 - 1 = 1 await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2 expect(limitCounter).toBe(2); expect(limitTimestamp).toBe(3000); }); it('should block when bucket is filled', async () => { limitCounter = 1; limitTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); }); it('should calculate correct info when blocked', async () => { limitCounter = 1; limitTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.resetSec).toBe(1); expect(info.resetMs).toBe(1000); expect(info.fullResetSec).toBe(1); expect(info.fullResetMs).toBe(1000); }); it('should allow when bucket is filled but should drip', async () => { limitCounter = 1; limitTimestamp = 0; mockTimeService.now = 1000; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); }); it('should scale limit by factor', async () => { limitCounter = 1; limitTimestamp = 0; const i1 = await serviceUnderTest().limit(limit, actor, 0.5); // 1 + 1 = 2 const i2 = await serviceUnderTest().limit(limit, actor, 0.5); // 2 + 1 = 3 expect(i1.blocked).toBeFalsy(); expect(i2.blocked).toBeTruthy(); }); it('should set counter expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]); }); it('should set timestamp expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]); }); it('should not increment when already blocked', async () => { limitCounter = 1; limitTimestamp = 0; mockTimeService.now += 100; await serviceUnderTest().limit(limit, actor); expect(limitCounter).toBe(1); expect(limitTimestamp).toBe(0); }); it('should skip if factor is zero', async () => { const info = await serviceUnderTest().limit(limit, actor, 0); expect(info.blocked).toBeFalsy(); expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); }); it('should throw if factor is negative', async () => { const promise = serviceUnderTest().limit(limit, actor, -1); await expect(promise).rejects.toThrow(/factor is zero or negative/); }); it('should throw if size is zero', async () => { limit.size = 0; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/size is less than 1/); }); it('should throw if size is negative', async () => { limit.size = -1; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/size is less than 1/); }); it('should throw if size is fraction', async () => { limit.size = 0.5; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/size is less than 1/); }); it('should throw if dripRate is zero', async () => { limit.dripRate = 0; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/dripRate is less than 1/); }); it('should throw if dripRate is negative', async () => { limit.dripRate = -1; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/dripRate is less than 1/); }); it('should throw if dripRate is fraction', async () => { limit.dripRate = 0.5; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/dripRate is less than 1/); }); it('should throw if dripSize is zero', async () => { limit.dripSize = 0; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/dripSize is less than 1/); }); it('should throw if dripSize is negative', async () => { limit.dripSize = -1; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/dripSize is less than 1/); }); it('should throw if dripSize is fraction', async () => { limit.dripSize = 0.5; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/dripSize is less than 1/); }); it('should retry when redis conflicts', async () => { let numCalls = 0; const realMockRedisExec = mockRedisExec; mockRedisExec = () => { if (numCalls > 0) { mockRedisExec = realMockRedisExec; } numCalls++; return Promise.resolve(null); }; await serviceUnderTest().limit(limit, actor); expect(numCalls).toBe(2); }); it('should bail out after 3 tries', async () => { let numCalls = 0; mockRedisExec = () => { numCalls++; return Promise.resolve(null); }; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/transaction conflict/); expect(numCalls).toBe(3); }); it('should apply correction if extra calls slip through', async () => { limitCounter = 2; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); expect(info.remaining).toBe(0); expect(info.resetMs).toBe(2000); expect(info.resetSec).toBe(2); expect(info.fullResetMs).toBe(2000); expect(info.fullResetSec).toBe(2); }); }); describe('with min interval', () => { let limit: Keyed = null!; beforeEach(() => { limit = { type: undefined, key, minInterval: 1000, }; }); it('should allow when limit is not reached', async () => { const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); }); it('should calculate correct info when allowed', async () => { const info = await serviceUnderTest().limit(limit, actor); expect(info.remaining).toBe(0); expect(info.resetSec).toBe(1); expect(info.resetMs).toBe(1000); expect(info.fullResetSec).toBe(1); expect(info.fullResetMs).toBe(1000); }); it('should increment counter when called', async () => { await serviceUnderTest().limit(limit, actor); expect(minCounter).not.toBeUndefined(); expect(minCounter).toBe(1); }); it('should set timestamp when called', async () => { mockTimeService.now = 1000; await serviceUnderTest().limit(limit, actor); expect(minCounter).not.toBeUndefined(); expect(minTimestamp).toBe(1000); }); it('should decrement counter when minInterval has passed', async () => { minCounter = 1; minTimestamp = 0; mockTimeService.now = 1000; await serviceUnderTest().limit(limit, actor); expect(minCounter).not.toBeUndefined(); expect(minCounter).toBe(1); // 1 (starting) - 1 (interval) + 1 (call) = 1 }); it('should reset counter entirely', async () => { minCounter = 2; minTimestamp = 0; mockTimeService.now = 1000; await serviceUnderTest().limit(limit, actor); expect(minCounter).not.toBeUndefined(); expect(minCounter).toBe(1); // 2 (starting) - 2 (interval) + 1 (call) = 1 }); it('should maintain counter between calls over time', async () => { await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 mockTimeService.now += 1000; // 1 - 1 = 0 await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 await serviceUnderTest().limit(limit, actor); // blocked await serviceUnderTest().limit(limit, actor); // blocked mockTimeService.now += 1000; // 1 - 1 = 0 mockTimeService.now += 1000; // 0 - 1 = 0 await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 expect(minCounter).toBe(1); expect(minTimestamp).toBe(3000); }); it('should block when interval exceeded', async () => { minCounter = 1; minTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); }); it('should calculate correct info when blocked', async () => { minCounter = 1; minTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.resetSec).toBe(1); expect(info.resetMs).toBe(1000); expect(info.fullResetSec).toBe(1); expect(info.fullResetMs).toBe(1000); }); it('should allow when bucket is filled but interval has passed', async () => { minCounter = 1; minTimestamp = 0; mockTimeService.now = 1000; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); }); it('should scale interval by factor', async () => { minCounter = 1; minTimestamp = 0; mockTimeService.now += 500; const info = await serviceUnderTest().limit(limit, actor, 0.5); expect(info.blocked).toBeFalsy(); }); it('should set counter expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_min_c', '1', 'EX', 1]); }); it('should set timestamp expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_min_t', '0', 'EX', 1]); }); it('should not increment when already blocked', async () => { minCounter = 1; minTimestamp = 0; mockTimeService.now += 100; await serviceUnderTest().limit(limit, actor); expect(minCounter).toBe(1); expect(minTimestamp).toBe(0); }); it('should skip if factor is zero', async () => { const info = await serviceUnderTest().limit(limit, actor, 0); expect(info.blocked).toBeFalsy(); expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); }); it('should throw if factor is negative', async () => { const promise = serviceUnderTest().limit(limit, actor, -1); await expect(promise).rejects.toThrow(/factor is zero or negative/); }); it('should skip if minInterval is zero', async () => { limit.minInterval = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); }); it('should throw if minInterval is negative', async () => { limit.minInterval = -1; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/minInterval is negative/); }); it('should not apply correction to extra calls', async () => { minCounter = 2; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); expect(info.remaining).toBe(0); expect(info.resetMs).toBe(1000); expect(info.resetSec).toBe(1); expect(info.fullResetMs).toBe(1000); expect(info.fullResetSec).toBe(1); }); }); describe('with legacy limit', () => { let limit: Keyed = null!; beforeEach(() => { limit = { type: undefined, key, max: 1, duration: 1000, }; }); it('should allow when limit is not reached', async () => { const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); }); it('should infer dripRate from duration', async () => { limit.max = 10; limit.duration = 10000; limitCounter = 10; limitTimestamp = 0; const i1 = await serviceUnderTest().limit(limit, actor); mockTimeService.now += 1000; const i2 = await serviceUnderTest().limit(limit, actor); mockTimeService.now += 2000; const i3 = await serviceUnderTest().limit(limit, actor); const i4 = await serviceUnderTest().limit(limit, actor); const i5 = await serviceUnderTest().limit(limit, actor); mockTimeService.now += 2000; const i6 = await serviceUnderTest().limit(limit, actor); expect(i1.blocked).toBeTruthy(); expect(i2.blocked).toBeFalsy(); expect(i3.blocked).toBeFalsy(); expect(i4.blocked).toBeFalsy(); expect(i5.blocked).toBeTruthy(); expect(i6.blocked).toBeFalsy(); }); it('should calculate correct info when allowed', async () => { limit.max = 10; limit.duration = 10000; limitCounter = 10; limitTimestamp = 0; mockTimeService.now += 2000; const info = await serviceUnderTest().limit(limit, actor); expect(info.remaining).toBe(1); expect(info.resetSec).toBe(0); expect(info.resetMs).toBe(0); expect(info.fullResetSec).toBe(9); expect(info.fullResetMs).toBe(9000); }); it('should calculate correct info when blocked', async () => { limit.max = 10; limit.duration = 10000; limitCounter = 10; limitTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.remaining).toBe(0); expect(info.resetSec).toBe(1); expect(info.resetMs).toBe(1000); expect(info.fullResetSec).toBe(10); expect(info.fullResetMs).toBe(10000); }); it('should allow when bucket is filled but interval has passed', async () => { limitCounter = 10; limitTimestamp = 0; mockTimeService.now = 1000; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); }); it('should scale limit by factor', async () => { limitCounter = 10; limitTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor, 0.5); // 10 + 1 = 11 expect(info.blocked).toBeTruthy(); }); it('should set counter expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]); }); it('should set timestamp expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]); }); it('should not increment when already blocked', async () => { limitCounter = 1; limitTimestamp = 0; mockTimeService.now += 100; await serviceUnderTest().limit(limit, actor); expect(limitCounter).toBe(1); expect(limitTimestamp).toBe(0); }); it('should not allow dripRate to be lower than 0', async () => { // real-world case; taken from StreamingApiServerService limit.max = 4096; limit.duration = 2000; limitCounter = 4096; limitTimestamp = 0; const i1 = await serviceUnderTest().limit(limit, actor); mockTimeService.now = 1; const i2 = await serviceUnderTest().limit(limit, actor); expect(i1.blocked).toBeTruthy(); expect(i2.blocked).toBeFalsy(); }); it('should skip if factor is zero', async () => { const info = await serviceUnderTest().limit(limit, actor, 0); expect(info.blocked).toBeFalsy(); expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); }); it('should throw if factor is negative', async () => { const promise = serviceUnderTest().limit(limit, actor, -1); await expect(promise).rejects.toThrow(/factor is zero or negative/); }); it('should throw if max is zero', async () => { limit.max = 0; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/size is less than 1/); }); it('should throw if max is negative', async () => { limit.max = -1; const promise = serviceUnderTest().limit(limit, actor); await expect(promise).rejects.toThrow(/size is less than 1/); }); it('should apply correction if extra calls slip through', async () => { limitCounter = 2; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); expect(info.remaining).toBe(0); expect(info.resetMs).toBe(2000); expect(info.resetSec).toBe(2); expect(info.fullResetMs).toBe(2000); expect(info.fullResetSec).toBe(2); }); }); describe('with legacy limit and min interval', () => { let limit: Keyed = null!; beforeEach(() => { limit = { type: undefined, key, max: 5, duration: 5000, minInterval: 1000, }; }); it('should allow when limit is not reached', async () => { const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); }); it('should block when limit exceeded', async () => { limitCounter = 5; limitTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); }); it('should block when minInterval exceeded', async () => { minCounter = 1; minTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); }); it('should calculate correct info when allowed', async () => { limitCounter = 1; limitTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.remaining).toBe(0); expect(info.resetSec).toBe(1); expect(info.resetMs).toBe(1000); expect(info.fullResetSec).toBe(2); expect(info.fullResetMs).toBe(2000); }); it('should calculate correct info when blocked by limit', async () => { limitCounter = 5; limitTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.remaining).toBe(0); expect(info.resetSec).toBe(1); expect(info.resetMs).toBe(1000); expect(info.fullResetSec).toBe(5); expect(info.fullResetMs).toBe(5000); }); it('should calculate correct info when blocked by minInterval', async () => { minCounter = 1; minTimestamp = 0; const info = await serviceUnderTest().limit(limit, actor); expect(info.remaining).toBe(0); expect(info.resetSec).toBe(1); expect(info.resetMs).toBe(1000); expect(info.fullResetSec).toBe(1); expect(info.fullResetMs).toBe(1000); }); it('should allow when counter is filled but interval has passed', async () => { limitCounter = 5; limitTimestamp = 0; mockTimeService.now = 1000; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); }); it('should allow when minCounter is filled but interval has passed', async () => { minCounter = 1; minTimestamp = 0; mockTimeService.now = 1000; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeFalsy(); }); it('should scale limit and interval by factor', async () => { limitCounter = 5; limitTimestamp = 0; minCounter = 1; minTimestamp = 0; mockTimeService.now += 500; const info = await serviceUnderTest().limit(limit, actor, 0.5); expect(info.blocked).toBeFalsy(); }); it('should set bucket counter expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]); }); it('should set bucket timestamp expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]); }); it('should set min counter expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_min_c', '1', 'EX', 1]); }); it('should set min timestamp expiration', async () => { const commands: unknown[][] = []; mockRedis.push(command => { commands.push(command); return null; }); await serviceUnderTest().limit(limit, actor); expect(commands).toContainEqual(['set', 'rl_actor_test_min_t', '0', 'EX', 1]); }); it('should not increment when already blocked', async () => { limitCounter = 5; limitTimestamp = 0; minCounter = 1; minTimestamp = 0; mockTimeService.now += 100; await serviceUnderTest().limit(limit, actor); expect(limitCounter).toBe(5); expect(limitTimestamp).toBe(0); expect(minCounter).toBe(1); expect(minTimestamp).toBe(0); }); it('should apply correction if extra calls slip through', async () => { limitCounter = 6; minCounter = 6; const info = await serviceUnderTest().limit(limit, actor); expect(info.blocked).toBeTruthy(); expect(info.remaining).toBe(0); expect(info.resetMs).toBe(2000); expect(info.resetSec).toBe(2); expect(info.fullResetMs).toBe(6000); expect(info.fullResetSec).toBe(6); }); }); }); });