backend: Adds fields filtering tests to recording API

Enables filtering of recording API responses (start, get, list, stop)
using both the `fields` query parameter and the `X-Fields` header.
The feature allows clients to request only specific fields in the
response, improving performance and reducing payload size.
When both are provided, values are merged (union of unique fields).
This commit is contained in:
CSantosM 2026-02-16 19:02:00 +01:00
parent 9dc4834edd
commit 874538a8b7
7 changed files with 306 additions and 13 deletions

View File

@ -248,12 +248,13 @@ export const expectValidRecordingWithFields = (rec: MeetRecordingInfo, fields: s
};
const expectObjectFields = (obj: unknown, present: string[] = [], absent: string[] = []) => {
expect(Object.keys(obj as any)).toEqual(present);
present.forEach((key) => {
expect(obj).toHaveProperty(key);
expect((obj as any)[key]).not.toBeUndefined();
});
absent.forEach((key) => {
// Si la propiedad existe, debe ser undefined
// if the property exists, it must be undefined. If it doesn't exist, it's also valid (not present)
expect(Object.prototype.hasOwnProperty.call(obj, key) ? (obj as any)[key] : undefined).toBeUndefined();
});
};

View File

@ -883,6 +883,9 @@ export const startRecording = async (
config?: {
layout?: string;
encoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
},
options?: {
headers?: { xFields?: string };
}
) => {
checkAppIsRunning();
@ -899,31 +902,57 @@ export const startRecording = async (
body.config = config;
}
return await request(app)
const req = request(app)
.post(getFullPath(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`))
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.send(body);
if (options?.headers?.xFields) {
req.set('x-fields', options.headers.xFields);
}
return await req;
};
export const stopRecording = async (recordingId: string) => {
export const stopRecording = async (
recordingId: string,
options?: {
headers?: { xFields?: string };
}
) => {
checkAppIsRunning();
const response = await request(app)
const req = request(app)
.post(getFullPath(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}/stop`))
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.send();
if (options?.headers?.xFields) {
req.set('x-fields', options.headers.xFields);
}
const response = await req;
await sleep('2.5s');
return response;
};
export const getAllRecordings = async (query: Record<string, unknown> = {}) => {
export const getAllRecordings = async (
query: Record<string, unknown> = {},
headers?: { xFields?: string }
) => {
checkAppIsRunning();
return await request(app)
const req = request(app)
.get(getFullPath(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`))
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.query(query);
if (headers?.xFields) {
req.set('x-fields', headers.xFields);
}
return await req;
};
export const getAllRecordingsFromRoom = async (roomMemberToken: string) => {
@ -962,12 +991,29 @@ export const downloadRecordings = async (
return await req;
};
export const getRecording = async (recordingId: string) => {
export const getRecording = async (
recordingId: string,
options?: {
fields?: string;
headers?: { xFields?: string };
}
) => {
checkAppIsRunning();
return await request(app)
const queryParams: Record<string, string> = {};
if (options?.fields) queryParams.fields = options.fields;
const req = request(app)
.get(getFullPath(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}`))
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY);
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.query(queryParams);
if (options?.headers?.xFields) {
req.set('x-fields', options.headers.xFields);
}
return await req;
};
export const getRecordingMedia = async (recordingId: string, range?: string) => {

View File

@ -1,7 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import { MeetRecordingStatus, MeetRoom } from '@openvidu-meet/typings';
import { errorRecordingNotFound } from '../../../../src/models/error.model.js';
import { expectValidationError, expectValidGetRecordingResponse } from '../../../helpers/assertion-helpers.js';
import {
expectValidationError,
expectValidGetRecordingResponse,
expectValidRecordingWithFields
} from '../../../helpers/assertion-helpers.js';
import {
deleteAllRecordings,
deleteAllRooms,
@ -67,6 +71,62 @@ describe('Recording API Tests', () => {
});
});
describe('GET Recording - Fields filtering', () => {
let recordingId: string;
beforeAll(async () => {
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
context = await setupMultiRecordingsTestContext(1, 1, 1);
({ recordingId = '' } = context.getRoomByIndex(0)!);
});
it('should filter fields using fields query param', async () => {
const response = await getRecording(recordingId, { fields: 'recordingId,roomId,status' });
expect(response.status).toBe(200);
expectValidRecordingWithFields(response.body, ['recordingId', 'roomId', 'status']);
});
it('should filter fields using X-Fields header', async () => {
const response = await getRecording(recordingId, { headers: { xFields: 'recordingId,roomName' } });
expect(response.status).toBe(200);
expectValidRecordingWithFields(response.body, ['recordingId', 'roomName']);
});
it('should combine X-Fields header with fields query param (union)', async () => {
// Query param: fields=recordingId, Header: X-Fields=status
const response = await getRecording(recordingId, {
fields: 'recordingId',
headers: { xFields: 'status' }
});
expect(response.status).toBe(200);
expectValidRecordingWithFields(response.body, ['recordingId', 'status']);
});
it('should work with only X-Fields header and no query params', async () => {
const response = await getRecording(recordingId, {
headers: { xFields: 'recordingId,roomId,roomName,status' }
});
expect(response.status).toBe(200);
expectValidRecordingWithFields(response.body, ['recordingId', 'roomId', 'roomName', 'status']);
});
it('should return all fields when no filtering is specified', async () => {
const response = await getRecording(recordingId);
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('recordingId');
expect(response.body).toHaveProperty('roomId');
expect(response.body).toHaveProperty('roomName');
expect(response.body).toHaveProperty('status');
expect(response.body).toHaveProperty('layout');
expect(response.body).toHaveProperty('encoding');
});
});
describe('Get Recording Validation', () => {
it('should fail when recordingId has incorrect format', async () => {
const response = await getRecording('incorrect-format');

View File

@ -108,7 +108,7 @@ describe('Recordings API Tests', () => {
(recording: MeetRecordingInfo) => recording.roomId === room.roomId
);
expect(recording).toBeDefined();
expectValidRecordingWithFields(recording, ['roomId', 'recordingId']);
expectValidRecordingWithFields(recording, ['recordingId', 'roomId']);
expect(recording).toHaveProperty('roomId', room.roomId);
expect(recording.recordingId).toContain(room.roomId);
});
@ -287,6 +287,72 @@ describe('Recordings API Tests', () => {
});
});
describe('List recordings - Fields filtering', () => {
beforeAll(async () => {
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
context = await setupMultiRecordingsTestContext(2, 2, 2);
});
it('should filter fields using X-Fields header', async () => {
const response = await getAllRecordings({}, { xFields: 'recordingId,roomId' });
expectSuccessListRecordingResponse(response, 2, false, false);
response.body.recordings.forEach((recording: MeetRecordingInfo) => {
expectValidRecordingWithFields(recording, ['recordingId', 'roomId']);
});
});
it('should combine X-Fields header with fields query param (union)', async () => {
// Query param requests 'recordingId', header requests 'roomName' → result should have both
const response = await getAllRecordings({ fields: 'recordingId' }, { xFields: 'roomName' });
expectSuccessListRecordingResponse(response, 2, false, false);
response.body.recordings.forEach((recording: MeetRecordingInfo) => {
expectValidRecordingWithFields(recording, ['recordingId', 'roomName']);
});
});
it('should deduplicate fields when same field is in both query param and header', async () => {
const response = await getAllRecordings(
{ fields: 'recordingId,roomId' },
{ xFields: 'recordingId,status' }
);
expectSuccessListRecordingResponse(response, 2, false, false);
response.body.recordings.forEach((recording: MeetRecordingInfo) => {
expectValidRecordingWithFields(recording, ['recordingId', 'roomId', 'status']);
});
});
it('should work with only headers and no query params', async () => {
const response = await getAllRecordings({}, { xFields: 'recordingId,status,roomId' });
expectSuccessListRecordingResponse(response, 2, false, false);
response.body.recordings.forEach((recording: MeetRecordingInfo) => {
expectValidRecordingWithFields(recording, ['recordingId', 'roomId', 'status']);
});
});
it('should ignore invalid header values gracefully and fallback to query params', async () => {
const response = await getAllRecordings({ fields: 'recordingId,roomId' }, { xFields: '' });
expectSuccessListRecordingResponse(response, 2, false, false);
response.body.recordings.forEach((recording: MeetRecordingInfo) => {
expectValidRecordingWithFields(recording, ['recordingId', 'roomId']);
});
});
it('should ignore invalid field names in X-Fields header', async () => {
const response = await getAllRecordings({}, { xFields: 'recordingId,invalidField,roomId' });
expectSuccessListRecordingResponse(response, 2, false, false);
response.body.recordings.forEach((recording: MeetRecordingInfo) => {
expectValidRecordingWithFields(recording, ['recordingId', 'roomId']);
expect(recording).not.toHaveProperty('invalidField');
});
});
});
describe('List Recordings Validation', () => {
it('should fail when maxItems is not a number', async () => {
const response = await getAllRecordings({ maxItems: 'not-a-number' });

View File

@ -0,0 +1,62 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import {
expectValidRecordingLocationHeader,
expectValidRecordingWithFields
} from '../../../helpers/assertion-helpers.js';
import {
deleteAllRecordings,
deleteAllRooms,
disconnectFakeParticipants,
startTestServer,
stopRecording
} from '../../../helpers/request-helpers.js';
import { setupSingleRoomWithRecording } from '../../../helpers/test-scenarios.js';
import { TestContext } from '../../../interfaces/scenarios.js';
/**
* Tests for X-Fields header and fields query parameter support across all recording operations.
*
* All recording operations (POST start, POST stop, GET all, GET one) support:
* - `fields` query parameter for filtering response fields
* - `X-Fields` header for filtering response fields
* When both are provided, values are merged (union of unique fields).
*/
describe('Recording Header Fields Tests', () => {
let context: TestContext | null = null;
beforeAll(async () => {
await startTestServer();
});
afterAll(async () => {
await disconnectFakeParticipants();
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
context = null;
});
describe('POST /recordings/:recordingId/stop - X-Fields header and fields query param', () => {
afterAll(async () => {
await disconnectFakeParticipants();
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
});
it('should filter response fields using X-Fields header on stop recording', async () => {
const roomData = await setupSingleRoomWithRecording(false);
const recId = roomData.recordingId!;
const response = await stopRecording(recId, {
headers: { xFields: 'recordingId,status' }
});
expect(response.status).toBe(202);
expectValidRecordingLocationHeader(response);
expectValidRecordingWithFields(response.body, ['recordingId', 'status']);
});
});
});

View File

@ -13,6 +13,8 @@ import { errorRoomNotFound } from '../../../../src/models/error.model.js';
import { RecordingRepository } from '../../../../src/repositories/recording.repository.js';
import {
expectValidationError,
expectValidRecordingLocationHeader,
expectValidRecordingWithFields,
expectValidStartRecordingResponse,
expectValidStopRecordingResponse
} from '../../../helpers/assertion-helpers.js';
@ -132,6 +134,36 @@ describe('Recording API Tests', () => {
});
});
describe('Start recordings - Fields filtering', () => {
let room: MeetRoom;
beforeAll(async () => {
// Create a room and join a participant
context = await setupMultiRoomTestContext(1, true);
({ room } = context.getRoomByIndex(0)!);
});
afterAll(async () => {
await disconnectFakeParticipants();
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
context = null;
});
it('should filter response fields using X-Fields header on start recording', async () => {
// Start a new recording with X-Fields header
const response = await startRecording(room.roomId, undefined, {
headers: { xFields: 'recordingId,roomId,status' }
});
expect(response.status).toBe(201);
expectValidRecordingLocationHeader(response);
expectValidRecordingWithFields(response.body, ['recordingId', 'roomId', 'status']);
// Clean up
await stopRecording(response.body.recordingId);
});
});
describe('Start Recording Validation failures', () => {
beforeAll(async () => {
// Create a room without participants

View File

@ -1,6 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import { MeetRoom } from '@openvidu-meet/typings';
import { expectErrorResponse, expectValidStopRecordingResponse } from '../../../helpers/assertion-helpers';
import {
expectErrorResponse,
expectValidRecordingLocationHeader,
expectValidRecordingWithFields,
expectValidStopRecordingResponse
} from '../../../helpers/assertion-helpers';
import {
deleteAllRecordings,
deleteAllRooms,
@ -93,11 +98,32 @@ describe('Recording API Tests', () => {
expect(response.body.message).toContain('Invalid request');
expect(response.body.details).toStrictEqual([
{
field: 'recordingId',
field: 'params.recordingId',
message: 'recordingId does not follow the expected format'
}
]);
});
});
});
describe('POST /recordings/:recordingId/stop - X-Fields header and fields query param', () => {
let recordingId: string;
beforeAll(async () => {
// Create a room and join a participant
context = await setupMultiRoomTestContext(1, true);
({ room } = context.getRoomByIndex(0)!);
const response = await startRecording(room.roomId);
recordingId = response.body.recordingId;
});
it('should filter response fields using X-Fields header on stop recording', async () => {
const response = await stopRecording(recordingId, {
headers: { xFields: 'recordingId,status' }
});
expect(response.status).toBe(202);
expectValidRecordingLocationHeader(response);
expectValidRecordingWithFields(response.body, ['recordingId', 'status']);
});
});
});