UNPKG

nostr-deploy-server

Version:

Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers

352 lines (287 loc) 11.4 kB
import { CacheInvalidationService } from '../../helpers/cache-invalidation'; import { CacheService } from '../../utils/cache'; import { ConfigManager } from '../../utils/config'; import { logger } from '../../utils/logger'; // Mock dependencies with proper setup jest.mock('../../utils/config', () => ({ ConfigManager: { getInstance: jest.fn().mockReturnValue({ getConfig: jest.fn().mockReturnValue({ logLevel: 'info', realtimeCacheInvalidation: false, invalidationRelays: [], invalidationTimeoutMs: 30000, invalidationReconnectDelayMs: 5000, }), }), }, })); jest.mock('../../utils/logger', () => ({ logger: { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn(), }, })); jest.mock('../../utils/cache'); jest.mock('nostr-tools', () => ({ SimplePool: jest.fn().mockImplementation(() => ({ subscribeMany: jest.fn(), close: jest.fn(), })), })); jest.mock('websocket-polyfill'); const mockCacheService = CacheService as jest.Mocked<typeof CacheService>; describe('CacheInvalidationService', () => { let mockConfig: any; beforeEach(() => { jest.clearAllMocks(); // Setup test-specific mock configuration mockConfig = { realtimeCacheInvalidation: true, invalidationRelays: ['wss://relay.primal.net', 'wss://relay.damus.io'], invalidationTimeoutMs: 30000, invalidationReconnectDelayMs: 5000, defaultRelays: ['wss://relay.primal.net', 'wss://nos.lol'], defaultBlossomServers: ['https://blossom.primal.net', 'https://cdn.satellite.earth'], }; // Update the mock to return our test config (ConfigManager.getInstance as jest.Mock).mockReturnValue({ getConfig: jest.fn().mockReturnValue(mockConfig), }); // Logger is already mocked at module level // Setup cache service mocks mockCacheService.invalidateBlobForPath = jest.fn().mockResolvedValue(undefined); mockCacheService.invalidateRelaysForPubkey = jest.fn().mockResolvedValue(undefined); mockCacheService.invalidateBlossomServersForPubkey = jest.fn().mockResolvedValue(undefined); mockCacheService.invalidateAllForPubkey = jest.fn().mockResolvedValue(undefined); mockCacheService.invalidateNegativeCache = jest.fn().mockResolvedValue(undefined); // Add new cache update methods mockCacheService.setBlobForPath = jest.fn().mockResolvedValue(undefined); mockCacheService.setRelaysForPubkey = jest.fn().mockResolvedValue(undefined); mockCacheService.setBlossomServersForPubkey = jest.fn().mockResolvedValue(undefined); }); describe('Initialization', () => { it('should initialize service when real-time invalidation is enabled', () => { const service = new CacheInvalidationService(); const stats = service.getStats(); expect(stats.enabled).toBe(true); expect(logger.info).toHaveBeenCalledWith('Real-time cache invalidation service enabled'); }); it('should not initialize service when real-time invalidation is disabled', () => { mockConfig.realtimeCacheInvalidation = false; const service = new CacheInvalidationService(); const stats = service.getStats(); expect(stats.enabled).toBe(false); expect(logger.info).toHaveBeenCalledWith('Real-time cache invalidation service disabled'); }); it('should use configured relays for invalidation', () => { const service = new CacheInvalidationService(); const stats = service.getStats(); expect(stats.relays).toEqual(['wss://relay.primal.net', 'wss://relay.damus.io']); }); }); describe('Event Handling', () => { let service: CacheInvalidationService; beforeEach(() => { service = new CacheInvalidationService(); }); it('should handle static file events and update path cache', async () => { const mockEvent = { pubkey: 'test-pubkey-123', tags: [ ['d', '/index.html'], ['x', 'abcdef1234567890'], ], kind: 34128, created_at: Math.floor(Date.now() / 1000), content: '', id: 'event-id', sig: 'signature', }; const handleStaticFileEvent = (service as any).handleStaticFileEvent.bind(service); await handleStaticFileEvent(mockEvent); expect(mockCacheService.setBlobForPath).toHaveBeenCalledWith( 'test-pubkey-123', '/index.html', { pubkey: 'test-pubkey-123', path: '/index.html', sha256: 'abcdef1234567890', created_at: mockEvent.created_at, } ); expect(logger.info).toHaveBeenCalledWith( expect.stringMatching(/✅ Cache UPDATED for static file: \/index\.html by test-pub.*/) ); }); it('should handle static file events without SHA256 and invalidate cache', async () => { const mockEvent = { pubkey: 'test-pubkey-no-hash', tags: [ ['d', '/no-hash.html'], // Missing 'x' tag with SHA256 ], kind: 34128, created_at: Math.floor(Date.now() / 1000), content: '', id: 'event-id-no-hash', sig: 'signature', }; const handleStaticFileEvent = (service as any).handleStaticFileEvent.bind(service); await handleStaticFileEvent(mockEvent); expect(mockCacheService.invalidateBlobForPath).toHaveBeenCalledWith( 'test-pubkey-no-hash', '/no-hash.html' ); expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching(/missing 'x' tag with SHA256 hash/) ); }); it('should handle relay list events and update relay cache', async () => { const mockEvent = { pubkey: 'test-pubkey-456', tags: [ ['r', 'wss://relay1.com', 'read'], ['r', 'wss://relay2.com'], ['r', 'wss://relay3.com', 'write'], // Should be ignored ], kind: 10002, created_at: Date.now(), content: '', id: 'relay-event-id', sig: 'signature', }; const handleRelayListEvent = (service as any).handleRelayListEvent.bind(service); await handleRelayListEvent(mockEvent); expect(mockCacheService.setRelaysForPubkey).toHaveBeenCalledWith('test-pubkey-456', [ 'wss://relay1.com', 'wss://relay2.com', ]); expect(logger.info).toHaveBeenCalledWith( expect.stringMatching(/✅ Relay list cache UPDATED for test-pub.*/) ); }); it('should handle blossom server events and update server cache', async () => { const mockEvent = { pubkey: 'test-pubkey-789', tags: [ ['server', 'https://blossom1.com'], ['server', 'https://blossom2.com'], ], kind: 10063, created_at: Date.now(), content: '', id: 'blossom-event-id', sig: 'signature', }; const handleBlossomServerEvent = (service as any).handleBlossomServerEvent.bind(service); await handleBlossomServerEvent(mockEvent); expect(mockCacheService.setBlossomServersForPubkey).toHaveBeenCalledWith('test-pubkey-789', [ 'https://blossom1.com', 'https://blossom2.com', ]); expect(logger.info).toHaveBeenCalledWith( expect.stringMatching(/✅ Blossom server cache UPDATED for test-pub.*/) ); }); it('should handle errors during event processing gracefully', async () => { mockCacheService.invalidateBlobForPath.mockRejectedValue(new Error('Cache error')); const mockEvent = { pubkey: 'test-pubkey-error', tags: [['d', '/error.html']], kind: 34128, created_at: Date.now(), content: '', id: 'event-id', sig: 'signature', }; const handleStaticFileEvent = (service as any).handleStaticFileEvent.bind(service); await handleStaticFileEvent(mockEvent); expect(logger.error).toHaveBeenCalledWith( expect.stringMatching(/❌ Error handling static file event for cache invalidation:/), expect.any(Error) ); }); }); describe('Statistics', () => { it('should provide service statistics', () => { const service = new CacheInvalidationService(); const stats = service.getStats(); expect(stats).toMatchObject({ enabled: true, connectedRelays: expect.any(Number), activeSubscriptions: expect.any(Number), relays: expect.any(Array), }); }); it('should show disabled state when service is disabled', () => { mockConfig.realtimeCacheInvalidation = false; const service = new CacheInvalidationService(); const stats = service.getStats(); expect(stats.enabled).toBe(false); }); }); describe('Graceful Shutdown', () => { it('should shutdown gracefully', async () => { const service = new CacheInvalidationService(); await service.shutdown(); expect(logger.info).toHaveBeenCalledWith('Shutting down cache invalidation service...'); expect(logger.info).toHaveBeenCalledWith('Cache invalidation service shutdown complete'); }); it('should handle shutdown errors gracefully', async () => { const service = new CacheInvalidationService(); // Mock error during shutdown const mockPool = (service as any).pool; mockPool.close = jest.fn().mockImplementation(() => { throw new Error('Shutdown error'); }); await service.shutdown(); expect(logger.error).toHaveBeenCalledWith( 'Error closing invalidation relay connections:', expect.any(Error) ); }); }); describe('Configuration Validation', () => { it('should handle empty relay list when service is enabled', () => { mockConfig.invalidationRelays = []; const service = new CacheInvalidationService(); const stats = service.getStats(); expect(stats.relays).toEqual([]); // Service should still be marked as enabled even with no relays expect(stats.enabled).toBe(true); }); it('should use default configuration values', () => { // Test that default values are used when environment variables are not set const service = new CacheInvalidationService(); expect(ConfigManager.getInstance).toHaveBeenCalled(); }); }); describe('Reconnection Logic', () => { it('should schedule reconnection on subscription close', async () => { jest.useFakeTimers(); const service = new CacheInvalidationService(); const scheduleReconnect = (service as any).scheduleReconnect.bind(service); scheduleReconnect(); // Fast forward time jest.advanceTimersByTime(5000); expect(logger.info).toHaveBeenCalledWith('Attempting to reconnect invalidation service...'); jest.useRealTimers(); }); it('should not reconnect when service is shutting down', async () => { jest.useFakeTimers(); const service = new CacheInvalidationService(); (service as any).isShuttingDown = true; const scheduleReconnect = (service as any).scheduleReconnect.bind(service); scheduleReconnect(); jest.advanceTimersByTime(10000); // Should not attempt to reconnect expect(logger.info).not.toHaveBeenCalledWith( 'Attempting to reconnect invalidation service...' ); jest.useRealTimers(); }); }); });