@sailboat-computer/data-storage
Version:
Shared data storage library for sailboat computer v3
277 lines (243 loc) • 9.58 kB
text/typescript
/**
* Rate Limiting Tests
*
* Tests for the rate limiting functionality in the UnifiedStorageManager.
*/
import { createUnifiedStorageManager } from '../../src/storage/UnifiedStorageManagerImpl';
import { StorageConfig, StorageTier } from '../../src/types';
import { MockProvider, MockProviderState } from '../mocks/mock-provider';
// Mock providers
const mockRedisProvider = new MockProvider();
const mockLocalFileProvider = {
initialize: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
store: jest.fn().mockImplementation((data, metadata) => {
return Promise.resolve(`local-${Date.now()}`);
}),
retrieve: jest.fn().mockImplementation((query) => {
return Promise.resolve([
{
data: { value: 42 },
metadata: {
id: query.id || 'test-id',
category: 'test-category',
timestamp: new Date().toISOString(),
tags: {},
tier: StorageTier.HOT
}
}
]);
}),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
cleanup: jest.fn().mockResolvedValue({
itemsRemoved: 10,
bytesFreed: 1024,
duration: 100
}),
getStatus: jest.fn().mockResolvedValue({
connected: true,
healthy: true,
metrics: {
operationsPerSecond: 100,
averageLatency: 5,
errorRate: 0,
storageUsed: 1024 * 1024,
storageAvailable: 1024 * 1024 * 100
}
}),
getLocalStorageStatus: jest.fn().mockResolvedValue({
available: true,
healthy: true,
totalSizeBytes: 1024 * 1024 * 100,
usedSizeBytes: 1024 * 1024,
freeSizeBytes: 1024 * 1024 * 99,
itemCount: 100
}),
clearAll: jest.fn().mockResolvedValue({
success: true,
itemsRemoved: 100,
bytesFreed: 1024 * 1024,
durationMs: 100
})
};
// Mock the local file provider
jest.mock('../../src/storage/providers/local-file', () => {
return {
createLocalFileProvider: jest.fn(() => mockLocalFileProvider)
};
});
// Mock the StorageManagerImpl class
jest.mock('../../src/storage/manager', () => {
return {
StorageManagerImpl: jest.fn().mockImplementation(() => {
return {
initialize: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
store: jest.fn().mockImplementation((data, category) => {
return Promise.resolve(`mock-${Date.now()}`);
}),
retrieve: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
storeBatch: jest.fn().mockResolvedValue([]),
retrieveBatch: jest.fn().mockResolvedValue([]),
cleanup: jest.fn().mockResolvedValue({
itemsRemoved: 0,
bytesFreed: 0,
duration: 0
}),
migrate: jest.fn().mockResolvedValue(undefined),
getStatus: jest.fn().mockResolvedValue({
connected: true,
healthy: true,
queryPerformance: {
hot: { averageQueryTime: 0, queriesPerSecond: 0 },
warm: { averageQueryTime: 0, queriesPerSecond: 0 },
cold: { averageQueryTime: 0, queriesPerSecond: 0 },
overall: 'healthy',
providers: {
hot: 'healthy',
warm: 'healthy',
cold: 'healthy'
}
}
})
};
})
};
});
describe('Rate Limiting Tests', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
const createStorageConfig = (): StorageConfig => ({
providers: {
hot: {
type: 'redis',
config: {
host: 'localhost',
port: 6379
}
}
}
});
it('should apply rate limiting to operations', async () => {
// Arrange
const storageManager = createUnifiedStorageManager(createStorageConfig(), {
resilience: {
rateLimit: {
defaultTokensPerSecond: 2, // 2 operations per second
defaultBucketSize: 2, // Allow bursts of up to 2 operations
waitWhenLimited: false, // Don't wait when rate limited
maxWaitTimeMs: 1000
}
}
});
await storageManager.initialize();
// Act & Assert
// First two operations should succeed (using the initial bucket tokens)
await expect(storageManager.store({ value: 1 }, 'test-category')).resolves.toBeDefined();
await expect(storageManager.store({ value: 2 }, 'test-category')).resolves.toBeDefined();
// Third operation should fail due to rate limiting
await expect(storageManager.store({ value: 3 }, 'test-category')).rejects.toThrow('Rate limit exceeded');
// Check metrics
const metrics = storageManager.getResilienceMetrics();
expect(metrics.rateLimit.limitedCount['store']).toBe(1);
expect(metrics.rateLimit.currentTokens['store']).toBeLessThan(1);
// Advance time to replenish tokens
jest.advanceTimersByTime(1000); // Advance 1 second
// Now the operation should succeed
await expect(storageManager.store({ value: 4 }, 'test-category')).resolves.toBeDefined();
});
it('should wait when rate limited if waitWhenLimited is true', async () => {
// Arrange
const storageManager = createUnifiedStorageManager(createStorageConfig(), {
resilience: {
rateLimit: {
defaultTokensPerSecond: 2, // 2 operations per second
defaultBucketSize: 2, // Allow bursts of up to 2 operations
waitWhenLimited: true, // Wait when rate limited
maxWaitTimeMs: 2000
}
}
});
await storageManager.initialize();
// Act & Assert
// First two operations should succeed immediately
const start = Date.now();
await storageManager.store({ value: 1 }, 'test-category');
await storageManager.store({ value: 2 }, 'test-category');
// Third operation should wait for a token
const storePromise = storageManager.store({ value: 3 }, 'test-category');
// Advance time to replenish tokens
jest.advanceTimersByTime(500); // Advance 0.5 seconds (should replenish 1 token)
// Wait for the operation to complete
await storePromise;
// Check metrics
const metrics = storageManager.getResilienceMetrics();
expect(metrics.rateLimit.waitTimeMs['store']).toBeGreaterThan(0);
});
it('should bypass rate limiting for specified operations', async () => {
// Arrange
const storageManager = createUnifiedStorageManager(createStorageConfig(), {
resilience: {
rateLimit: {
defaultTokensPerSecond: 2, // 2 operations per second
defaultBucketSize: 2, // Allow bursts of up to 2 operations
waitWhenLimited: false, // Don't wait when rate limited
maxWaitTimeMs: 1000,
bypassOperations: ['retrieve'] // Bypass rate limiting for retrieve operations
}
}
});
await storageManager.initialize();
// Act & Assert
// First two store operations should succeed
await expect(storageManager.store({ value: 1 }, 'test-category')).resolves.toBeDefined();
await expect(storageManager.store({ value: 2 }, 'test-category')).resolves.toBeDefined();
// Third store operation should fail due to rate limiting
await expect(storageManager.store({ value: 3 }, 'test-category')).rejects.toThrow('Rate limit exceeded');
// But retrieve operations should bypass rate limiting
await expect(storageManager.retrieve({ category: 'test-category' })).resolves.toBeDefined();
await expect(storageManager.retrieve({ category: 'test-category' })).resolves.toBeDefined();
await expect(storageManager.retrieve({ category: 'test-category' })).resolves.toBeDefined();
// Check metrics
const metrics = storageManager.getResilienceMetrics();
expect(metrics.rateLimit.bypassCount['retrieve']).toBe(3);
});
it('should apply operation-specific rate limits', async () => {
// Arrange
const storageManager = createUnifiedStorageManager(createStorageConfig(), {
resilience: {
rateLimit: {
defaultTokensPerSecond: 2, // 2 operations per second
defaultBucketSize: 2, // Allow bursts of up to 2 operations
waitWhenLimited: false, // Don't wait when rate limited
maxWaitTimeMs: 1000,
operationLimits: {
store: {
tokensPerSecond: 1, // 1 operation per second for store
bucketSize: 1 // Allow bursts of up to 1 operation for store
}
}
}
}
});
await storageManager.initialize();
// Act & Assert
// First store operation should succeed
await expect(storageManager.store({ value: 1 }, 'test-category')).resolves.toBeDefined();
// Second store operation should fail due to operation-specific rate limiting
await expect(storageManager.store({ value: 2 }, 'test-category')).rejects.toThrow('Rate limit exceeded');
// But retrieve operations should use the default rate limit
await expect(storageManager.retrieve({ category: 'test-category' })).resolves.toBeDefined();
await expect(storageManager.retrieve({ category: 'test-category' })).resolves.toBeDefined();
// Third retrieve operation should fail due to default rate limiting
await expect(storageManager.retrieve({ category: 'test-category' })).rejects.toThrow('Rate limit exceeded');
});
});