From 3b7961055880fccd32b5f142b78bb981b58354d9 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Tue, 29 Apr 2025 13:06:09 +0200 Subject: [PATCH] backend: Add integration tests for recording media retrieval and validation, including range requests --- .../recordings/get-media-recording.test.ts | 195 ++++++++++++++++++ backend/tests/utils/assertion-helpers.ts | 130 ++++++++++++ backend/tests/utils/helpers.ts | 18 +- 3 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 backend/tests/integration/api/recordings/get-media-recording.test.ts diff --git a/backend/tests/integration/api/recordings/get-media-recording.test.ts b/backend/tests/integration/api/recordings/get-media-recording.test.ts new file mode 100644 index 0000000..7f856e8 --- /dev/null +++ b/backend/tests/integration/api/recordings/get-media-recording.test.ts @@ -0,0 +1,195 @@ +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { expectSuccessRecordingMediaResponse, expectValidationError } from '../../../utils/assertion-helpers'; +import { + deleteAllRecordings, + deleteAllRooms, + getRecordingMedia, + startTestServer, + stopAllRecordings, + stopRecording +} from '../../../utils/helpers'; +import { setupMultiRecordingsTestContext } from '../../../utils/test-scenarios'; +import { MeetRoom } from '../../../../src/typings/ce'; + +describe('Recording API Tests', () => { + let room: MeetRoom, recordingId: string, moderatorCookie: string; + + beforeAll(async () => { + startTestServer(); + + const testContext = await setupMultiRecordingsTestContext(1, 1, 1, '3s'); + const roomData = testContext.getRoomByIndex(0)!; + + ({ room, recordingId = '', moderatorCookie } = roomData); + }); + + afterAll(async () => { + await stopAllRecordings(moderatorCookie); + await Promise.all([deleteAllRecordings(), deleteAllRooms()]); + }); + describe('Recording Media Tests', () => { + it('should return 200 when requesting the full media content', async () => { + const response = await getRecordingMedia(recordingId); + + console.log('Recording media response:', response.body); + expectSuccessRecordingMediaResponse(response); + }); + + it('should return 206 when requesting partial media content', async () => { + const fullResponse = await getRecordingMedia(recordingId); + const fullSize = parseInt(fullResponse.headers['content-length']); + + // Request first 10000 bytes + const range = 'bytes=0-9999'; + const response = await getRecordingMedia(recordingId, range); + expectSuccessRecordingMediaResponse(response, range, fullSize); + }); + + it('should handle requests for specific byte ranges', async () => { + // Get full recording size + const fullResponse = await getRecordingMedia(recordingId); + const fullSize = parseInt(fullResponse.headers['content-length']); + + // Request a 1000 byte segment from the middle + const middleStart = Math.floor(fullSize / 2); + const middleEnd = middleStart + 999; + const range = `bytes=${middleStart}-${middleEnd}`; + + const response = await getRecordingMedia(recordingId, range); + expectSuccessRecordingMediaResponse(response, range, fullSize); + }); + + it('should handle end-only ranges correctly', async () => { + // Request last 2000 bytes + const fullResponse = await getRecordingMedia(recordingId); + const fullSize = parseInt(fullResponse.headers['content-length']); + + const start = fullSize - 2000; + const range = `bytes=${start}-`; + + const response = await getRecordingMedia(recordingId, range); + expectSuccessRecordingMediaResponse(response, range, fullSize); + }); + }); + + describe('Edge Cases and Robustness Tests', () => { + it('should handle very large range requests gracefully', async () => { + // Get full size + const fullResponse = await getRecordingMedia(recordingId); + const fullSize = parseInt(fullResponse.headers['content-length']); + + // Request more data than available + const range = `bytes=0-${fullSize * 2}`; + const response = await getRecordingMedia(recordingId, range); + + // Should still return data but adjust the range + expectSuccessRecordingMediaResponse(response, range, fullSize, { + ignoreRangeFormat: true, + expectedStatus: 206 + }); + }); + + it('should sanitize recordingId with spaces', async () => { + // Adding spaces before and after the recordingId + const spacedId = ` ${recordingId} `; + const response = await getRecordingMedia(spacedId); + + expectSuccessRecordingMediaResponse(response); + }); + + it('should handle multiple range requests to the same recording', async () => { + // Make 3 consecutive range requests to ensure stability + const rangeSize = 1000; + + for (let i = 0; i < 3; i++) { + const start = i * rangeSize; + const end = start + rangeSize - 1; + const range = `bytes=${start}-${end}`; + + const fullResponse = await getRecordingMedia(recordingId); + const fullSize = parseInt(fullResponse.headers['content-length']); + const response = await getRecordingMedia(recordingId, range); + + expectSuccessRecordingMediaResponse(response, range, fullSize); + } + }); + + it('should handle boundary ranges properly', async () => { + // Get full size + const fullResponse = await getRecordingMedia(recordingId); + const fullSize = parseInt(fullResponse.headers['content-length']); + + // Test extreme ranges at the boundaries + const testCases = [ + { range: 'bytes=0-0', description: 'First byte only' }, + { range: `bytes=${fullSize - 1}-${fullSize - 1}`, description: 'Last byte only' }, + { range: `bytes=0-${Math.floor(fullSize / 3)}`, description: 'First third' }, + { range: `bytes=${Math.floor((fullSize * 2) / 3)}-${fullSize - 1}`, description: 'Last third' } + ]; + + for (const testCase of testCases) { + const response = await getRecordingMedia(recordingId, testCase.range); + expectSuccessRecordingMediaResponse(response, testCase.range, fullSize, { + allowSizeDifference: true + }); + } + }); + }); + + describe('Recording Media Validation', () => { + it('shoud return a 422 when the range header has invalid format', async () => { + const response = await getRecordingMedia(recordingId, 'bytes=100'); + + expectValidationError(response, 'headers.range', 'Invalid range header format. Expected: bytes=start-end'); + }); + + it('should return 422 when the range format is completely wrong', async () => { + const response = await getRecordingMedia(recordingId, 'invalid-range'); + + expectValidationError(response, 'headers.range', 'Invalid range header format. Expected: bytes=start-end'); + }); + + it('should return a 416 when range is not satisfiable', async () => { + // Get full size + const fullResponse = await getRecordingMedia(recordingId); + const fullSize = parseInt(fullResponse.headers['content-length']); + + // Request a range beyond the file size + const response = await getRecordingMedia(recordingId, `bytes=${fullSize + 1}-${fullSize + 1000}`); + expect(response.status).toBe(416); + expect(response.body).toHaveProperty('name', 'Recording Error'); + expect(response.body).toHaveProperty('message'); + expect(response.body.message).toContain(`Recording '${recordingId}' range not satisfiable`); + expect(response.body.message).toMatch(/File size: \d+/); + }); + + it('should return a 409 when the recording is in progress', async () => { + const testContext = await setupMultiRecordingsTestContext(1, 1, 0, '0s'); + const { recordingId: activeRecordingId = '', moderatorCookie } = testContext.rooms[0]; + + // Attempt to get the media of an active recording + const response = await getRecordingMedia(activeRecordingId); + expect(response.status).toBe(409); + expect(response.body).toHaveProperty('name', 'Recording Error'); + expect(response.body).toHaveProperty('message'); + expect(response.body.message).toContain(`Recording '${activeRecordingId}' is not stopped yet`); + + await stopRecording(activeRecordingId, moderatorCookie); + }); + + it('should return 404 when recording not found', async () => { + const nonExistentId = `${room.roomId}--EG_nonexistent--12345`; + const response = await getRecordingMedia(nonExistentId); + + expect(response.status).toBe(404); + }); + + it('should return 400 when recordingId format is invalid', async () => { + const invalidId = 'invalid-recording-id'; + const response = await getRecordingMedia(invalidId); + + expect(response.status).toBe(422); + expectValidationError(response, 'params.recordingId', 'does not follow the expected format'); + }); + }); +}); diff --git a/backend/tests/utils/assertion-helpers.ts b/backend/tests/utils/assertion-helpers.ts index 5f3d6b0..daed9d5 100644 --- a/backend/tests/utils/assertion-helpers.ts +++ b/backend/tests/utils/assertion-helpers.ts @@ -192,6 +192,136 @@ export const expectValidRecordingLocationHeader = (response: any) => { expect(response.headers.location).toContain(response.body.recordingId); }; +/** + * Validates a successful recording media response, supporting edge cases and range requests. + * + * @param response - The HTTP response object to validate + * @param range - Optional range header that was sent in the request + * @param fullSize - Optional total file size for range validation + * @param options - Optional configuration to handle edge cases: + * - allowSizeDifference: Allows a difference between content-length and actual body size (default: false) + * - ignoreRangeFormat: Ignores exact range format checking (useful for adjusted ranges) (default: false) + * - expectedStatus: Override the expected status code (default: auto-determined based on range) + */ +export const expectSuccessRecordingMediaResponse = ( + response: any, + range?: string, + fullSize?: number, + options?: { + allowSizeDifference?: boolean; + ignoreRangeFormat?: boolean; + expectedStatus?: number; + } +) => { + // Default options + const opts = { + allowSizeDifference: false, + ignoreRangeFormat: false, + ...options + }; + + // Determine expected status + const expectedStatus = opts.expectedStatus ?? (range ? 206 : 200); + + // Basic validations for any successful response + expect(response.status).toBe(expectedStatus); + expect(response.headers['content-type']).toBe('video/mp4'); + expect(response.headers['accept-ranges']).toBe('bytes'); + expect(response.headers['content-length']).toBeDefined(); + expect(parseInt(response.headers['content-length'])).toBeGreaterThan(0); + expect(response.headers['cache-control']).toBeDefined(); + + // Verify response is binary data with some size + expect(response.body).toBeInstanceOf(Buffer); + expect(response.body.length).toBeGreaterThan(0); + + // Handle range responses (206 Partial Content) + if (range && expectedStatus === 206) { + // Verify the content-range header + expect(response.headers['content-range']).toBeDefined(); + + // If ignoreRangeFormat is true, only check the format of the content-range header + if (opts.ignoreRangeFormat) { + expect(response.headers['content-range']).toMatch(/^bytes \d+-\d+\/\d+$/); + + if (fullSize) { + // Verify the total size in content-range header + const totalSizeMatch = response.headers['content-range'].match(/\/(\d+)$/); + + if (totalSizeMatch) { + expect(parseInt(totalSizeMatch[1])).toBe(fullSize); + } + } + } else { + // Extract the requested range from the request header + const rangeMatch = range.match(/^bytes=(\d+)-(\d*)$/); + + if (!rangeMatch) { + throw new Error(`Invalid range format: ${range}`); + } + + const requestedStart = parseInt(rangeMatch[1]); + const requestedEnd = rangeMatch[2] ? parseInt(rangeMatch[2]) : fullSize ? fullSize - 1 : undefined; + + expect(requestedStart).not.toBeNaN(); + + // Verify the range in the response + const contentRangeMatch = response.headers['content-range'].match(/^bytes (\d+)-(\d+)\/(\d+)$/); + + if (!contentRangeMatch) { + throw new Error(`Invalid content-range format: ${response.headers['content-range']}`); + } + + const actualStart = parseInt(contentRangeMatch[1]); + const actualEnd = parseInt(contentRangeMatch[2]); + const actualTotal = parseInt(contentRangeMatch[3]); + + // Verify the start matches + expect(actualStart).toBe(requestedStart); + + // If full size is provided, verify the total is correct + if (fullSize) { + expect(actualTotal).toBe(fullSize); + + // The end may be adjusted if it exceeds the total size + if (requestedEnd !== undefined && requestedEnd >= fullSize) { + expect(actualEnd).toBe(fullSize - 1); + } else if (requestedEnd !== undefined) { + expect(actualEnd).toBe(requestedEnd); + } + } + } + + // Verify that Content-Length is consistent + const declaredLength = parseInt(response.headers['content-length']); + expect(declaredLength).toBeGreaterThan(0); + + // If size differences are not allowed, body length must match exactly + if (!opts.allowSizeDifference) { + expect(response.body.length).toBe(declaredLength); + } else { + // Allow some difference but ensure it's within a reasonable tolerance + const bodyLength = response.body.length; + const diff = Math.abs(bodyLength - declaredLength); + const tolerance = Math.max(declaredLength * 0.05, 10); // 5% or at least 10 bytes + + expect(diff).toBeLessThanOrEqual(tolerance); + } + } else if (expectedStatus === 200) { + // For full content responses + const declaredLength = parseInt(response.headers['content-length']); + + if (!opts.allowSizeDifference) { + expect(response.body.length).toBe(declaredLength); + } + + // If full size is provided, content-length must match + if (fullSize !== undefined) { + expect(declaredLength).toBe(fullSize); + } + } +}; + export const expectValidStartRecordingResponse = (response: any, roomId: string) => { expect(response.status).toBe(201); expect(response.body).toHaveProperty('recordingId'); diff --git a/backend/tests/utils/helpers.ts b/backend/tests/utils/helpers.ts index fe981e1..d73812a 100644 --- a/backend/tests/utils/helpers.ts +++ b/backend/tests/utils/helpers.ts @@ -372,7 +372,23 @@ export const deleteRecording = async (recordingId: string) => { return await request(app) .delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}`) .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY); -} +}; + +export const getRecordingMedia = async (recordingId: string, range?: string) => { + if (!app) { + throw new Error('App instance is not defined'); + } + + const req = request(app) + .get(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}/media`) + .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY); + + if (range) { + req.set('range', range); + } + + return await req; +}; export const stopAllRecordings = async (moderatorCookie: string) => { if (!app) {