UNPKG

@sailboat-computer/data-storage

Version:

Shared data storage library for sailboat computer v3

277 lines (243 loc) 9.58 kB
/** * 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'); }); });