From 41e39bc3bdf8a5595ac59875d68b505a3b2c2d17 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Thu, 14 Aug 2025 17:50:21 +0200 Subject: [PATCH] backend: enhance stale recording evaluation logic and add comprehensive tests --- backend/src/services/recording.service.ts | 19 +- .../stale-recordings-cleanup.test.ts | 559 ++++++++++++++++++ 2 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 backend/tests/integration/api/recordings/stale-recordings-cleanup.test.ts diff --git a/backend/src/services/recording.service.ts b/backend/src/services/recording.service.ts index acce81c..c13aa0b 100644 --- a/backend/src/services/recording.service.ts +++ b/backend/src/services/recording.service.ts @@ -862,6 +862,7 @@ export class RecordingService { */ protected async evaluateAndAbortStaleRecording(egressInfo: EgressInfo): Promise { const recordingId = RecordingHelper.extractRecordingIdFromEgress(egressInfo); + const { roomId } = RecordingHelper.extractInfoFromRecordingId(recordingId); const updatedAt = RecordingHelper.extractUpdatedDate(egressInfo); const staleAfterMs = ms(INTERNAL_CONFIG.RECORDING_STALE_AFTER); @@ -878,17 +879,29 @@ export class RecordingService { return false; } + const lkRoomExists = await this.livekitService.roomExists(roomId); + const ageIsStale = updatedAt < Date.now() - staleAfterMs; + let isRecordingStale = false; + + if (ageIsStale) { + if (!lkRoomExists) { + isRecordingStale = true; // There is no room and updated before stale time -> stale + } else { + const lkRoom = await this.livekitService.getRoom(roomId); + isRecordingStale = lkRoom.numPublishers === 0; // No publishers in the room and updated before stale time -> stale + } + } + // Check if recording has not been updated recently - const isStale = updatedAt < Date.now() - staleAfterMs; this.logger.debug(`Recording ${recordingId} last updated at ${new Date(updatedAt).toISOString()}`); - if (!isStale) { + if (!isRecordingStale) { this.logger.debug(`Recording ${recordingId} is still fresh`); return false; } - this.logger.warn(`Recording ${recordingId} is stale, aborting...`); + this.logger.warn(`Room ${roomId} does not exist and recording ${recordingId} is stale, aborting...`); // Abort the recording const { egressId } = RecordingHelper.extractInfoFromRecordingId(recordingId); diff --git a/backend/tests/integration/api/recordings/stale-recordings-cleanup.test.ts b/backend/tests/integration/api/recordings/stale-recordings-cleanup.test.ts new file mode 100644 index 0000000..b98fb15 --- /dev/null +++ b/backend/tests/integration/api/recordings/stale-recordings-cleanup.test.ts @@ -0,0 +1,559 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { EgressInfo, EgressStatus, Room } from 'livekit-server-sdk'; +import ms from 'ms'; +import { container } from '../../../../src/config/index.js'; +import INTERNAL_CONFIG from '../../../../src/config/internal-config.js'; +import { MeetLock } from '../../../../src/helpers/index.js'; +import { + LiveKitService, + LoggerService, + MeetStorageService, + MutexService, + RecordingService +} from '../../../../src/services/index.js'; +import { MeetRecordingInfo, MeetRecordingStatus } from '../../../../src/typings/ce/index.js'; +import { startTestServer } from '../../../helpers/request-helpers.js'; + +describe('Recording Cleanup Tests', () => { + let recordingService: RecordingService; + let mutexService: MutexService; + let livekitService: LiveKitService; + let storageService: MeetStorageService; + + const getRecordingLock = (roomId: string) => MeetLock.getRecordingActiveLock(roomId); + + const testRooms = { + recentLock: 'room-recent-lock', + withPublishers: 'room-with-publishers', + withoutPublishersWithRecording: 'room-without-publishers-with-recording', + withoutPublishersNoRecording: 'room-without-publishers-no-recording', + nonExistentWithRecording: 'room-non-existent-with-recording', + nonExistentNoRecording: 'room-non-existent-no-recording', + staleRecording: 'room-stale-recording', + freshRecording: 'room-fresh-recording', + abortedRecording: 'room-aborted-recording', + noUpdatedAt: 'room-no-updated-at' + }; + + beforeAll(() => { + startTestServer(); + recordingService = container.get(RecordingService); + mutexService = container.get(MutexService); + livekitService = container.get(LiveKitService); + storageService = container.get(MeetStorageService); + + // Mute logs for the test + const logger = container.get(LoggerService); + jest.spyOn(logger, 'debug').mockImplementation(() => {}); + jest.spyOn(logger, 'verbose').mockImplementation(() => {}); + jest.spyOn(logger, 'info').mockImplementation(() => {}); + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + jest.spyOn(logger, 'error').mockImplementation(() => {}); + }); + + beforeEach(async () => { + // Clean up any existing locks before each test + for (const roomId of Object.values(testRooms)) { + try { + await mutexService.release(getRecordingLock(roomId)); + } catch (e) { + // Ignore errors if the lock does not exist + } + } + + // Setup spies + jest.spyOn(mutexService, 'getLocksByPrefix'); + jest.spyOn(mutexService, 'lockExists'); + jest.spyOn(mutexService, 'getLockCreatedAt'); + jest.spyOn(mutexService, 'release'); + jest.spyOn(livekitService, 'roomExists'); + jest.spyOn(livekitService, 'getRoom'); + jest.spyOn(livekitService, 'getInProgressRecordingsEgress'); + jest.spyOn(livekitService, 'stopEgress'); + jest.spyOn(storageService, 'getRecordingMetadata'); + jest.spyOn(storageService, 'saveRecordingMetadata'); + jest.spyOn(recordingService as never, 'performRecordingLocksGarbageCollection'); + jest.spyOn(recordingService as never, 'evaluateAndReleaseOrphanedLock'); + jest.spyOn(recordingService as never, 'performStaleRecordingsCleanup'); + jest.spyOn(recordingService as never, 'evaluateAndAbortStaleRecording'); + jest.spyOn(recordingService, 'getRecording'); + jest.spyOn(recordingService as never, 'updateRecordingStatus'); + + jest.clearAllMocks(); + + // Do not set up global mocks here to improve test isolation + }); + + afterEach(async () => { + // Clean up all spies and its invocations + jest.clearAllMocks(); + jest.restoreAllMocks(); + + // Explicitly restore the mock behavior for getLockCreatedAt + if (mutexService.getLockCreatedAt && jest.isMockFunction(mutexService.getLockCreatedAt)) { + (mutexService.getLockCreatedAt as jest.Mock).mockReset(); + } + }); + + afterAll(async () => { + // Clean up all test locks + for (const roomId of Object.values(testRooms)) { + try { + await mutexService.release(getRecordingLock(roomId)); + } catch (e) { + // Ignore errors if the lock does not exist + } + } + + // Restore all mocks + jest.restoreAllMocks(); + }); + + /** + * Creates a mock EgressInfo object for testing + */ + function createMockEgressInfo( + roomId: string, + egressId: string, + status: EgressStatus, + updatedAt?: number + ): EgressInfo { + const uid = '1234567890'; + return { + egressId, + roomId, + roomName: roomId, + status, + updatedAt: updatedAt ? BigInt(updatedAt * 1_000_000) : undefined, // Convert to nanoseconds + startedAt: BigInt(Date.now() * 1_000_000), + endedAt: status === EgressStatus.EGRESS_COMPLETE ? BigInt(Date.now() * 1_000_000) : undefined, + fileResults: [ + { + filename: `${roomId}--${uid}.mp4`, + size: BigInt(1024 * 1024), // 1MB + duration: BigInt(60 * 1_000_000_000) // 60 seconds in nanoseconds + } + ], + streamResults: [], + request: { + case: 'roomComposite' + } + } as unknown as EgressInfo; + } + + /** + * Creates a mock MeetRecordingInfo object for testing + */ + function createMockRecordingInfo( + recordingId: string, + roomId: string, + status: MeetRecordingStatus + ): MeetRecordingInfo { + return { + recordingId, + roomId, + roomName: roomId, + status, + startDate: Date.now() - ms('10m'), + filename: `${recordingId}.mp4` + }; + } + + describe('Perform Stale Recordings Cleanup', () => { + it('should not process any recordings when there are no in-progress recordings', async () => { + // Simulate empty response from LiveKit + (livekitService.getInProgressRecordingsEgress as jest.Mock).mockResolvedValueOnce([] as never); + + // Execute the stale recordings cleanup + await recordingService['performStaleRecordingsCleanup'](); + + // Verify that we checked for recordings but didn't attempt to process any + expect(livekitService.getInProgressRecordingsEgress).toHaveBeenCalled(); + expect((recordingService as never)['evaluateAndAbortStaleRecording']).not.toHaveBeenCalled(); + }); + + it('should gracefully handle errors during in-progress recordings retrieval', async () => { + // Simulate LiveKit service failure + (livekitService.getInProgressRecordingsEgress as jest.Mock).mockRejectedValueOnce( + new Error('Failed to retrieve recordings') as never + ); + + // Execute the stale recordings cleanup - should not throw + await recordingService['performStaleRecordingsCleanup'](); + + // Verify the error was handled properly without further processing + expect(livekitService.getInProgressRecordingsEgress).toHaveBeenCalled(); + expect((recordingService as never)['evaluateAndAbortStaleRecording']).not.toHaveBeenCalled(); + }); + + it('should process each in-progress recording to detect and abort stale ones', async () => { + const roomIds = [testRooms.staleRecording, testRooms.freshRecording, testRooms.abortedRecording]; + const mockEgressInfos = roomIds.map((roomId) => + createMockEgressInfo(roomId, `EG_${roomId}`, EgressStatus.EGRESS_ACTIVE) + ); + + // Simulate existing in-progress recordings in the system + (livekitService.getInProgressRecordingsEgress as jest.Mock).mockResolvedValueOnce(mockEgressInfos as never); + + // Execute the stale recordings cleanup + await recordingService['performStaleRecordingsCleanup'](); + + // Verify that each recording was processed individually + expect((recordingService as never)['evaluateAndAbortStaleRecording']).toHaveBeenCalledTimes(3); + mockEgressInfos.forEach((egressInfo) => { + expect((recordingService as never)['evaluateAndAbortStaleRecording']).toHaveBeenCalledWith(egressInfo); + }); + }); + }); + + describe('Evaluate and Abort Stale Recording', () => { + it('should skip processing if the recording is already aborted', async () => { + const roomId = testRooms.abortedRecording; + const egressId = `EG_${roomId}`; + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE); + + // Mock recording as already aborted + const mockRecordingInfo = createMockRecordingInfo( + `${roomId}--${egressId}--1234567890`, + roomId, + MeetRecordingStatus.ABORTED + ); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the method returned true (already aborted) + expect(result).toBe(true); + expect(recordingService.getRecording).toHaveBeenCalled(); + expect(livekitService.roomExists).not.toHaveBeenCalled(); + expect(recordingService['updateRecordingStatus']).not.toHaveBeenCalled(); + expect(livekitService.stopEgress).not.toHaveBeenCalled(); + }); + + it('should skip processing if the recording has no updatedAt timestamp', async () => { + const roomId = testRooms.noUpdatedAt; + const egressId = `EG_${roomId}`; + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE); // No updatedAt + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo( + `${roomId}--${egressId}--1234567890`, + roomId, + MeetRecordingStatus.ACTIVE + ); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the method returned false (kept as fresh) + expect(result).toBe(false); + expect(recordingService.getRecording).toHaveBeenCalled(); + expect(livekitService.roomExists).not.toHaveBeenCalled(); + expect(recordingService['updateRecordingStatus']).not.toHaveBeenCalled(); + expect(livekitService.stopEgress).not.toHaveBeenCalled(); + }); + + it('should keep recording as fresh if it has been updated recently', async () => { + const roomId = testRooms.freshRecording; + const egressId = `EG_${roomId}`; + const recentUpdateTime = Date.now() - ms('1m'); // 1 minute ago (fresh) + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, recentUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo( + `${roomId}--${egressId}--1234567890`, + roomId, + MeetRecordingStatus.ACTIVE + ); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(true as never); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the method returned false (still fresh) + expect(result).toBe(false); + expect(recordingService.getRecording).toHaveBeenCalled(); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect(livekitService.stopEgress).not.toHaveBeenCalled(); + expect(recordingService['updateRecordingStatus']).not.toHaveBeenCalled(); + }); + + it('should abort recording if room does not exist and recording updated time is stale', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const staleUpdateTime = Date.now() - ms('10m'); // 10 minutes ago (stale) + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, staleUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(false as never); + (livekitService.stopEgress as jest.Mock).mockResolvedValueOnce({} as never); + ((recordingService as never)['updateRecordingStatus'] as jest.Mock).mockResolvedValueOnce( + undefined as never + ); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the recording was aborted + expect(result).toBe(true); + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect((recordingService as never)['updateRecordingStatus']).toHaveBeenCalledWith( + recordingId, + MeetRecordingStatus.ABORTED + ); + expect(livekitService.stopEgress).toHaveBeenCalledWith(egressId); + }); + + it('should abort recording if room exists with no publishers and updated time is stale', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const staleUpdateTime = Date.now() - ms('10m'); // 10 minutes ago (stale) + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, staleUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValue(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValue(true as never); // Room exists + (livekitService.getRoom as jest.Mock).mockResolvedValue({ + numParticipants: 0, + numPublishers: 0 + } as Room as never); + (livekitService.stopEgress as jest.Mock).mockResolvedValue({} as never); + ((recordingService as never)['updateRecordingStatus'] as jest.Mock).mockResolvedValueOnce( + undefined as never + ); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the recording was aborted + expect(result).toBe(true); + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect((recordingService as never)['updateRecordingStatus']).toHaveBeenCalledWith( + recordingId, + MeetRecordingStatus.ABORTED + ); + expect(livekitService.stopEgress).toHaveBeenCalledWith(egressId); + }); + + it('should keep recording if it has been updated recently regardless room existence', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, Date.now()); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValue(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(true as never); // Room exists + + // Execute evaluateAndAbortStaleRecording + let result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the recording was kept fresh (not aborted) + expect(result).toBe(false); + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect((recordingService as never)['updateRecordingStatus']).not.toHaveBeenCalled(); + expect(livekitService.stopEgress).not.toHaveBeenCalled(); + + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(false as never); // Room exists + + // Execute evaluateAndAbortStaleRecording + result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the recording was kept fresh (not aborted) + expect(result).toBe(false); + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect((recordingService as never)['updateRecordingStatus']).not.toHaveBeenCalled(); + expect(livekitService.stopEgress).not.toHaveBeenCalled(); + }); + + it('should keep recording if room exists with publishers even when updated time is stale', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const staleUpdateTime = Date.now() - ms('10m'); // 10 minutes ago (stale) + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, staleUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(true as never); + (livekitService.getRoom as jest.Mock).mockResolvedValueOnce({ + numParticipants: 1, + numPublishers: 1 + } as Room as never); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the recording was kept fresh (not aborted) + expect(result).toBe(false); + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect((recordingService as never)['updateRecordingStatus']).not.toHaveBeenCalled(); + expect(livekitService.stopEgress).not.toHaveBeenCalled(); + }); + + it('should keep recording if room exists with no publishers but updated time is recent', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const recentUpdateTime = Date.now() - ms('1m'); // 1 minute ago (recent) + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, recentUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(true as never); + (livekitService.getRoom as jest.Mock).mockResolvedValueOnce({ + numParticipants: 1, + numPublishers: 0 + } as Room as never); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the recording was kept fresh (not aborted) + expect(result).toBe(false); + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect((recordingService as never)['updateRecordingStatus']).not.toHaveBeenCalled(); + expect(livekitService.stopEgress).not.toHaveBeenCalled(); + }); + + it('should keep recording if room exists with publishers and updated time is recent', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const recentUpdateTime = Date.now() - ms('1m'); // 1 minute ago (recent) + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, recentUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(true as never); + (livekitService.getRoom as jest.Mock).mockResolvedValueOnce({ + numParticipants: 1, + numPublishers: 1 + } as Room as never); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the recording was kept fresh (not aborted) + expect(result).toBe(false); + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect((recordingService as never)['updateRecordingStatus']).not.toHaveBeenCalled(); + expect(livekitService.stopEgress).not.toHaveBeenCalled(); + }); + + it('should handle edge case when updatedAt is exactly on the staleAfterMs threshold', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const staleUpdateTime = Date.now() - ms(INTERNAL_CONFIG.RECORDING_STALE_AFTER); + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, staleUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(false as never); + (livekitService.stopEgress as jest.Mock).mockResolvedValueOnce({} as never); + ((recordingService as never)['updateRecordingStatus'] as jest.Mock).mockResolvedValueOnce( + undefined as never + ); + + // Execute evaluateAndAbortStaleRecording + const result = await recordingService['evaluateAndAbortStaleRecording'](egressInfo); + + // Verify that the recording was aborted + expect(result).toBe(true); + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect((recordingService as never)['updateRecordingStatus']).toHaveBeenCalledWith( + recordingId, + MeetRecordingStatus.ABORTED + ); + expect(livekitService.stopEgress).toHaveBeenCalledWith(egressId); + }); + + it('should handle errors during recording processing and rethrow them', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE); + + // Mock error during recording retrieval + (recordingService.getRecording as jest.Mock).mockRejectedValueOnce( + new Error('Recording not found in storage') as never + ); + + // Execute evaluateAndAbortStaleRecording and expect error to propagate + await expect(recordingService['evaluateAndAbortStaleRecording'](egressInfo)).rejects.toThrow( + 'Recording not found in storage' + ); + + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + }); + + it('should handle errors during recording abort and rethrow them', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const staleUpdateTime = Date.now() - ms('10m'); // 10 minutes ago (stale) + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, staleUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(false as never); + (livekitService.stopEgress as jest.Mock).mockRejectedValueOnce(new Error('Failed to stop egress') as never); + + // Execute evaluateAndAbortStaleRecording and expect error to propagate + await expect(recordingService['evaluateAndAbortStaleRecording'](egressInfo)).rejects.toThrow( + 'Failed to stop egress' + ); + + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect(livekitService.stopEgress).toHaveBeenCalledWith(egressId); + }); + + it('should handle case where updatedAt is in the future due to clock skew', async () => { + const roomId = testRooms.staleRecording; + const egressId = `EG_${roomId}`; + const recordingId = `${roomId}--${egressId}--1234567890`; + const staleUpdateTime = Date.now() + ms('10m'); // 10 minutes in the future (not stale) + const egressInfo = createMockEgressInfo(roomId, egressId, EgressStatus.EGRESS_ACTIVE, staleUpdateTime); + + // Mock recording as active + const mockRecordingInfo = createMockRecordingInfo(recordingId, roomId, MeetRecordingStatus.ACTIVE); + (recordingService.getRecording as jest.Mock).mockResolvedValueOnce(mockRecordingInfo as never); + (livekitService.roomExists as jest.Mock).mockResolvedValueOnce(true as never); + (livekitService.getRoom as jest.Mock).mockResolvedValueOnce({ + numParticipants: 0, + numPublishers: 0 + } as Room as never); + + // Execute evaluateAndAbortStaleRecording and expect it to resolve to false + await expect(recordingService['evaluateAndAbortStaleRecording'](egressInfo)).resolves.toBe(false); + + expect(recordingService.getRecording).toHaveBeenCalledWith(recordingId); + expect(livekitService.roomExists).toHaveBeenCalledWith(roomId); + expect(livekitService.getRoom).not.toBeCalled(); + }); + }); +});