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);
|
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) => {
|
export const expectValidStartRecordingResponse = (response: any, roomId: string) => {
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(response.body).toHaveProperty('recordingId');
|
expect(response.body).toHaveProperty('recordingId');
|
||||||
|
|||||||
@ -372,8 +372,24 @@ export const deleteRecording = async (recordingId: string) => {
|
|||||||
return await request(app)
|
return await request(app)
|
||||||
.delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}`)
|
.delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}`)
|
||||||
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
|
.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) => {
|
export const stopAllRecordings = async (moderatorCookie: string) => {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw new Error('App instance is not defined');
|
throw new Error('App instance is not defined');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user