From 874538a8b73064b69164a4b453d14c8e99eb5b0d Mon Sep 17 00:00:00 2001 From: CSantosM <4a.santos@gmail.com> Date: Mon, 16 Feb 2026 19:02:00 +0100 Subject: [PATCH] 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). --- .../tests/helpers/assertion-helpers.ts | 3 +- .../backend/tests/helpers/request-helpers.ts | 62 ++++++++++++++--- .../api/recordings/get-recording.test.ts | 62 ++++++++++++++++- .../api/recordings/get-recordings.test.ts | 68 ++++++++++++++++++- .../recording-header-fields.test.ts | 62 +++++++++++++++++ .../api/recordings/start-recording.test.ts | 32 +++++++++ .../api/recordings/stop-recording.test.ts | 30 +++++++- 7 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 meet-ce/backend/tests/integration/api/recordings/recording-header-fields.test.ts diff --git a/meet-ce/backend/tests/helpers/assertion-helpers.ts b/meet-ce/backend/tests/helpers/assertion-helpers.ts index 8af7da8e..57c8f9a0 100644 --- a/meet-ce/backend/tests/helpers/assertion-helpers.ts +++ b/meet-ce/backend/tests/helpers/assertion-helpers.ts @@ -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(); }); }; diff --git a/meet-ce/backend/tests/helpers/request-helpers.ts b/meet-ce/backend/tests/helpers/request-helpers.ts index 3124f5ec..71fb3330 100644 --- a/meet-ce/backend/tests/helpers/request-helpers.ts +++ b/meet-ce/backend/tests/helpers/request-helpers.ts @@ -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 = {}) => { +export const getAllRecordings = async ( + query: Record = {}, + 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 = {}; + + 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) => { diff --git a/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts index 3dcea2e1..c05ffa5b 100644 --- a/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts @@ -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'); diff --git a/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts b/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts index c7e37eef..febb92d1 100644 --- a/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts @@ -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' }); diff --git a/meet-ce/backend/tests/integration/api/recordings/recording-header-fields.test.ts b/meet-ce/backend/tests/integration/api/recordings/recording-header-fields.test.ts new file mode 100644 index 00000000..2a00d843 --- /dev/null +++ b/meet-ce/backend/tests/integration/api/recordings/recording-header-fields.test.ts @@ -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']); + }); + }); +}); diff --git a/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts index 8a2f4353..4c07407d 100644 --- a/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts @@ -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 diff --git a/meet-ce/backend/tests/integration/api/recordings/stop-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/stop-recording.test.ts index e295df8d..722d0a92 100644 --- a/meet-ce/backend/tests/integration/api/recordings/stop-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/stop-recording.test.ts @@ -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']); + }); + }); });