backend: Add integration tests for recording media retrieval and validation, including range requests
This commit is contained in:
parent
ac9c803dcc
commit
3b79610558
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user