openvidu/backend/tests/helpers/assertion-helpers.ts

656 lines
21 KiB
TypeScript

import { expect } from '@jest/globals';
import { container } from '../../src/config/dependency-injector.config';
import INTERNAL_CONFIG from '../../src/config/internal-config';
import { TokenService } from '../../src/services';
import {
MeetingEndAction,
MeetRecordingAccess,
MeetRecordingInfo,
MeetRecordingStatus,
MeetRoom,
MeetRoomAutoDeletionPolicy,
MeetRoomConfig,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomStatus,
ParticipantPermissions,
ParticipantRole
} from '../../src/typings/ce';
export const expectErrorResponse = (
response: any,
status = 422,
error = 'Unprocessable Entity',
message = 'Invalid request',
details?: Array<{ field?: string; message: string }>
) => {
expect(response.status).toBe(status);
expect(response.body).toMatchObject({
...(error ? { error } : {}),
...(message ? { message } : {})
});
if (details === undefined) {
expect(response.body.details).toBeUndefined();
return;
}
expect(Array.isArray(response.body.details)).toBe(true);
expect(response.body.details).toEqual(
expect.arrayContaining(
details.map((d) => {
const matcher: any = { message: expect.stringContaining(d.message) };
if (d.field !== undefined) {
matcher.field = d.field;
}
return expect.objectContaining(matcher);
})
)
);
};
export const expectValidationError = (response: any, field: string, message: string) => {
expectErrorResponse(response, 422, 'Unprocessable Entity', 'Invalid request', [{ field, message }]);
};
/**
* Asserts that a rooms response matches the expected values for testing purposes.
* Validates the room array length and pagination properties.
*
* @param body - The API response body to validate
* @param expectedRoomLength - The expected number of rooms in the response
* @param expectedMaxItems - The expected maximum number of items in pagination
* @param expectedTruncated - The expected value for pagination.isTruncated flag
* @param expectedNextPageToken - The expected presence of pagination.nextPageToken
* (if true, expects nextPageToken to be defined;
* if false, expects nextPageToken to be undefined)
*/
export const expectSuccessRoomsResponse = (
response: any,
expectedRoomLength: number,
expectedMaxItems: number,
expectedTruncated: boolean,
expectedNextPageToken: boolean
) => {
const { body } = response;
expect(response.status).toBe(200);
expect(body).toBeDefined();
expect(body.rooms).toBeDefined();
expect(Array.isArray(body.rooms)).toBe(true);
expect(body.rooms.length).toBe(expectedRoomLength);
expect(body.pagination).toBeDefined();
expect(body.pagination.isTruncated).toBe(expectedTruncated);
expectedNextPageToken
? expect(body.pagination.nextPageToken).toBeDefined()
: expect(body.pagination.nextPageToken).toBeUndefined();
expect(body.pagination.maxItems).toBe(expectedMaxItems);
};
export const expectSuccessRoomResponse = (
response: any,
roomName: string,
autoDeletionDate?: number,
config?: MeetRoomConfig
) => {
expect(response.status).toBe(200);
expectValidRoom(response.body, roomName, config, autoDeletionDate);
};
export const expectSuccessRoomConfigResponse = (response: any, config: MeetRoomConfig) => {
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
expect(response.body).toEqual(config);
};
export const expectValidRoom = (
room: MeetRoom,
name: string,
config?: MeetRoomConfig,
autoDeletionDate?: number,
autoDeletionPolicy?: MeetRoomAutoDeletionPolicy,
status?: MeetRoomStatus,
meetingEndAction?: MeetingEndAction
) => {
expect(room).toBeDefined();
expect(room.roomId).toBeDefined();
expect(room.roomName).toBeDefined();
expect(room.roomName).toBe(name);
expect(room.roomId).not.toBe('');
expect(room.roomId).toContain(room.roomName.replace(/\s+/g, '')); // Ensure roomId contains the name without spaces
expect(room.creationDate).toBeDefined();
if (autoDeletionDate !== undefined) {
expect(room.autoDeletionDate).toBeDefined();
expect(room.autoDeletionDate).toBe(autoDeletionDate);
} else {
expect(room.autoDeletionDate).toBeUndefined();
}
if (autoDeletionPolicy !== undefined) {
expect(room.autoDeletionPolicy).toBeDefined();
expect(room.autoDeletionPolicy).toEqual(autoDeletionPolicy);
} else {
expect(room.autoDeletionPolicy).toEqual({
withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS,
withRecordings: MeetRoomDeletionPolicyWithRecordings.CLOSE
});
}
expect(room.config).toBeDefined();
if (config !== undefined) {
expect(room.config).toEqual(config);
} else {
expect(room.config).toEqual({
recording: {
enabled: true,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true }
});
}
expect(room.moderatorUrl).toBeDefined();
expect(room.speakerUrl).toBeDefined();
expect(room.moderatorUrl).toContain(room.roomId);
expect(room.speakerUrl).toContain(room.roomId);
expect(room.status).toBeDefined();
expect(room.status).toEqual(status || MeetRoomStatus.OPEN);
expect(room.meetingEndAction).toBeDefined();
expect(room.meetingEndAction).toEqual(meetingEndAction || MeetingEndAction.NONE);
};
export const expectValidRecording = (
recording: MeetRecordingInfo,
recordingId: string,
roomId: string,
roomName: string,
status: MeetRecordingStatus
) => {
expect(recording).toBeDefined();
expect(recording.recordingId).toBeDefined();
expect(recording.roomId).toBeDefined();
expect(recording.roomName).toBeDefined();
expect(recording.recordingId).toBe(recordingId);
expect(recording.roomId).toBe(roomId);
expect(recording.roomName).toBe(roomName);
expect(recording.startDate).toBeDefined();
expect(recording.status).toBeDefined();
expect(recording.status).toBe(status);
expect(recording.filename).toBeDefined();
expect(recording.details).toBeDefined();
};
export const expectValidRoomWithFields = (room: MeetRoom, fields: string[] = []) => {
expect(room).toBeDefined();
expectObjectFields(room, fields);
};
export const expectValidRecordingWithFields = (rec: MeetRecordingInfo, fields: string[] = []) => {
expect(rec).toBeDefined();
expectObjectFields(rec, fields);
};
const expectObjectFields = (obj: any, present: string[] = [], absent: string[] = []) => {
present.forEach((key) => {
expect(obj).toHaveProperty(key);
expect((obj as any)[key]).not.toBeUndefined();
});
absent.forEach((key) => {
// Si la propiedad existe, debe ser undefined
expect(Object.prototype.hasOwnProperty.call(obj, key) ? (obj as any)[key] : undefined).toBeUndefined();
});
};
// Validate recording location header in the response
export const expectValidRecordingLocationHeader = (response: any) => {
const locationHeader = response.headers.location;
expect(locationHeader).toBeDefined();
const locationHeaderUrl = new URL(locationHeader);
expect(locationHeaderUrl.pathname).toBe(
`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${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, roomName: string) => {
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('recordingId');
expectValidRecordingLocationHeader(response);
const recordingId = response.body.recordingId;
expect(recordingId).toBeDefined();
expect(recordingId).toContain(roomId);
expect(response.body).toHaveProperty('roomId', roomId);
expect(response.body).toHaveProperty('roomName', roomName);
expect(response.body).toHaveProperty('startDate');
expect(response.body).toHaveProperty('status', 'active');
expect(response.body).toHaveProperty('filename');
expect(response.body).not.toHaveProperty('duration');
expect(response.body).not.toHaveProperty('endDate');
expect(response.body).not.toHaveProperty('size');
};
export const expectValidStopRecordingResponse = (
response: any,
recordingId: string,
roomId: string,
roomName: string
) => {
expect(response.status).toBe(202);
expect(response.body).toBeDefined();
expect(response.body).toHaveProperty('recordingId', recordingId);
expect([MeetRecordingStatus.COMPLETE, MeetRecordingStatus.ENDING]).toContain(response.body.status);
expect(response.body).toHaveProperty('roomId', roomId);
expect(response.body).toHaveProperty('roomName', roomName);
expect(response.body).toHaveProperty('filename');
expect(response.body).toHaveProperty('startDate');
expect(response.body).toHaveProperty('duration', expect.any(Number));
expectValidRecordingLocationHeader(response);
};
export const expectValidGetRecordingResponse = (
response: any,
recordingId: string,
roomId: string,
roomName: string,
status?: MeetRecordingStatus,
maxSecDuration?: number
) => {
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const body = response.body;
expect(body).toMatchObject({ recordingId, roomId, roomName });
const isRecFinished =
status &&
(status === MeetRecordingStatus.COMPLETE ||
status === MeetRecordingStatus.ABORTED ||
status === MeetRecordingStatus.FAILED ||
status === MeetRecordingStatus.LIMIT_REACHED);
expect(body).toEqual(
expect.objectContaining({
recordingId: expect.stringMatching(new RegExp(`^${recordingId}$`)),
roomId: expect.stringMatching(new RegExp(`^${roomId}$`)),
roomName: expect.stringMatching(new RegExp(`^${roomName}$`)),
...(isRecFinished ? { status: expect.any(String) } : {}),
...(isRecFinished ? { duration: expect.any(Number) } : {}),
...(isRecFinished ? { startDate: expect.any(Number) } : {}),
...(isRecFinished ? { endDate: expect.any(Number) } : {}),
...(isRecFinished ? { size: expect.any(Number) } : {}),
filename: expect.any(String),
...(isRecFinished ? { details: expect.any(String) } : {})
})
);
expect(body.status).toBeDefined();
if (status !== undefined) {
expect(body.status).toBe(status);
}
if (isRecFinished) {
expect(body.endDate).toBeGreaterThanOrEqual(body.startDate);
expect(body.duration).toBeGreaterThanOrEqual(0);
}
if (isRecFinished && maxSecDuration) {
expect(body.duration).toBeLessThanOrEqual(maxSecDuration);
const computedSec = (body.endDate - body.startDate) / 1000;
const diffSec = Math.abs(maxSecDuration - computedSec);
// Estimate 5 seconds of tolerace because of time to start/stop recording
expect(diffSec).toBeLessThanOrEqual(5);
}
};
export const expectSuccessListRecordingResponse = (
response: any,
recordingLength: number,
isTruncated: boolean,
nextPageToken: boolean,
maxItems = 10
) => {
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
expect(response.body.recordings).toBeDefined();
expect(Array.isArray(response.body.recordings)).toBe(true);
expect(response.body.recordings.length).toBe(recordingLength);
expect(response.body.pagination).toBeDefined();
expect(response.body.pagination.isTruncated).toBe(isTruncated);
if (nextPageToken) {
expect(response.body.pagination.nextPageToken).toBeDefined();
} else {
expect(response.body.pagination.nextPageToken).toBeUndefined();
}
expect(response.body.pagination.maxItems).toBeDefined();
expect(response.body.pagination.maxItems).toBeGreaterThan(0);
expect(response.body.pagination.maxItems).toBeLessThanOrEqual(100);
expect(response.body.pagination.maxItems).toBe(maxItems);
};
export const expectValidGetRecordingUrlResponse = (response: any, recordingId: string) => {
expect(response.status).toBe(200);
const recordingUrl = response.body.url;
expect(recordingUrl).toBeDefined();
const parsedUrl = new URL(recordingUrl);
expect(parsedUrl.pathname).toBe(`/recording/${recordingId}`);
expect(parsedUrl.searchParams.get('secret')).toBeDefined();
};
export const expectValidRoomRolesAndPermissionsResponse = (response: any, roomId: string) => {
expect(response.status).toBe(200);
expect(response.body).toEqual(
expect.arrayContaining([
{
role: ParticipantRole.MODERATOR,
permissions: getPermissions(roomId, ParticipantRole.MODERATOR)
},
{
role: ParticipantRole.SPEAKER,
permissions: getPermissions(roomId, ParticipantRole.SPEAKER)
}
])
);
};
export const expectValidRoomRoleAndPermissionsResponse = (
response: any,
roomId: string,
participantRole: ParticipantRole
) => {
expect(response.status).toBe(200);
expect(response.body).toEqual({
role: participantRole,
permissions: getPermissions(roomId, participantRole)
});
};
export const getPermissions = (
roomId: string,
role: ParticipantRole,
addJoinPermission = true
): ParticipantPermissions => {
switch (role) {
case ParticipantRole.MODERATOR:
return {
livekit: {
roomJoin: addJoinPermission,
room: roomId,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true
},
openvidu: {
canRecord: true,
canChat: true,
canChangeVirtualBackground: true
}
};
case ParticipantRole.SPEAKER:
return {
livekit: {
roomJoin: addJoinPermission,
room: roomId,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true
},
openvidu: {
canRecord: false,
canChat: true,
canChangeVirtualBackground: true
}
};
default:
throw new Error(`Unknown role ${role}`);
}
};
export const expectValidParticipantTokenResponse = (
response: any,
roomId: string,
participantRole: ParticipantRole,
participantName?: string,
participantIdentity?: string,
otherRoles: ParticipantRole[] = []
) => {
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('token');
const token = response.body.token;
const decodedToken = decodeJWTToken(token);
const permissions = getPermissions(roomId, participantRole, !!participantName);
const rolesAndPermissions = otherRoles.map((role) => ({
role,
permissions: getPermissions(roomId, role, !!participantName).openvidu
}));
if (!rolesAndPermissions.some((r) => r.role === participantRole)) {
rolesAndPermissions.push({
role: participantRole,
permissions: permissions.openvidu
});
}
if (participantName) {
expect(decodedToken).toHaveProperty('name', participantName);
expect(decodedToken).toHaveProperty('sub');
if (participantIdentity) {
expect(decodedToken.sub).toBe(participantIdentity);
} else {
expect(decodedToken.sub).toContain(participantName.replace(/\s+/g, '')); // Ensure sub contains the name without spaces
}
} else {
expect(decodedToken).not.toHaveProperty('name');
expect(decodedToken).not.toHaveProperty('sub');
}
expect(decodedToken).toHaveProperty('video', permissions.livekit);
expect(decodedToken).toHaveProperty('metadata');
const metadata = JSON.parse(decodedToken.metadata || '{}');
expect(metadata).toHaveProperty('roles');
expect(metadata.roles).toEqual(expect.arrayContaining(rolesAndPermissions));
expect(metadata).toHaveProperty('selectedRole', participantRole);
// Check that the token is included in a cookie
expect(response.headers['set-cookie']).toBeDefined();
const cookies = response.headers['set-cookie'] as unknown as string[];
const participantTokenCookie = cookies.find((cookie) =>
cookie.startsWith(`${INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME}=`)
) as string;
expect(participantTokenCookie).toBeDefined();
expect(participantTokenCookie).toContain(token);
expect(participantTokenCookie).toContain('HttpOnly');
expect(participantTokenCookie).toContain('SameSite=Strict');
expect(participantTokenCookie).toContain('Path=/');
};
export const expectValidRecordingTokenResponse = (
response: any,
roomId: string,
participantRole: ParticipantRole,
canRetrieveRecordings: boolean,
canDeleteRecordings: boolean
) => {
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('token');
const token = response.body.token;
const decodedToken = decodeJWTToken(token);
expect(decodedToken).toHaveProperty('video', {
room: roomId
});
expect(decodedToken).toHaveProperty('metadata');
const metadata = JSON.parse(decodedToken.metadata || '{}');
expect(metadata).toHaveProperty('role', participantRole);
expect(metadata).toHaveProperty('recordingPermissions', {
canRetrieveRecordings,
canDeleteRecordings
});
// Check that the token is included in a cookie
expect(response.headers['set-cookie']).toBeDefined();
const cookies = response.headers['set-cookie'] as unknown as string[];
const participantTokenCookie = cookies.find((cookie) =>
cookie.startsWith(`${INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME}=`)
) as string;
expect(participantTokenCookie).toBeDefined();
expect(participantTokenCookie).toContain(token);
expect(participantTokenCookie).toContain('HttpOnly');
expect(participantTokenCookie).toContain('SameSite=Strict');
expect(participantTokenCookie).toContain('Path=/');
};
const decodeJWTToken = (token: string) => {
const tokenService = container.get(TokenService);
return tokenService.getClaimsIgnoringExpiration(token);
};