backend: enhance room deletion API to support field filtering

- Added support for `fields` and `extraFields` parameters in `deleteMeetRoom` and `bulkDeleteMeetRooms` methods to allow clients to specify which fields to include in the response.
- Updated the `RoomService` to handle field filtering logic when deleting rooms, ensuring only requested fields are returned in the response.
- Enhanced integration tests to verify that the API correctly filters responses based on `fields` and `extraFields` query parameters and headers during room deletion operations.
- Created new test cases to validate the behavior when some rooms fail to delete due to active meetings, ensuring the response includes the correct fields.
- Refactored existing tests to accommodate the new field filtering functionality and ensure comprehensive coverage.
This commit is contained in:
CSantosM 2026-02-13 15:26:56 +01:00
parent 5c80387244
commit d155846708
16 changed files with 1076 additions and 149 deletions

View File

@ -0,0 +1,31 @@
name: X-ExtraFields
in: header
description: >
Specifies which extra fields of the `room` object to include fully in the response.
<br/><br/>
By default, certain large or nested properties of the `room` object (like `config`) are excluded
to optimize payload size and reduce network bandwidth.
<br/><br/>
Use this header to include the full data of these properties in the response,
avoiding the need for a subsequent GET request.
<br/><br/>
Provide a comma-separated list of `room` property names to include.
<br/><br/>
> **Note:** Extra fields specified here will be included even if not specified in the `X-Fields` header.
<br/><br/>
This header is supported on all room operations. For GET and DELETE operations,
it can be combined with the `extraFields` query parameter. When both are provided,
values are merged (union of unique fields).
required: false
schema:
type: string

View File

@ -0,0 +1,22 @@
name: X-Fields
in: header
description: >
Specifies which fields of the `room` object to include in the response.
<br/><br/>
Provide a comma-separated list of `room` property names to filter the response payload.
<br/><br/>
This header allows you to optimize API responses by requesting only the data you need
from the `room` object, reducing bandwidth usage and improving performance.
<br/><br/>
This header is supported on all room operations. For GET and DELETE operations,
it can be combined with the `fields` query parameter. When both are provided,
values are merged (union of unique fields).
required: false
schema:
type: string
examples:
basic:
value: 'roomId,roomName,accessUrl'
summary: Only return basic room information

View File

@ -1,24 +0,0 @@
name: X-ExtraFields
in: header
description: >
Specifies which extra fields to include fully in the response.
By default, certain large or nested properties (like `config`) are excluded
to optimize payload size and reduce network bandwidth.
Use this header to include the full data of these properties in the creation response,
avoiding the need for a subsequent GET request.
Provide a comma-separated list of property names to include.
Note: Extra fields specified here will be included even if not specified in the `X-Fields` header.
required: false
schema:
type: string
examples:
config:
value: 'config'
summary: Include full room configuration in response
combined:
value: 'config'
summary: 'Use with X-Fields header for union behavior (X-Fields X-ExtraFields)'

View File

@ -1,15 +0,0 @@
name: X-Fields
in: header
description: >
Specifies which fields to include in the response for the room resource.
Provide a comma-separated list of field names to filter the response payload.
This header allows you to optimize API responses by requesting only the data you need,
reducing bandwidth usage and improving performance.
required: false
schema:
type: string
examples:
basic:
value: 'roomId,roomName,accessUrl'
summary: Only return basic room information

View File

