backend: Add integration tests for recording media retrieval and validation, including range requests

This commit is contained in:
Carlos Santos 2025-04-29 13:06:09 +02:00
parent ac9c803dcc
commit 3b79610558
3 changed files with 342 additions and 1 deletions

View File

@ -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');
});
});
});

View File

@ -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');

View File

@ -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) {