import { describe, it, expect, vi } from 'vitest';
import { RateLimiter, createShopifyRateLimiter } from '../../src/middleware/rate-limiter.js';
// ─── tryConsume ───
describe('RateLimiter — tryConsume', () => {
it('allows consumption within the limit', () => {
const limiter = new RateLimiter({
maxTokens: 5,
refillRate: 1,
refillInterval: 1000,
});
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(true);
});
it('blocks when tokens are exhausted', () => {
const limiter = new RateLimiter({
maxTokens: 2,
refillRate: 1,
refillInterval: 60000, // 60s — effectively no refill during test
});
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(false); // exhausted
});
it('allows consuming multiple tokens at once', () => {
const limiter = new RateLimiter({
maxTokens: 10,
refillRate: 1,
refillInterval: 60000,
});
expect(limiter.tryConsume('key-a', 5)).toBe(true);
expect(limiter.tryConsume('key-a', 5)).toBe(true);
expect(limiter.tryConsume('key-a', 1)).toBe(false);
});
});
// ─── Refill ───
describe('RateLimiter — refill', () => {
it('replenishes tokens after the refill interval', () => {
vi.useFakeTimers();
try {
const limiter = new RateLimiter({
maxTokens: 2,
refillRate: 2, // 2 tokens per interval
refillInterval: 1000, // 1 second
});
// Drain all tokens
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(false);
// Advance time by 1 second — should refill 2 tokens
vi.advanceTimersByTime(1000);
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('does not exceed maxTokens after refill', () => {
vi.useFakeTimers();
try {
const limiter = new RateLimiter({
maxTokens: 3,
refillRate: 10, // aggressive refill
refillInterval: 1000,
});
// Consume one token
limiter.tryConsume('key-a');
// Advance time to trigger refill way beyond max
vi.advanceTimersByTime(10000);
// Should still only have maxTokens available
expect(limiter.getRemainingTokens('key-a')).toBeLessThanOrEqual(3);
} finally {
vi.useRealTimers();
}
});
});
// ─── Per-key isolation ───
describe('RateLimiter — per-key isolation', () => {
it('tracks separate buckets for different keys', () => {
const limiter = new RateLimiter({
maxTokens: 2,
refillRate: 1,
refillInterval: 60000,
});
// Exhaust key-a
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(true);
expect(limiter.tryConsume('key-a')).toBe(false);
// key-b should still be full
expect(limiter.tryConsume('key-b')).toBe(true);
expect(limiter.tryConsume('key-b')).toBe(true);
expect(limiter.tryConsume('key-b')).toBe(false);
});
it('returns maxTokens for a key that has not been used', () => {
const limiter = new RateLimiter({
maxTokens: 40,
refillRate: 2,
refillInterval: 1000,
});
expect(limiter.getRemainingTokens('new-key')).toBe(40);
});
it('reset restores a key to full capacity', () => {
const limiter = new RateLimiter({
maxTokens: 5,
refillRate: 1,
refillInterval: 60000,
});
limiter.tryConsume('key-a');
limiter.tryConsume('key-a');
limiter.tryConsume('key-a');
limiter.reset('key-a');
expect(limiter.getRemainingTokens('key-a')).toBe(5);
});
});
// ─── createShopifyRateLimiter factory ───
describe('createShopifyRateLimiter', () => {
it('creates a limiter with default Shopify config', () => {
const limiter = createShopifyRateLimiter();
// Should have 40 tokens initially
expect(limiter.getRemainingTokens('test-key')).toBe(40);
// Consume all 40
for (let i = 0; i < 40; i++) {
expect(limiter.tryConsume('test-key')).toBe(true);
}
// 41st should fail
expect(limiter.tryConsume('test-key')).toBe(false);
});
});