diff --git a/packages/daemon/__tests__/guards/guards.test.ts b/packages/daemon/__tests__/guards/guards.test.ts index 51c254d9..42ac6a30 100644 --- a/packages/daemon/__tests__/guards/guards.test.ts +++ b/packages/daemon/__tests__/guards/guards.test.ts @@ -13,6 +13,7 @@ import { unchanged, invalidNetwork, reorgStarted, + hasNewEvents, } from '../../src/guards'; import { EventTypes } from '../../src/types'; @@ -309,3 +310,51 @@ describe('websocket guards', () => { expect(websocketDisconnected(mockContext, mockConnectedEvent)).toBe(false); }); }); + +describe('event loss detection guards', () => { + test('hasNewEvents returns true when data.hasNewEvents is true', () => { + const mockEvent = { + data: { + hasNewEvents: true, + events: [{ id: 1 }, { id: 2 }], + }, + }; + + expect(hasNewEvents(mockContext, mockEvent)).toBe(true); + }); + + test('hasNewEvents returns false when data.hasNewEvents is false', () => { + const mockEvent = { + data: { + hasNewEvents: false, + events: [], + }, + }; + + expect(hasNewEvents(mockContext, mockEvent)).toBe(false); + }); + + test('hasNewEvents returns false when data is missing', () => { + const mockEvent = {}; + + expect(hasNewEvents(mockContext, mockEvent)).toBe(false); + }); + + test('hasNewEvents returns false when data is null', () => { + const mockEvent = { + data: null, + }; + + expect(hasNewEvents(mockContext, mockEvent)).toBe(false); + }); + + test('hasNewEvents returns false when hasNewEvents is undefined', () => { + const mockEvent = { + data: { + events: [], + }, + }; + + expect(hasNewEvents(mockContext, mockEvent)).toBe(false); + }); +}); diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 424247d1..99ad840b 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -31,6 +31,7 @@ import { handleVertexAccepted, metadataDiff, handleReorgStarted, + checkForMissedEvents, } from '../../src/services'; import logger from '../../src/logger'; import { @@ -54,6 +55,7 @@ jest.mock('../../src/logger', () => ({ debug: jest.fn(), error: jest.fn(), info: jest.fn(), + warn: jest.fn(), })); jest.mock('axios', () => ({ @@ -1041,3 +1043,188 @@ describe('handleReorgStarted', () => { .toThrow('Invalid event type for REORG_STARTED'); }); }); + +describe('checkForMissedEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + const mockUrl = 'http://mock-host:8080/v1a'; + (getFullnodeHttpUrl as jest.Mock).mockReturnValue(mockUrl); + }); + + it('should return hasNewEvents=true when API returns events', async () => { + const mockResponse = { + status: 200, + data: { + events: [ + { + id: 115182, + timestamp: 1761758848.1938324, + type: 'VERTEX_METADATA_CHANGED', + data: { hash: 'mockHash' }, + }, + { + id: 115183, + timestamp: 1761758848.196779, + type: 'NEW_VERTEX_ACCEPTED', + data: { hash: 'mockHash' }, + }, + ], + latest_event_id: 115561, + }, + }; + + (axios.get as jest.Mock).mockResolvedValue(mockResponse); + + const context = { + event: { + event: { + id: 115181, + }, + }, + }; + + const result = await checkForMissedEvents(context as any); + + expect(result.hasNewEvents).toBe(true); + expect(result.events).toHaveLength(2); + expect(axios.get).toHaveBeenCalledWith('http://mock-host:8080/v1a/event', { + params: { + last_ack_event_id: 115181, + size: 1, + }, + }); + expect(logger.warn).toHaveBeenCalledWith( + 'Detected 2 missed event(s) after ACK 115181. Will reconnect.' + ); + }); + + it('should return hasNewEvents=false when API returns no events', async () => { + const mockResponse = { + status: 200, + data: { + events: [], + latest_event_id: 115181, + }, + }; + + (axios.get as jest.Mock).mockResolvedValue(mockResponse); + + const context = { + event: { + event: { + id: 115181, + }, + }, + }; + + const result = await checkForMissedEvents(context as any); + + expect(result.hasNewEvents).toBe(false); + expect(result.events).toHaveLength(0); + expect(logger.debug).toHaveBeenCalledWith( + 'No missed events detected after ACK 115181' + ); + }); + + it('should throw error when HTTP request fails', async () => { + (axios.get as jest.Mock).mockResolvedValue({ + status: 500, + data: {}, + }); + + const context = { + event: { + event: { + id: 115181, + }, + }, + }; + + await expect(checkForMissedEvents(context as any)) + .rejects + .toThrow('Failed to check for missed events: HTTP 500'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('HTTP 500') + ); + }); + + it('should throw error when network request fails', async () => { + const networkError = new Error('ECONNREFUSED: Connection refused'); + (axios.get as jest.Mock).mockRejectedValue(networkError); + + const context = { + event: { + event: { + id: 115181, + }, + }, + }; + + await expect(checkForMissedEvents(context as any)) + .rejects + .toThrow('Failed to check for missed events: Network error'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Network error') + ); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('ECONNREFUSED') + ); + }); + + it('should throw error when context has no event', async () => { + const context = {}; + + await expect(checkForMissedEvents(context as any)) + .rejects + .toThrow('No event in context when checking for missed events'); + }); + + it('should handle API response with non-array events field', async () => { + const mockResponse = { + status: 200, + data: { + events: null, + latest_event_id: 115181, + }, + }; + + (axios.get as jest.Mock).mockResolvedValue(mockResponse); + + const context = { + event: { + event: { + id: 115181, + }, + }, + }; + + const result = await checkForMissedEvents(context as any); + + expect(result.hasNewEvents).toBe(false); + }); + + it('should throw error when response data is invalid', async () => { + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: null, + }); + + const context = { + event: { + event: { + id: 115181, + }, + }, + }; + + await expect(checkForMissedEvents(context as any)) + .rejects + .toThrow('Failed to check for missed events: Invalid response structure'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid response data structure') + ); + }); +}); diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index d98d6bd9..3b379bc2 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -828,18 +828,37 @@ export const checkForMissedEvents = async (context: Context): Promise<{ hasNewEv logger.debug(`Checking for missed events after event ID ${lastAckEventId}`); - const response = await axios.get(`${fullnodeUrl}/event`, { - params: { - last_ack_event_id: lastAckEventId, - size: 1, - }, - }); + let response; + try { + response = await axios.get(`${fullnodeUrl}/event`, { + params: { + last_ack_event_id: lastAckEventId, + size: 1, + }, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error( + `Failed to check for missed events after ACK ${lastAckEventId}: Network error - ${errorMessage}. URL: ${fullnodeUrl}/event` + ); + throw new Error(`Failed to check for missed events: Network error - ${errorMessage}`); + } if (response.status !== 200) { + logger.error( + `Failed to check for missed events after ACK ${lastAckEventId}: HTTP ${response.status}. URL: ${fullnodeUrl}/event` + ); throw new Error(`Failed to check for missed events: HTTP ${response.status}`); } - const events = response.data; + if (!response.data || typeof response.data !== 'object') { + logger.error( + `Failed to check for missed events after ACK ${lastAckEventId}: Invalid response data structure. Response: ${JSONBigInt.stringify(response.data)}` + ); + throw new Error('Failed to check for missed events: Invalid response structure'); + } + + const { events } = response.data; const hasNewEvents = Array.isArray(events) && events.length > 0; if (hasNewEvents) {