@ -12,24 +12,20 @@ import { container } from '../config/dependency-injector.config.js';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import { MeetRoomHelper } from '../helpers/room.helper.js';
import { handleError } from '../models/error.model.js';
import { MeetRoomDeletionOptions } from '../models/request-context.model.js';
import { LoggerService } from '../services/logger.service.js';
import { RoomService } from '../services/room.service.js';
import { getBaseUrl } from '../utils/url.utils.js';
interface RequestWithValidatedHeaders extends Request {
validatedHeaders?: {
'x-fields'?: MeetRoomField[];
'x-extrafields'?: MeetRoomExtraField[];
};
}
export const createRoom = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const options: MeetRoomOptions = req.body;
const { validatedHeaders } = req as RequestWithValidatedHeaders;
const fields = validatedHeaders?.['x-fields'];
const extraFields = validatedHeaders?.['x-extrafields'];
// Fields are merged from headers into req.query by the middleware
const { fields, extraFields } = req.query as {
fields?: MeetRoomField[];
extraFields?: MeetRoomExtraField[];
};
try {
logger.verbose(`Creating room with options '${JSON.stringify(options)}'`);
@ -105,16 +101,22 @@ export const deleteRoom = async (req: Request, res: Response) => {
const roomService = container.get(RoomService);
const { roomId } = req.params;
const { withMeeting, withRecordings } = req.query as {
const { fields, extraFields, withMeeting, withRecordings } = req.query as {
fields?: MeetRoomField[];
extraFields?: MeetRoomExtraField[];
withMeeting: MeetRoomDeletionPolicyWithMeeting;
withRecordings: MeetRoomDeletionPolicyWithRecordings;
};
try {
logger.verbose(`Deleting room '${roomId}'`);
const response = await roomService.deleteMeetRoom(roomId, withMeeting, withRecordings);
const deleteOpts: MeetRoomDeletionOptions = {
withMeeting,
withRecordings,
fields: MeetRoomHelper.computeFieldsForRoomQuery(fields, extraFields)
};
const response = await roomService.deleteMeetRoom(roomId, deleteOpts);
// Add metadata to room if present in response
if (response.room) {
response.room = MeetRoomHelper.addResponseMetadata(response.room);
}
@ -139,17 +141,24 @@ export const bulkDeleteRooms = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const { roomIds, withMeeting, withRecordings } = req.query as {
const { roomIds, fields, extraFields, withMeeting, withRecordings } = req.query as {
roomIds: string[];
fields?: MeetRoomField[];
extraFields?: MeetRoomExtraField[];
withMeeting: MeetRoomDeletionPolicyWithMeeting;
withRecordings: MeetRoomDeletionPolicyWithRecordings;
};
try {
logger.verbose(`Deleting rooms: ${roomIds}`);
const { successful, failed } = await roomService.bulkDeleteMeetRooms(roomIds, withMeeting, withRecordings);
logger.verbose(`Deleting rooms: ${roomIds} with options: ${JSON.stringify(req.query)}`);
const deleteOpts: MeetRoomDeletionOptions = {
withMeeting,
withRecordings,
fields: MeetRoomHelper.computeFieldsForRoomQuery(fields, extraFields)
};
const { successful, failed } = await roomService.bulkDeleteMeetRooms(roomIds, deleteOpts);
// Add metadata to each room object in successful/failed arrays
successful.forEach((item) => {
if (item.room) {
item.room = MeetRoomHelper.addResponseMetadata(item.room);

View File

@ -2,12 +2,12 @@ import { NextFunction, Request, Response } from 'express';
import { rejectUnprocessableRequest } from '../../models/error.model.js';
import {
BulkDeleteRoomsReqSchema,
CreateRoomHeadersSchema,
DeleteRoomReqSchema,
GetRoomQuerySchema,
mergeHeaderFieldsIntoQuery,
nonEmptySanitizedRoomId,
RoomFiltersSchema,
RoomOptionsSchema,
RoomQueryFieldsSchema,
UpdateRoomAnonymousReqSchema,
UpdateRoomConfigReqSchema,
UpdateRoomRolesReqSchema,
@ -15,26 +15,30 @@ import {
} from '../../models/zod-schemas/room.schema.js';
export const validateCreateRoomReq = (req: Request, res: Response, next: NextFunction) => {
mergeHeaderFieldsIntoQuery(req.headers, req.query);
const bodyResult = RoomOptionsSchema.safeParse(req.body);
if (!bodyResult.success) {
return rejectUnprocessableRequest(res, bodyResult.error);
}
// Validate X-Fields and X-Expand headers
const headersResult = CreateRoomHeadersSchema.safeParse(req.headers);
req.body = bodyResult.data;
if (!headersResult.success) {
return rejectUnprocessableRequest(res, headersResult.error);
const { success, error, data } = RoomQueryFieldsSchema.safeParse(req.query);
if (!success) {
return rejectUnprocessableRequest(res, error);
}
req.body = bodyResult.data;
// Store validated headers in a custom property for controller access
(req as any).validatedHeaders = headersResult.data;
req.query = data;
next();
};
export const validateGetRoomsReq = (req: Request, res: Response, next: NextFunction) => {
// Merge X-Fields and X-ExtraFields headers into query params before validation
mergeHeaderFieldsIntoQuery(req.headers, req.query);
const { success, error, data } = RoomFiltersSchema.safeParse(req.query);
if (!success) {
@ -49,6 +53,9 @@ export const validateGetRoomsReq = (req: Request, res: Response, next: NextFunct
};
export const validateBulkDeleteRoomsReq = (req: Request, res: Response, next: NextFunction) => {
// Merge X-Fields and X-ExtraFields headers into query params before validation
mergeHeaderFieldsIntoQuery(req.headers, req.query);
const { success, error, data } = BulkDeleteRoomsReqSchema.safeParse(req.query);
if (!success) {
@ -72,7 +79,10 @@ export const withValidRoomId = (req: Request, res: Response, next: NextFunction)
};
export const validateGetRoomReq = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = GetRoomQuerySchema.safeParse(req.query);
// Merge X-Fields and X-ExtraFields headers into query params before validation
mergeHeaderFieldsIntoQuery(req.headers, req.query);
const { success, error, data } = RoomQueryFieldsSchema.safeParse(req.query);
if (!success) {
return rejectUnprocessableRequest(res, error);
@ -91,6 +101,9 @@ export const validateDeleteRoomReq = (req: Request, res: Response, next: NextFun
req.params.roomId = roomIdResult.data;
// Merge X-Fields and X-ExtraFields headers into query params before validation
mergeHeaderFieldsIntoQuery(req.headers, req.query);
const queryParamsResult = DeleteRoomReqSchema.safeParse(req.query);
if (!queryParamsResult.success) {

View File

@ -1,4 +1,10 @@
import { MeetRoomMemberTokenMetadata, MeetUser } from '@openvidu-meet/typings';
import {
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomField,
MeetRoomMemberTokenMetadata,
MeetUser
} from '@openvidu-meet/typings';
/**
* Context information stored per HTTP request.
@ -7,3 +13,15 @@ export interface RequestContext {
user?: MeetUser;
roomMember?: MeetRoomMemberTokenMetadata;
}
/**
* Options for room deletion operations.
*/
export interface MeetRoomDeletionOptions {
/** Policy for handling rooms with active meetings */
withMeeting?: MeetRoomDeletionPolicyWithMeeting;
/** Policy for handling rooms with recordings */
withRecordings?: MeetRoomDeletionPolicyWithRecordings;
/** Array of base fields to include in the response (for HTTP field filtering) */
fields?: MeetRoomField[];
}

View File

@ -374,19 +374,6 @@ export const RoomOptionsSchema: z.ZodType<MeetRoomOptions> = z.object({
const extraFieldsSchema = z
.string()
.optional()
.refine(
(value) => {
if (!value) return true;
const allowed = MEET_ROOM_EXTRA_FIELDS;
const requested = value.split(',').map((p) => p.trim());
return requested.every((p) => allowed.includes(p as MeetRoomExtraField));
},
{
message: `Invalid extraFields. Valid options: ${MEET_ROOM_EXTRA_FIELDS.join(', ')}`
}
)
.transform((value) => {
// Transform to typed array of MeetRoomExtraField
if (!value) return undefined;
@ -450,17 +437,70 @@ export const RoomFiltersSchema = z.object({
sortOrder: z.enum(['asc', 'desc']).optional().default('desc')
});
export const GetRoomQuerySchema = z.object({
export const RoomQueryFieldsSchema = z.object({
fields: fieldsSchema,
extraFields: extraFieldsSchema
});
export const CreateRoomHeadersSchema = z.object({
/**
* Schema for validating X-Fields and X-ExtraFields headers.
* Used across all room operations to support field filtering via headers.
*/
export const RoomHeaderFieldsSchema = z.object({
'x-fields': fieldsSchema,
'x-extrafields': extraFieldsSchema
});
/**
* Merges validated header field values into query params.
* Headers are merged with query params using union (deduplication).
* If a header and query param overlap, the merged result contains unique values from both.
*
* This allows controllers to only read from req.query regardless of whether
* the client used headers, query params, or both.
*
* @param headers - The request headers object
* @param query - The current query params object (will be mutated)
*/
export function mergeHeaderFieldsIntoQuery(headers: Record<string, unknown>, query: Record<string, unknown>): void {
const headerResult = RoomHeaderFieldsSchema.safeParse(headers);
if (!headerResult.success) {
// If headers are invalid, skip merging (they'll be ignored)
return;
}
const headerFields = headerResult.data['x-fields'];
const headerExtraFields = headerResult.data['x-extrafields'];
if (headerFields) {
const existingFields =
typeof query.fields === 'string'
? query.fields
.split(',')
.map((f: string) => f.trim())
.filter((f: string) => f !== '')
: [];
const merged = Array.from(new Set([...existingFields, ...headerFields]));
query.fields = merged.join(',');
}
if (headerExtraFields) {
const existingExtraFields =
typeof query.extraFields === 'string'
? query.extraFields
.split(',')
.map((f: string) => f.trim())
.filter((f: string) => f !== '')
: [];
const merged = Array.from(new Set([...existingExtraFields, ...headerExtraFields]));
query.extraFields = merged.join(',');
}
}
export const DeleteRoomReqSchema = z.object({
fields: fieldsSchema,
extraFields: extraFieldsSchema,
withMeeting: RoomDeletionPolicyWithMeetingSchema.optional().default(MeetRoomDeletionPolicyWithMeeting.FAIL),
withRecordings: RoomDeletionPolicyWithRecordingsSchema.optional().default(MeetRoomDeletionPolicyWithRecordings.FAIL)
});
@ -500,6 +540,8 @@ export const BulkDeleteRoomsReqSchema = z.object({
message: 'At least one valid roomId is required after sanitization'
})
),
fields: fieldsSchema,
extraFields: extraFieldsSchema,
withMeeting: RoomDeletionPolicyWithMeetingSchema.optional().default(MeetRoomDeletionPolicyWithMeeting.FAIL),
withRecordings: RoomDeletionPolicyWithRecordingsSchema.optional().default(MeetRoomDeletionPolicyWithRecordings.FAIL)
});

View File

@ -37,6 +37,7 @@ import {
OpenViduMeetError
} from '../models/error.model.js';
import { MeetRoomDeletionOptions } from '../models/request-context.model.js';
import { RoomMemberRepository } from '../repositories/room-member.repository.js';
import { RoomRepository } from '../repositories/room.repository.js';
import { FrontendEventService } from './frontend-event.service.js';
@ -425,27 +426,37 @@ export class RoomService {
* Deletes a room based on the specified policies for handling active meetings and recordings.
*
* @param roomId - The unique identifier of the room to delete
* @param withMeeting - Policy for handling rooms with active meetings
* @param withRecordings - Policy for handling rooms with recordings
* @param options - Deletion options including policies for handling active meetings and recordings
* @returns Promise with deletion result including status code, success code, message and room (if updated instead of deleted)
* @throws Error with specific error codes for conflict scenarios
*/
async deleteMeetRoom(
roomId: string,
withMeeting = MeetRoomDeletionPolicyWithMeeting.FAIL,
withRecordings = MeetRoomDeletionPolicyWithRecordings.FAIL
options: MeetRoomDeletionOptions = {}
): Promise<{
successCode: MeetRoomDeletionSuccessCode;
message: string;
room?: MeetRoom;
}> {
const {
withMeeting = MeetRoomDeletionPolicyWithMeeting.FAIL,
withRecordings = MeetRoomDeletionPolicyWithRecordings.FAIL,
fields
} = options;
try {
this.logger.info(
`Deleting room '${roomId}' with policies: withMeeting=${withMeeting}, withRecordings=${withRecordings}`
);
// Check if there's an active meeting in the room and/or if it has recordings associated
const room = await this.getMeetRoom(roomId, ['status']);
// Create a Set for adding required fields for deletion logic
const requiredFields = new Set<MeetRoomField>(['roomId', 'status']);
// requiredFields.add('autoDeletionPolicy');
// requiredFields.add('meetingEndAction');
// Merge and deduplicate fields for DB query
const fieldsForQuery = Array.from(new Set([...(fields || []), ...requiredFields]));
const room = await this.getMeetRoom(roomId, Array.from(fieldsForQuery));
const hasActiveMeeting = room.status === MeetRoomStatus.ACTIVE_MEETING;
const hasRecordings = await this.recordingService.hasRoomRecordings(roomId);
@ -453,20 +464,25 @@ export class RoomService {
`Room '${roomId}' status: hasActiveMeeting=${hasActiveMeeting}, hasRecordings=${hasRecordings}`
);
const updatedRoom = await this.executeDeletionStrategy(
roomId,
// Pass room object to avoid second DB fetch
let updatedRoom = await this.executeDeletionStrategy(
room,
hasActiveMeeting,
hasRecordings,
withMeeting,
withRecordings
);
// Remove required fields added for deletion logic from the response (they are not needed in the response)
updatedRoom = updatedRoom ? MeetRoomHelper.applyFieldFilters(updatedRoom, fields) : undefined;
return this.getDeletionResponse(
roomId,
hasActiveMeeting,
hasRecordings,
withMeeting,
withRecordings,
MeetRoomHelper.applyFieldFilters(updatedRoom!, undefined, [])
updatedRoom
);
} catch (error) {
this.logger.error(`Error deleting room '${roomId}': ${error}`);
@ -481,14 +497,18 @@ export class RoomService {
* - If there is an active meeting, sets the meeting end action (DELETE or CLOSE) and optionally ends the meeting.
* - If there are recordings and policy is CLOSE, closes the room.
* - If force delete is requested, deletes the room and all recordings and members.
*
* @param room - The room object (already fetched from DB) to avoid duplicate queries
*/
protected async executeDeletionStrategy(
roomId: string,
room: MeetRoom,
hasActiveMeeting: boolean,
hasRecordings: boolean,
withMeeting: MeetRoomDeletionPolicyWithMeeting,
withRecordings: MeetRoomDeletionPolicyWithRecordings
): Promise<MeetRoom | undefined> {
const roomId = room.roomId;
// Validate policies first (fail-fast)
this.validateDeletionPolicies(roomId, hasActiveMeeting, hasRecordings, withMeeting, withRecordings);
@ -501,8 +521,6 @@ export class RoomService {
return undefined;
}
const room = await this.getMeetRoom(roomId);
// Determine actions based on policies
const shouldForceEndMeeting = hasActiveMeeting && withMeeting === MeetRoomDeletionPolicyWithMeeting.FORCE;
const shouldCloseRoom = hasRecordings && withRecordings === MeetRoomDeletionPolicyWithRecordings.CLOSE;
@ -697,17 +715,15 @@ export class RoomService {
/**
* Deletes multiple rooms in bulk using the deleteMeetRoom method, processing them in batches.
*
* @param rooms - Array of room identifiers to be deleted.
* @param roomsOrRoomIds - Array of room identifiers to be deleted.
* If an array of MeetRoom objects is provided, the roomId will be extracted from each object.
* @param withMeeting - Policy for handling rooms with active meetings
* @param withRecordings - Policy for handling rooms with recordings
* @param options - Deletion options including policies for handling active meetings and recordings
* @param batchSize - Number of rooms to process in each batch (default: 10)
* @returns Promise with arrays of successful and failed deletions
*/
async bulkDeleteMeetRooms(
rooms: string[] | MeetRoom[],
withMeeting = MeetRoomDeletionPolicyWithMeeting.FAIL,
withRecordings = MeetRoomDeletionPolicyWithRecordings.FAIL,
roomsOrRoomIds: string[] | MeetRoom[],
options: MeetRoomDeletionOptions = {},
batchSize = 10
): Promise<{
successful: {
@ -722,8 +738,14 @@ export class RoomService {
message: string;
}[];
}> {
const {
withMeeting = MeetRoomDeletionPolicyWithMeeting.FAIL,
withRecordings = MeetRoomDeletionPolicyWithRecordings.FAIL,
fields
} = options;
this.logger.info(
`Starting bulk deletion of ${rooms.length} rooms with policies: withMeeting=${withMeeting}, withRecordings=${withRecordings}`
`Starting bulk deletion of ${roomsOrRoomIds.length} rooms with policies: withMeeting=${withMeeting}, withRecordings=${withRecordings}`
);
const successful: {
@ -739,10 +761,10 @@ export class RoomService {
}[] = [];
// Process rooms in batches
for (let i = 0; i < rooms.length; i += batchSize) {
const batch = rooms.slice(i, i + batchSize);
for (let i = 0; i < roomsOrRoomIds.length; i += batchSize) {
const batch = roomsOrRoomIds.slice(i, i + batchSize);
const batchNumber = Math.floor(i / batchSize) + 1;
const totalBatches = Math.ceil(rooms.length / batchSize);
const totalBatches = Math.ceil(roomsOrRoomIds.length / batchSize);
this.logger.debug(`Processing batch ${batchNumber}/${totalBatches} with ${batch.length} rooms`);
@ -754,6 +776,10 @@ export class RoomService {
try {
const user = this.requestSessionService.getAuthenticatedUser();
// !FIXME: This permission check is necessary for HTTP requests,
// !but it is not ideal to have it here in the service layer,
// !as this method can also be called from non-HTTP contexts (e.g., scheduled jobs for auto-deletion, background jobs for recording management, etc).
// !This should be refactored and moved to a controller or a separate layer responsible for access control for HTTP requests.
// Check permissions if user is authenticated and not an admin
if (user && user.role !== MeetUserRole.ADMIN) {
const isOwner = await this.isRoomOwner(roomId, user.userId);
@ -766,14 +792,14 @@ export class RoomService {
let result;
if (typeof room === 'string') {
result = await this.deleteMeetRoom(roomId, withMeeting, withRecordings);
result = await this.deleteMeetRoom(roomId, { withMeeting, withRecordings, fields });
} else {
// Extract deletion policies from the room object
result = await this.deleteMeetRoom(
roomId,
room.autoDeletionPolicy?.withMeeting,
room.autoDeletionPolicy?.withRecordings
);
result = await this.deleteMeetRoom(roomId, {
withMeeting: room.autoDeletionPolicy?.withMeeting,
withRecordings: room.autoDeletionPolicy?.withRecordings,
fields
});
}
this.logger.info(result.message);
@ -824,7 +850,7 @@ export class RoomService {
}
this.logger.info(
`Bulk deletion completed: ${successful.length}/${rooms.length} successful, ${failed.length}/${rooms.length} failed`
`Bulk deletion completed: ${successful.length}/${roomsOrRoomIds.length} successful, ${failed.length}/${roomsOrRoomIds.length} failed`
);
return { successful, failed };
}

View File

@ -10,6 +10,8 @@ import {
MeetRoomConfig,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomExtraField,
MeetRoomField,
MeetRoomMemberOptions,
MeetRoomMemberRole,
MeetRoomMemberTokenMetadata,
@ -439,13 +441,26 @@ export const createRoom = async (
return response.body;
};
export const getRooms = async (query: Record<string, unknown> = {}) => {
export const getRooms = async (
query: Record<string, unknown> = {},
headers?: { xFields?: string; xExtraFields?: string }
) => {
checkAppIsRunning();
return await request(app)
const req = request(app)
.get(getFullPath(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`))
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.query(query);
if (headers?.xFields) {
req.set('x-fields', headers.xFields);
}
if (headers?.xExtraFields) {
req.set('x-extrafields', headers.xExtraFields);
}
return await req;
};
/**
@ -458,7 +473,13 @@ export const getRooms = async (query: Record<string, unknown> = {}) => {
* @returns A Promise that resolves to the room data
* @throws Error if the app instance is not defined
*/
export const getRoom = async (roomId: string, fields?: string, extraFields?: string, roomMemberToken?: string) => {
export const getRoom = async (
roomId: string,
fields?: string,
extraFields?: string,
roomMemberToken?: string,
headers?: { xFields?: string; xExtraFields?: string }
) => {
checkAppIsRunning();
const queryParams: Record<string, string> = {};
@ -477,6 +498,14 @@ export const getRoom = async (roomId: string, fields?: string, extraFields?: str
req.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY);
}
if (headers?.xFields) {
req.set('x-fields', headers.xFields);
}
if (headers?.xExtraFields) {
req.set('x-extrafields', headers.xExtraFields);
}
return await req;
};
@ -525,24 +554,69 @@ export const updateRoomAnonymousConfig = async (roomId: string, anonymousConfig:
.send({ anonymous: anonymousConfig });
};
export const deleteRoom = async (roomId: string, query: Record<string, unknown> = {}) => {
export const deleteRoom = async (
roomId: string,
query: Record<string, unknown> = {},
headers?: { xFields?: string; xExtraFields?: string }
) => {
checkAppIsRunning();
const result = await request(app)
const req = request(app)
.delete(getFullPath(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms/${roomId}`))
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.query(query);
if (headers?.xFields) {
req.set('x-fields', headers.xFields);
}
if (headers?.xExtraFields) {
req.set('x-extrafields', headers.xExtraFields);
}
const result = await req;
await sleep('1s');
return result;
};
export const bulkDeleteRooms = async (roomIds: string[], withMeeting?: string, withRecordings?: string) => {
export const bulkDeleteRooms = async (
roomIds: string[],
withMeeting?: string,
withRecordings?: string,
fields?: MeetRoomField[],
extraFields?: MeetRoomExtraField[],
headers?: { xFields?: string; xExtraFields?: string }
) => {
checkAppIsRunning();
const result = await request(app)
const query: Record<string, string | boolean | undefined> = {
roomIds: roomIds.join(','),
withMeeting,
withRecordings
};
if (fields) {
query.fields = fields.join(',');
}
if (extraFields) {
query.extraFields = extraFields.join(',');
}
const req = request(app)
.delete(getFullPath(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`))
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.query({ roomIds: roomIds.join(','), withMeeting, withRecordings });
.query(query);
if (headers?.xFields) {
req.set('x-fields', headers.xFields);
}
if (headers?.xExtraFields) {
req.set('x-extrafields', headers.xExtraFields);
}
const result = await req;
await sleep('1s');
return result;
};

View File

@ -253,6 +253,264 @@ describe('Room API Tests', () => {
);
expectExtraFieldsInResponse(successfulRoom4.room);
});
it('should return partial room properties based on fields parameter when some rooms fail due to active meetings', async () => {
// Create a room with an active meeting that will be scheduled for deletion
const { room: roomWithMeeting } = await setupSingleRoom(true);
// Create a room without an active meeting that will be deleted immediately
const { room: roomWithoutMeeting } = await setupSingleRoom(false);
// Attempt to bulk delete both rooms with specific fields using query params
const response = await bulkDeleteRooms(
[roomWithMeeting.roomId, roomWithoutMeeting.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
MeetRoomDeletionPolicyWithRecordings.FAIL,
['roomId', 'roomName'] // fields query param
);
expect(response.status).toBe(200);
expect(response.body.successful).toHaveLength(2);
// Find the room with meeting (should have room object in response)
const scheduledRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithMeeting.roomId
);
expect(scheduledRoom).toBeDefined();
expect(scheduledRoom.room).toBeDefined();
expect(scheduledRoom.successCode).toBe(
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify only requested fields are present
expect(Object.keys(scheduledRoom.room)).toHaveLength(3); // roomId, roomName, and _extraFields
expect(scheduledRoom.room.roomId).toBe(roomWithMeeting.roomId);
expect(scheduledRoom.room.roomName).toBeDefined();
// Find the room without meeting (should NOT have room object)
const deletedRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithoutMeeting.roomId
);
expect(deletedRoom).toBeDefined();
expect(deletedRoom.room).toBeUndefined();
expect(deletedRoom.successCode).toBe(MeetRoomDeletionSuccessCode.ROOM_DELETED);
});
it('should return partial room properties based on fields header when some rooms fail due to active meetings', async () => {
// Create a room with an active meeting that will be scheduled for deletion
const { room: roomWithMeeting } = await setupSingleRoom(true);
// Create a room without an active meeting
const { room: roomWithoutMeeting } = await setupSingleRoom(false);
// Attempt to bulk delete both rooms with specific fields using X-Fields header
const response = await bulkDeleteRooms(
[roomWithMeeting.roomId, roomWithoutMeeting.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
MeetRoomDeletionPolicyWithRecordings.FAIL,
undefined, // no query param fields
undefined, // no query param extraFields
{ xFields: 'roomId' } // X-Fields header
);
expect(response.status).toBe(200);
expect(response.body.successful).toHaveLength(2);
// Find the room with meeting (should have room object in response)
const scheduledRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithMeeting.roomId
);
expect(scheduledRoom).toBeDefined();
expect(scheduledRoom.room).toBeDefined();
// Verify only requested fields are present
expect(Object.keys(scheduledRoom.room)).toHaveLength(2); // roomId and _extraFields
expect(scheduledRoom.room.roomId).toBe(roomWithMeeting.roomId);
expectExtraFieldsInResponse(scheduledRoom.room);
});
it('should return partial room properties based on extraFields parameter when some rooms fail due to active meetings', async () => {
// Create a room with an active meeting that will be scheduled for deletion
const { room: roomWithMeeting } = await setupSingleRoom(true);
// Create a room without an active meeting
const { room: roomWithoutMeeting } = await setupSingleRoom(false);
// Attempt to bulk delete both rooms with specific extraFields using query params
const response = await bulkDeleteRooms(
[roomWithMeeting.roomId, roomWithoutMeeting.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
MeetRoomDeletionPolicyWithRecordings.FAIL,
undefined, // no fields param
['config'] // extraFields query param
);
expect(response.status).toBe(200);
expect(response.body.successful).toHaveLength(2);
// Find the room with meeting (should have room object in response)
const scheduledRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithMeeting.roomId
);
expect(scheduledRoom).toBeDefined();
expect(scheduledRoom.room).toBeDefined();
// Verify extra fields are present
expect(scheduledRoom.room.config).toBeDefined();
expect(scheduledRoom.room.roles).toBeDefined();
// Base fields should still be present (all base fields returned when no fields param)
expect(scheduledRoom.room.roomId).toBe(roomWithMeeting.roomId);
expect(scheduledRoom.room.roomName).toBeDefined();
expect(scheduledRoom.room.status).toBe(MeetRoomStatus.ACTIVE_MEETING);
expectExtraFieldsInResponse(scheduledRoom.room);
});
it('should return partial room properties based on extraFields header when some rooms fail due to active meetings', async () => {
// Create a room with an active meeting that will be scheduled for deletion
const { room: roomWithMeeting } = await setupSingleRoom(true);
// Create a room without an active meeting
const { room: roomWithoutMeeting } = await setupSingleRoom(false);
// Attempt to bulk delete both rooms with specific extraFields using X-ExtraFields header
const response = await bulkDeleteRooms(
[roomWithMeeting.roomId, roomWithoutMeeting.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
MeetRoomDeletionPolicyWithRecordings.FAIL,
undefined, // no query param fields
undefined, // no query param extraFields
{ xExtraFields: 'config' } // X-ExtraFields header
);
expect(response.status).toBe(200);
expect(response.body.successful).toHaveLength(2);
// Find the room with meeting (should have room object in response)
const scheduledRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithMeeting.roomId
);
expect(scheduledRoom).toBeDefined();
expect(scheduledRoom.room).toBeDefined();
// Verify config extra field is present
expect(scheduledRoom.room.config).toBeDefined();
// All base fields should be present (no fields param)
expect(scheduledRoom.room.roomId).toBe(roomWithMeeting.roomId);
expect(scheduledRoom.room.roomName).toBeDefined();
expect(scheduledRoom.room.status).toBe(MeetRoomStatus.ACTIVE_MEETING);
expectExtraFieldsInResponse(scheduledRoom.room);
});
it('should return partial room properties based on fields and extraFields parameters when some rooms fail due to active meetings', async () => {
// This test will verify that when some rooms fail to delete due to active meetings, the response includes the room details with the correct fields based on the fields and extraFields query parameters.
// Create a room with an active meeting that will be scheduled for deletion
const { room: roomWithMeeting } = await setupSingleRoom(true);
// Create a room without an active meeting
const { room: roomWithoutMeeting } = await setupSingleRoom(false);
// Attempt to bulk delete both rooms with specific fields and extraFields using query params
const response = await bulkDeleteRooms(
[roomWithMeeting.roomId, roomWithoutMeeting.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
MeetRoomDeletionPolicyWithRecordings.FAIL,
['roomId', 'roomName', 'status'], // fields query param
['config'] // extraFields query param
);
expect(response.status).toBe(200);
expect(response.body.successful).toHaveLength(2);
// Find the room with meeting (should have room object in response)
const scheduledRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithMeeting.roomId
);
expect(scheduledRoom).toBeDefined();
expect(scheduledRoom.room).toBeDefined();
expect(scheduledRoom.successCode).toBe(
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify only requested base fields are present
expect(Object.keys(scheduledRoom.room)).toHaveLength(5); // roomId, roomName, status, config and _extraFields
expect(scheduledRoom.room.roomId).toBe(roomWithMeeting.roomId);
expect(scheduledRoom.room.roomName).toBeDefined();
expect(scheduledRoom.room.status).toBe(MeetRoomStatus.ACTIVE_MEETING);
// Verify requested extra field is present
expect(scheduledRoom.room.config).toBeDefined();
expectExtraFieldsInResponse(scheduledRoom.room);
// Find the room without meeting (should NOT have room object)
const deletedRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithoutMeeting.roomId
);
expect(deletedRoom).toBeDefined();
expect(deletedRoom.room).toBeUndefined();
expect(deletedRoom.successCode).toBe(MeetRoomDeletionSuccessCode.ROOM_DELETED);
});
it('should return partial room properties based on fields and extraFields headers when some rooms fail due to active meetings', async () => {
// This test will verify that when some rooms fail to delete due to active meetings, the response includes the room details with the correct fields based on the fields and extraFields headers.
// Create a room with an active meeting
const { room: roomWithMeeting } = await setupSingleRoom(true);
// Create a room without an active meeting
const { room: roomWithoutMeeting } = await setupSingleRoom(false);
// Attempt to bulk delete both rooms with specific fields and extraFields using headers
const response = await bulkDeleteRooms(
[roomWithMeeting.roomId, roomWithoutMeeting.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
MeetRoomDeletionPolicyWithRecordings.FAIL,
undefined, // no query param fields
undefined, // no query param extraFields
{
xFields: 'roomId,roomName', // X-Fields header
xExtraFields: 'config,roles' // X-ExtraFields header
}
);
expect(response.status).toBe(200);
expect(response.body.successful).toHaveLength(2);
// Find the room with meeting (should have room object in response)
const scheduledRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithMeeting.roomId
);
console.log('Scheduled Room Response:', scheduledRoom.room);
expect(scheduledRoom).toBeDefined();
expect(scheduledRoom.room).toBeDefined();
expect(scheduledRoom.successCode).toBe(
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify only requested base fields are present
expect(Object.keys(scheduledRoom.room)).toHaveLength(4); // roomId, roomName, config and _extraFields
expect(scheduledRoom.room.roomId).toBe(roomWithMeeting.roomId);
expect(scheduledRoom.room.roomName).toBeDefined();
// Verify requested extra fields are present
expect(scheduledRoom.room.config).toBeDefined();
expectExtraFieldsInResponse(scheduledRoom.room);
// Find the room without meeting (should NOT have room object)
const deletedRoom = response.body.successful.find(
(s: { roomId: string; room?: MeetRoom }) => s.roomId === roomWithoutMeeting.roomId
);
expect(deletedRoom).toBeDefined();
expect(deletedRoom.room).toBeUndefined();
expect(deletedRoom.successCode).toBe(MeetRoomDeletionSuccessCode.ROOM_DELETED);
});
});
describe('Bulk delete Room Validation failures', () => {

View File

@ -533,22 +533,6 @@ describe('Room API Tests', () => {
});
describe('Room Creation Validation failures', () => {
it('should fail when x-ExtraFields header has invalid value', async () => {
const payload = {
roomName: 'Test Room with Invalid ExtraFields Header'
};
const response = await request(app)
.post(ROOMS_PATH)
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.set('x-ExtraFields', 'invalidField')
.send(payload)
.expect(422);
expect(response.body.error).toContain('Unprocessable Entity');
expect(JSON.stringify(response.body.details)).toContain('Invalid extraFields');
});
it('should fail when autoDeletionDate is negative', async () => {
const payload = {
autoDeletionDate: -5000,

View File

@ -82,7 +82,7 @@ describe('Room API Tests', () => {
expect(getResponse.status).toBe(404);
});
it('should return 202 with successCode=room_with_active_meeting_scheduled_to_be_deleted when withMeeting=when_meeting_ends', async () => {
it('should return 202 with successCode=room_with_active_meeting_deleted when withMeeting=force', async () => {
const response = await deleteRoom(roomId, {
withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS
});
@ -109,6 +109,153 @@ describe('Room API Tests', () => {
expect(getResponse.status).toBe(404);
});
it('should return partial room response when fields parameter are included with successCode=room_with_active_meeting_scheduled_to_be_closed when withMeeting=when_meeting_ends and withRecordings=close', async () => {
const response = await deleteRoom(roomId, {
withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
fields: 'roomId,roomName'
});
expect(response.status).toBe(202);
expect(response.body).toHaveProperty(
'successCode',
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify only requested fields are present
expect(Object.keys(response.body.room)).toHaveLength(3); // roomId, roomName, and _extraFields
expect(response.body.room.roomId).toBe(roomId);
expect(response.body.room.roomName).toBeDefined();
expectExtraFieldsInResponse(response.body.room);
});
it('should return partial room response with extra fields when fields and extraFields parameters are included with successCode=room_with_active_meeting_scheduled_to_be_closed when withMeeting=when_meeting_ends and withRecordings=close', async () => {
const response = await deleteRoom(roomId, {
withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
fields: 'roomId,status',
extraFields: 'config'
});
expect(response.status).toBe(202);
expect(response.body).toHaveProperty(
'successCode',
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify only requested base fields are present
expect(Object.keys(response.body.room)).toHaveLength(4); // roomId, status, config and _extraFields
expectExtraFieldsInResponse(response.body.room);
expect(response.body.room.roomId).toBe(roomId);
expect(response.body.room.status).toBe(MeetRoomStatus.ACTIVE_MEETING);
// Verify extra field is present
expect(response.body.room.config).toBeDefined();
});
it('should return partial room response when X-Fields headers are included with successCode=room_with_active_meeting_scheduled_to_be_closed when withMeeting=when_meeting_ends and withRecordings=close', async () => {
const response = await deleteRoom(
roomId,
{
withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS
},
{
xFields: 'roomId'
}
);
expect(response.status).toBe(202);
expect(response.body).toHaveProperty(
'successCode',
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify only requested field is present
expect(Object.keys(response.body.room)).toHaveLength(2); // roomId and _extraFields
expect(response.body.room.roomId).toBe(roomId);
expectExtraFieldsInResponse(response.body.room);
});
it('should return partial room response when X-ExtraFields headers are included with successCode=room_with_active_meeting_scheduled_to_be_closed when withMeeting=when_meeting_ends and withRecordings=close', async () => {
const response = await deleteRoom(
roomId,
{
withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS
},
{
xExtraFields: 'config,roles'
}
);
expect(response.status).toBe(202);
expect(response.body).toHaveProperty(
'successCode',
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify extra fields are present
expectExtraFieldsInResponse(response.body.room);
expect(response.body.room.config).toBeDefined();
expect(response.body.room.roles).toBeDefined();
// All base fields should be present (no fields param)
expect(response.body.room.roomId).toBe(roomId);
expect(response.body.room.roomName).toBeDefined();
expect(response.body.room.status).toBe(MeetRoomStatus.ACTIVE_MEETING);
});
it('should return partial room response when fields parameters and X-Fields headers are included with successCode=room_with_active_meeting_scheduled_to_be_closed when withMeeting=when_meeting_ends and withRecordings=close', async () => {
const response = await deleteRoom(
roomId,
{
withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
fields: 'roomId'
},
{
xFields: 'roomName,status'
}
);
expect(response.status).toBe(202);
expect(response.body).toHaveProperty(
'successCode',
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify merged fields from both query param and header are present
expect(Object.keys(response.body.room)).toHaveLength(4); // roomId, roomName, status and _extraFields
expectExtraFieldsInResponse(response.body.room);
expect(response.body.room.roomId).toBe(roomId);
expect(response.body.room.roomName).toBeDefined();
});
it('should return partial room response when X-Fields and X-ExtraFields headers are included with successCode=room_with_active_meeting_scheduled_to_be_closed when withMeeting=when_meeting_ends and withRecordings=close', async () => {
const response = await deleteRoom(
roomId,
{
withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS
},
{
xFields: 'roomId,status',
xExtraFields: 'config'
}
);
expect(response.status).toBe(202);
expect(response.body).toHaveProperty(
'successCode',
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
// Verify only requested base fields are present
expect(Object.keys(response.body.room)).toHaveLength(4); // roomId, status, config and _extraFields
expect(response.body.room.roomId).toBe(roomId);
expect(response.body.room.status).toBe(MeetRoomStatus.ACTIVE_MEETING);
// Other base fields should not be present
expect(response.body.room.roomName).toBeUndefined();
// Verify requested extra field is present
expect(response.body.room.config).toBeDefined();
});
it('should return 409 with error=room_has_active_meeting when withMeeting=fail', async () => {
const response = await deleteRoom(roomId, { withMeeting: MeetRoomDeletionPolicyWithMeeting.FAIL });
expect(response.status).toBe(409);

View File

@ -177,16 +177,5 @@ describe('Room API Tests', () => {
expectValidationError(response, 'roomId', 'cannot be empty after sanitization');
});
it('should fail when extraFields has invalid values', async () => {
const createdRoom = await createRoom({
roomName: 'invalid-extrafields-test'
});
// Get room with invalid extraFields values
const response = await getRoom(createdRoom.roomId, undefined, 'invalid,wrongparam');
expectValidationError(response, 'extraFields', 'Invalid extraFields. Valid options: config');
});
});
});

View File

@ -288,13 +288,15 @@ describe('Room API Tests', () => {
expectValidRoom(room, 'extrafields-list-test', 'extrafields_list_test', customConfig);
});
it('should fail when extraFields has invalid values', async () => {
it('should not fail when extraFields has invalid values', async () => {
await createRoom({
roomName: 'invalid-extrafields-list'
});
const response = await getRooms({ extraFields: 'invalid,wrongparam' });
expectValidationError(response, 'extraFields', 'Invalid extraFields');
expectSuccessRoomsResponse(response, 1, 10, false, false);
expectValidRoom(response.body.rooms[0], 'invalid-extrafields-list', 'invalid_extrafields_list');
});
it('should return multiple rooms with full config when using extraFields=config', async () => {

View File

@ -0,0 +1,351 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals';
import {
MeetRecordingEncodingPreset,
MeetRecordingLayout,
MeetRoom,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionSuccessCode
} from '@openvidu-meet/typings';
import {
expectSuccessRoomsResponse,
expectValidRoom,
expectValidRoomWithFields
} from '../../../helpers/assertion-helpers.js';
import {
bulkDeleteRooms,
createRoom,
deleteAllRecordings,
deleteAllRooms,
deleteRoom,
disconnectFakeParticipants,
getRoom,
getRooms,
startTestServer
} from '../../../helpers/request-helpers.js';
import { setupSingleRoom } from '../../../helpers/test-scenarios.js';
/**
* Tests for X-Fields and X-ExtraFields header support across all room operations.
*
* All room operations (POST, GET, DELETE) support the X-Fields and X-ExtraFields headers
* for controlling which fields are included in the response.
*
* For GET and DELETE operations, headers can be combined with query parameters.
* When both are provided, values are merged (union of unique fields).
*/
describe('Room Header Fields Tests', () => {
beforeAll(async () => {
await startTestServer();
});
afterEach(async () => {
await deleteAllRooms();
});
afterAll(async () => {
await disconnectFakeParticipants();
await deleteAllRecordings();
});
describe('GET /rooms - X-Fields and X-ExtraFields headers', () => {
it('should filter fields using X-Fields header', async () => {
await createRoom({ roomName: 'header-fields-test' });
const response = await getRooms({}, { xFields: 'roomId,roomName' });
expectSuccessRoomsResponse(response, 1, 10, false, false);
const room = response.body.rooms[0];
expectValidRoomWithFields(room, ['roomId', 'roomName']);
});
it('should include extra fields using X-ExtraFields header', async () => {
const customConfig = {
recording: {
enabled: true,
layout: MeetRecordingLayout.SPEAKER,
encoding: MeetRecordingEncodingPreset.H264_1080P_30
},
chat: { enabled: false },
virtualBackground: { enabled: true },
e2ee: { enabled: false },
captions: { enabled: true }
};
await createRoom({ roomName: 'header-extrafields-test', config: customConfig });
const response = await getRooms({}, { xExtraFields: 'config' });
expectSuccessRoomsResponse(response, 1, 10, false, false);
const room = response.body.rooms[0];
expectValidRoom(room, 'header-extrafields-test', 'header_extrafields_test', customConfig);
});
it('should combine X-Fields header with fields query param (union)', async () => {
await createRoom({ roomName: 'merge-fields-test' });
// Query param requests 'roomId', header requests 'roomName' → result should have both
const response = await getRooms({ fields: 'roomId' }, { xFields: 'roomName' });
expectSuccessRoomsResponse(response, 1, 10, false, false);
const room = response.body.rooms[0];
expectValidRoomWithFields(room, ['roomId', 'roomName']);
});
it('should combine X-ExtraFields header with extraFields query param (union)', async () => {
await createRoom({ roomName: 'merge-extrafields-test' });
// Both specify 'config' → result should contain config (deduplication)
const response = await getRooms({ extraFields: 'config' }, { xExtraFields: 'config' });
expectSuccessRoomsResponse(response, 1, 10, false, false);
const room = response.body.rooms[0];
expect(room.config).toBeDefined();
});
it('should combine query params and headers for both fields and extraFields', async () => {
await createRoom({ roomName: 'full-merge-test' });
// Query: fields=roomId, Header: X-Fields=roomName, X-ExtraFields=config
const response = await getRooms({ fields: 'roomId' }, { xFields: 'roomName', xExtraFields: 'config' });
expectSuccessRoomsResponse(response, 1, 10, false, false);
const room = response.body.rooms[0];
expectValidRoomWithFields(room, ['roomId', 'roomName', 'config']);
});
it('should work with only headers and no query params', async () => {
await createRoom({ roomName: 'only-headers-test' });
const response = await getRooms({}, { xFields: 'roomId,status', xExtraFields: 'config' });
expectSuccessRoomsResponse(response, 1, 10, false, false);
const room = response.body.rooms[0];
expectValidRoomWithFields(room, ['roomId', 'status', 'config']);
});
it('should ignore invalid header values gracefully and fallback to query params', async () => {
await createRoom({ roomName: 'invalid-header-test' });
// Invalid header values should be silently ignored
const response = await getRooms({ fields: 'roomId,roomName' }, { xFields: '' });
expectSuccessRoomsResponse(response, 1, 10, false, false);
const room = response.body.rooms[0];
expectValidRoomWithFields(room, ['roomId', 'roomName']);
});
});
describe('GET /rooms/:roomId - X-Fields and X-ExtraFields headers', () => {
it('should filter fields using X-Fields header', async () => {
const createdRoom = await createRoom({ roomName: 'get-room-header-test' });
const response = await getRoom(createdRoom.roomId, undefined, undefined, undefined, {
xFields: 'roomId,roomName'
});
expect(response.status).toBe(200);
expectValidRoomWithFields(response.body, ['roomId', 'roomName']);
});
it('should include extra fields using X-ExtraFields header', async () => {
const customConfig = {
recording: {
enabled: true,
layout: MeetRecordingLayout.GRID,
encoding: MeetRecordingEncodingPreset.H264_720P_30
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: false },
captions: { enabled: true }
};
const createdRoom = await createRoom({
roomName: 'get-room-extrafields-header',
config: customConfig
});
const response = await getRoom(createdRoom.roomId, undefined, undefined, undefined, {
xExtraFields: 'config'
});
expect(response.status).toBe(200);
expect(response.body.config).toBeDefined();
expect(response.body.config).toMatchObject(customConfig);
});
it('should combine X-Fields header with fields query param', async () => {
const createdRoom = await createRoom({ roomName: 'get-room-merge-test' });
// Query param: fields=roomId, Header: X-Fields=status
const response = await getRoom(createdRoom.roomId, 'roomId', undefined, undefined, {
xFields: 'status'
});
expect(response.status).toBe(200);
expectValidRoomWithFields(response.body, ['roomId', 'status']);
});
it('should combine X-ExtraFields header with extraFields query param', async () => {
const createdRoom = await createRoom({ roomName: 'get-room-merge-extra' });
// Both specify 'config' via different mechanisms
const response = await getRoom(createdRoom.roomId, undefined, 'config', undefined, {
xExtraFields: 'config'
});
expect(response.status).toBe(200);
expect(response.body.config).toBeDefined();
});
it('should work with both X-Fields and X-ExtraFields headers together', async () => {
const createdRoom = await createRoom({ roomName: 'get-room-both-headers' });
const response = await getRoom(createdRoom.roomId, undefined, undefined, undefined, {
xFields: 'roomId,roomName',
xExtraFields: 'config'
});
expect(response.status).toBe(200);
expectValidRoomWithFields(response.body, ['roomId', 'roomName', 'config']);
});
});
describe('DELETE /rooms/:roomId - X-Fields and X-ExtraFields headers', () => {
it('should filter room fields in response using X-Fields header when room is returned', async () => {
const { room } = await setupSingleRoom(true);
const response = await deleteRoom(
room.roomId,
{ withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS },
{ xFields: 'roomId,status' }
);
expect(response.status).toBe(202);
expect(response.body).toHaveProperty(
'successCode',
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED
);
expect(response.body.room).toBeDefined();
expectValidRoomWithFields(response.body.room, ['roomId', 'status']);
});
it('should include extra fields in response using X-ExtraFields header', async () => {
const { room } = await setupSingleRoom(true);
const response = await deleteRoom(
room.roomId,
{ withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS },
{ xExtraFields: 'config' }
);
expect(response.status).toBe(202);
expect(response.body.room).toBeDefined();
expect(response.body.room.config).toBeDefined();
});
it('should not affect response when room is not returned (direct deletion)', async () => {
const { roomId } = await createRoom();
// Direct deletion (no meeting, no recordings) → no room in response
const response = await deleteRoom(roomId, {}, { xFields: 'roomId' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('successCode', MeetRoomDeletionSuccessCode.ROOM_DELETED);
expect(response.body).not.toHaveProperty('room');
});
});
describe('DELETE /rooms (bulk) - X-Fields and X-ExtraFields headers', () => {
it('should filter room fields in bulk delete response using X-Fields header', async () => {
const { room } = await setupSingleRoom(true);
const response = await bulkDeleteRooms(
[room.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
undefined,
undefined,
undefined,
{ xFields: 'roomId,status' }
);
expect(response.status).toBe(200);
const successItem = response.body.successful.find((s: { room?: MeetRoom }) => s.room !== undefined);
expect(successItem).toBeDefined();
expectValidRoomWithFields(successItem.room, ['roomId', 'status']);
});
it('should include extra fields in bulk delete response using X-ExtraFields header', async () => {
const { room } = await setupSingleRoom(true);
const response = await bulkDeleteRooms(
[room.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
undefined,
undefined,
undefined,
{ xExtraFields: 'config' }
);
expect(response.status).toBe(200);
const successItem = response.body.successful.find((s: { room?: MeetRoom }) => s.room !== undefined);
expect(successItem).toBeDefined();
expect(successItem.room.config).toBeDefined();
});
it('should combine headers and query params in bulk delete', async () => {
const [room1, { room: room2 }] = await Promise.all([createRoom(), setupSingleRoom(true)]);
const response = await bulkDeleteRooms(
[room1.roomId, room2.roomId],
MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
undefined,
undefined,
undefined,
{ xFields: 'roomId,roomName', xExtraFields: 'config' }
);
// room1 gets deleted (no room in response), room2 gets scheduled (room in response)
const successWithRoom = response.body.successful.find((s: { room?: MeetRoom }) => s.room !== undefined);
if (successWithRoom) {
expectValidRoomWithFields(successWithRoom.room, ['roomId', 'roomName', 'config']);
}
});
it('should not affect items without room in response', async () => {
const { roomId } = await createRoom();
const response = await bulkDeleteRooms([roomId], undefined, undefined, undefined, undefined, {
xFields: 'roomId'
});
expect(response.status).toBe(200);
expect(response.body.successful[0]).toHaveProperty('successCode', MeetRoomDeletionSuccessCode.ROOM_DELETED);
expect(response.body.successful[0]).not.toHaveProperty('room');
});
});
describe('POST /rooms - X-Fields and X-ExtraFields headers (existing behavior)', () => {
it('should continue to support X-ExtraFields header on create', async () => {
const room = await createRoom({ roomName: 'post-header-test' }, undefined, { xExtraFields: 'config' });
expect(room.config).toBeDefined();
});
it('should continue to support X-Fields header on create', async () => {
const room = await createRoom({ roomName: 'post-xfields-test' }, undefined, { xFields: 'roomId,roomName' });
expectValidRoomWithFields(room, ['roomId', 'roomName']);
});
it('should support both X-Fields and X-ExtraFields on create', async () => {
const room = await createRoom({ roomName: 'post-both-test' }, undefined, {
xFields: 'roomId',
xExtraFields: 'config'
});
expectValidRoomWithFields(room, ['roomId', 'config']);
});
});
});