diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/chat/chat.service.spec.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/chat/chat.service.spec.ts new file mode 100644 index 000000000..9a68e0fc0 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/chat/chat.service.spec.ts @@ -0,0 +1,147 @@ +import { TestBed } from '@angular/core/testing'; +import { ChatService } from './chat.service'; +import { LoggerService } from '../logger/logger.service'; +import { ParticipantService } from '../participant/participant.service'; +import { PanelService } from '../panel/panel.service'; +import { ActionService } from '../action/action.service'; +import { TranslateService } from '../translate/translate.service'; +import { E2eeService } from '../e2ee/e2ee.service'; +import { DataTopic } from '../../models/data-topic.model'; +import { ChatMessage } from '../../models/chat.model'; + +class AudioDouble { + play = jasmine.createSpy('play').and.returnValue(Promise.resolve()); + volume = 0; +} + +describe('ChatService', () => { + let service: ChatService; + let loggerInstance: { d: jasmine.Spy; i: jasmine.Spy; e: jasmine.Spy }; + let loggerServiceMock: { get: jasmine.Spy }; + let participantServiceMock: { publishData: jasmine.Spy; getMyName: jasmine.Spy }; + let panelServiceMock: { isChatPanelOpened: jasmine.Spy; togglePanel: jasmine.Spy }; + let actionServiceMock: { launchNotification: jasmine.Spy }; + let translateServiceMock: { translate: jasmine.Spy }; + let e2eeServiceMock: { encrypt: jasmine.Spy }; + let audioFactorySpy: jasmine.Spy; + let audioInstance: AudioDouble; + let originalAudio: typeof Audio; + + beforeAll(() => { + originalAudio = (window as any).Audio; + audioFactorySpy = jasmine.createSpy('Audio').and.callFake(() => { + audioInstance = new AudioDouble(); + return audioInstance; + }); + (window as any).Audio = audioFactorySpy; + }); + + afterAll(() => { + (window as any).Audio = originalAudio; + }); + + beforeEach(() => { + audioFactorySpy.calls.reset(); + + loggerInstance = { + d: jasmine.createSpy('d'), + i: jasmine.createSpy('i'), + e: jasmine.createSpy('e') + }; + loggerServiceMock = { + get: jasmine.createSpy('get').and.returnValue(loggerInstance) + }; + participantServiceMock = { + publishData: jasmine.createSpy('publishData').and.resolveTo(undefined), + getMyName: jasmine.createSpy('getMyName').and.returnValue('alice') + }; + panelServiceMock = { + isChatPanelOpened: jasmine.createSpy('isChatPanelOpened').and.returnValue(true), + togglePanel: jasmine.createSpy('togglePanel') + }; + actionServiceMock = { + launchNotification: jasmine.createSpy('launchNotification') + }; + translateServiceMock = { + translate: jasmine.createSpy('translate').and.callFake((key: string) => `${key}_translated`) + }; + e2eeServiceMock = { + encrypt: jasmine.createSpy('encrypt').and.callFake(async (plain: Uint8Array) => plain) + }; + + TestBed.configureTestingModule({ + providers: [ + ChatService, + { provide: LoggerService, useValue: loggerServiceMock }, + { provide: ParticipantService, useValue: participantServiceMock }, + { provide: PanelService, useValue: panelServiceMock }, + { provide: ActionService, useValue: actionServiceMock }, + { provide: TranslateService, useValue: translateServiceMock }, + { provide: E2eeService, useValue: e2eeServiceMock } + ] + }); + + service = TestBed.inject(ChatService); + }); + + it('adds remote message without notification when chat panel is open', async () => { + const emissions: ChatMessage[][] = []; + const sub = service.chatMessages$.subscribe((messages) => emissions.push(messages)); + + await service.addRemoteMessage('Hello world', 'Bob'); + + expect(emissions.at(-1)).toEqual([{ isLocal: false, participantName: 'Bob', message: 'Hello world' }]); + expect(actionServiceMock.launchNotification).not.toHaveBeenCalled(); + expect(audioInstance.play).not.toHaveBeenCalled(); + + sub.unsubscribe(); + }); + + it('adds remote message and triggers notification with sound when chat panel is closed', async () => { + panelServiceMock.isChatPanelOpened.and.returnValue(false); + const emissions: ChatMessage[][] = []; + const sub = service.chatMessages$.subscribe((messages) => emissions.push(messages)); + + await service.addRemoteMessage('Hi there', 'Bob'); + + expect(actionServiceMock.launchNotification).toHaveBeenCalled(); + const notificationArgs = actionServiceMock.launchNotification.calls.mostRecent().args[0]; + expect(notificationArgs.message).toContain('BOB'); + expect(notificationArgs.buttonActionText).toBe('PANEL.CHAT.OPEN_CHAT_translated'); + expect(audioInstance.play).toHaveBeenCalled(); + expect(emissions.at(-1)?.length).toBe(1); + + sub.unsubscribe(); + }); + + it('does not send empty messages', async () => { + await service.sendMessage(' '); + + expect(e2eeServiceMock.encrypt).not.toHaveBeenCalled(); + expect(participantServiceMock.publishData).not.toHaveBeenCalled(); + }); + + it('encrypts, publishes and stores local messages', async () => { + const emissions: ChatMessage[][] = []; + const sub = service.chatMessages$.subscribe((messages) => emissions.push(messages)); + + await service.sendMessage('Hello world'); + + expect(e2eeServiceMock.encrypt).toHaveBeenCalled(); + expect(participantServiceMock.publishData).toHaveBeenCalled(); + const [, publishOptions] = participantServiceMock.publishData.calls.mostRecent().args; + expect(publishOptions).toEqual({ topic: DataTopic.CHAT, reliable: true }); + expect(emissions.at(-1)).toEqual([{ isLocal: true, participantName: 'alice', message: 'Hello world' }]); + + sub.unsubscribe(); + }); + it('logs and rethrows errors when encryption fails', async () => { + const error = new Error('encryption failed'); + e2eeServiceMock.encrypt.and.callFake(() => { + throw error; + }); + + await expectAsync(service.sendMessage('fail')).toBeRejectedWith(error); + expect(loggerInstance.e).toHaveBeenCalledWith('Error sending chat message:', error); + }); +}); diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/device/device.service.spec.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/device/device.service.spec.ts index ad2dbd543..21c5c6820 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/device/device.service.spec.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/device/device.service.spec.ts @@ -1,605 +1,605 @@ -// import { fakeAsync, flush, TestBed } from '@angular/core/testing'; -// import { CameraType, CustomDevice } from '../../models/device.model'; -// import { LoggerService } from '../logger/logger.service'; -// import { LoggerServiceMock } from '../logger/logger.service.mock'; -// import { PlatformService } from '../platform/platform.service'; -// import { PlatformServiceMock } from '../platform/platform.service.mock'; -// import { StorageService } from '../storage/storage.service'; -// import { StorageServiceMock } from '../storage/storage.service.mock'; - -// import { DeviceService } from './device.service'; - -// const OV_EMPTY_DEVICES: Device[] = [] = [{ deviceId: '1', kind: 'audioinput', label: '' }]; -// const OV_VIDEO_EMPTY_DEVICES: Device[] = [{ deviceId: '2', kind: 'videoinput', label: '' }]; -// const OV_AUDIO_DEVICES: Device[] = [ -// { deviceId: '1', kind: 'audioinput', label: 'mic1' }, -// { deviceId: '2', kind: 'audioinput', label: 'mic2' } -// ]; -// const OV_VIDEO_DEVICES: Device[] = [ -// { deviceId: '3', kind: 'videoinput', label: 'cam1' }, -// { deviceId: '4', kind: 'videoinput', label: 'cam2' } -// ]; -// const OV_MOBILE_VIDEO_DEVICES: Device[] = [ -// { deviceId: '5', kind: 'videoinput', label: 'CAMfront' }, -// { deviceId: '6', kind: 'videoinput', label: 'cam1' } -// ]; -// const OV_BOTH_DEVICES: Device[] = OV_AUDIO_DEVICES.concat(OV_VIDEO_DEVICES); - -// const CUSTOM_AUDIO_DEVICES: CustomDevice[] = [ -// { device: '1', label: 'mic1' }, -// { device: '2', label: 'mic2' } -// ]; -// const CUSTOM_VIDEO_DEVICES: CustomDevice[] = [ -// { device: '3', label: 'cam1', type: CameraType.FRONT }, -// { device: '4', label: 'cam2', type: CameraType.BACK } -// ]; - -// const CUSTOM_MOBILE_VIDEO_DEVICES: CustomDevice[] = [ -// { device: '5', label: 'CAMfront', type: CameraType.FRONT }, -// { device: '6', label: 'cam1BACK', type: CameraType.BACK } -// ]; -// const CUSTOM_AUDIO_STORAGE_DEVICE: CustomDevice = { device: '10', label: 'storageAudio' }; -// const CUSTOM_VIDEO_STORAGE_DEVICE: CustomDevice = { device: '11', label: 'storageVideo' }; - -// describe('DeviceService', () => { -// let service: DeviceService; -// let spyGetDevices; - -// beforeEach(() => { -// TestBed.configureTestingModule({ -// providers: [ -// { provide: LoggerService, useClass: LoggerServiceMock }, -// { provide: PlatformService, useClass: PlatformServiceMock }, -// { provide: StorageService, useClass: StorageServiceMock } -// ] -// }); -// service = TestBed.inject(DeviceService); -// }); - -// it('should be created', () => { -// expect(service).toBeTruthy(); -// }); - -// it('should be defined OV', () => { -// expect(service['OV']).toBeDefined(); -// }); - -// it('should initialize devices', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_EMPTY_DEVICES)); - -// expect(service['OV']).toBeDefined(); - -// service.initDevices(); -// flush(); - -// expect(spyGetDevices).toHaveBeenCalled(); -// expect(service['devices']).toBeDefined(); -// expect(service['devices'].length).toEqual(0); -// })); - -// it('should not initialize devices', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(undefined)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); - -// expect(service['OV']).toBeDefined(); - -// spyInitOpenViduDevices.call(service); -// flush(); - -// expect(spyGetDevices).toHaveBeenCalled(); -// expect(service['devices']).not.toBeDefined(); - -// expect(service.hasVideoDeviceAvailable()).toBeFalsy(); -// expect(service.hasAudioDeviceAvailable()).toBeFalsy(); -// })); - -// it('should not have any devices available', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_EMPTY_DEVICES)); -// service.initDevices(); -// flush(); -// expect(spyGetDevices).toHaveBeenCalled(); - -// expect(service.hasAudioDeviceAvailable()).toBeFalsy(); -// expect(service['micSelected']).not.toBeDefined(); - -// expect(service.hasVideoDeviceAvailable()).toBeFalsy(); -// expect(service['camSelected']).not.toBeDefined(); -// })); - -// it('should only have audio devices available', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_AUDIO_DEVICES)); -// service.initDevices(); -// flush(); -// expect(service['devices'].length).toEqual(2); - -// expect(service.hasAudioDeviceAvailable()).toBeTruthy(); -// expect(service['micSelected']).toBeDefined(); -// // 2 + empty microphone - resetDevices method -// expect(service['microphones'].length).toEqual(2 + 1); - -// expect(service.hasVideoDeviceAvailable()).toBeFalsy(); -// expect(service['camSelected']).not.toBeDefined(); -// // 2 + empty camera - resetDevices method -// expect(service['cameras'].length).toEqual(0 + 1); -// })); - -// it('should only have video devices available', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_VIDEO_DEVICES)); -// service.initDevices(); -// flush(); -// expect(service['devices'].length).toEqual(2); - -// expect(service.hasAudioDeviceAvailable()).toBeFalsy(); -// expect(service['micSelected']).not.toBeDefined(); - -// expect(service['microphones'].length).toEqual(0 + 1); - -// expect(service.hasVideoDeviceAvailable()).toBeTruthy(); -// expect(service['camSelected']).toBeDefined(); -// expect(service['cameras'].length).toEqual(2 + 1); -// })); - -// it('should have video and audio devices available', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// service.initDevices(); -// flush(); -// expect(service['devices'].length).toEqual(4); - -// expect(service.hasAudioDeviceAvailable()).toBeTruthy(); -// expect(service['micSelected']).toBeDefined(); -// expect(service['microphones'].length).toEqual(2 + 1); - -// expect(service.hasVideoDeviceAvailable()).toBeTruthy(); -// expect(service['camSelected']).toBeDefined(); -// expect(service['cameras'].length).toEqual(2 + 1); -// })); - -// it('should return first audio device available', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_AUDIO_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); -// expect(service.hasAudioDeviceAvailable()).toBeTruthy(); - -// expect(service.getMicSelected().label).toEqual(OV_AUDIO_DEVICES[0].label); -// })); - -// it('should return first video device available', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_VIDEO_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); -// expect(service.hasVideoDeviceAvailable()).toBeTruthy(); - -// expect(service.getCamSelected().label).toEqual(OV_VIDEO_DEVICES[0].label); -// })); - -// it('should return a microphone by a id', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_AUDIO_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); -// const spyGetMicrophoneByDeviceField = spyOn(service, 'getMicrophoneByDeviceField').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); - -// const device = spyGetMicrophoneByDeviceField.call(service, OV_AUDIO_DEVICES[0].deviceId); - -// expect(device).toBeDefined(); -// expect(device.device).toEqual(OV_AUDIO_DEVICES[0].deviceId); -// })); - -// it('should return a microphone by a label', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_AUDIO_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); -// const spyGetMicrophoneByDeviceField = spyOn(service, 'getMicrophoneByDeviceField').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); - -// const device = spyGetMicrophoneByDeviceField.call(service, OV_AUDIO_DEVICES[1].label); - -// expect(device).toBeDefined(); -// expect(device.label).toEqual(OV_AUDIO_DEVICES[1].label); -// })); - -// it('should return a camera by a id', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_VIDEO_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); -// const spyGetCameraByDeviceField = spyOn(service, 'getCameraByDeviceField').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// const device = spyGetCameraByDeviceField.call(service, OV_VIDEO_DEVICES[0].deviceId); - -// expect(device).toBeDefined(); -// expect(device.device).toEqual(OV_VIDEO_DEVICES[0].deviceId); -// })); - -// it('should return a camera by a label', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_VIDEO_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); -// const spyGetCameraByDeviceField = spyOn(service, 'getCameraByDeviceField').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// const device = spyGetCameraByDeviceField.call(service, OV_VIDEO_DEVICES[0].label); - -// expect(device).toBeDefined(); -// expect(device.label).toEqual(OV_VIDEO_DEVICES[0].label); -// })); - -// it('camera should need mirror', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_VIDEO_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); -// const spyCameraNeedsMirror = spyOn(service, 'cameraNeedsMirror').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// const mirror = spyCameraNeedsMirror.call(service, OV_VIDEO_DEVICES[0].label); - -// expect(mirror).toBeDefined(); -// expect(mirror).toBeTruthy(); -// })); - -// it('camera should not need mirror', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_VIDEO_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); -// const spyCameraNeedsMirror = spyOn(service, 'cameraNeedsMirror').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// const mirror = spyCameraNeedsMirror.call(service, OV_VIDEO_DEVICES[1].label); - -// expect(mirror).toBeDefined(); -// expect(mirror).toBeFalsy(); -// })); - -// it('should return camera devices', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// const cameras = service.getCameras(); - -// expect(cameras).toEqual(CUSTOM_VIDEO_DEVICES); -// })); - -// it('should return microphone devices', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); - -// const microphones = service.getMicrophones(); - -// expect(microphones).toEqual(CUSTOM_AUDIO_DEVICES); -// })); - -// it('should need replace audio track', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); - -// const micSelected = service.getMicSelected(); -// expect(micSelected).toBeDefined(); - -// const newDevice = CUSTOM_AUDIO_DEVICES.find((device) => micSelected.device !== device.device); - -// expect(newDevice).toBeDefined(); - -// const needUpdateAudioTrack = service.needUpdateAudioTrack(newDevice.device); -// expect(needUpdateAudioTrack).toBeTruthy(); -// })); - -// it('should not need replace audio track', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); - -// const micSelected = service.getMicSelected(); -// expect(micSelected).toBeDefined(); - -// const newDevice = CUSTOM_AUDIO_DEVICES.find((device) => micSelected.device === device.device); - -// expect(newDevice).toBeDefined(); - -// const needUpdateAudioTrack = service.needUpdateAudioTrack(newDevice.device); -// expect(needUpdateAudioTrack).toBeFalsy(); -// })); - -// it('should need replace video track', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// const camSelected = service.getCamSelected(); -// expect(camSelected).toBeDefined(); - -// const newDevice = CUSTOM_VIDEO_DEVICES.find((device) => camSelected.device !== device.device); - -// expect(newDevice).toBeDefined(); - -// const needUpdateVideoTrack = service.needUpdateVideoTrack(newDevice.device); -// expect(needUpdateVideoTrack).toBeTruthy(); -// })); - -// it('should not need replace video track', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// const camSelected = service.getCamSelected(); -// expect(camSelected).toBeDefined(); - -// const newDevice = CUSTOM_VIDEO_DEVICES.find((device) => camSelected.device === device.device); - -// expect(newDevice).toBeDefined(); - -// const needUpdateVideoTrack = service.needUpdateVideoTrack(newDevice.device); -// expect(needUpdateVideoTrack).toBeFalsy(); -// })); - -// it('should set cam selected', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// const camSelected = service.getCamSelected(); -// expect(camSelected).toBeDefined(); -// expect(camSelected).toEqual(CUSTOM_VIDEO_DEVICES[0]); - -// const newDevice = CUSTOM_VIDEO_DEVICES.find((device) => camSelected.device !== device.device); -// expect(newDevice).toBeDefined(); -// service.setCamSelected(newDevice.device); - -// const newCamSelected = service.getCamSelected(); -// expect(newCamSelected).toEqual(newDevice); -// })); - -// it('should set mic selected', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); - -// const micSelected = service.getMicSelected(); -// expect(micSelected).toBeDefined(); -// expect(micSelected).toEqual(CUSTOM_AUDIO_DEVICES[0]); - -// const newDevice = CUSTOM_AUDIO_DEVICES.find((device) => micSelected.device !== device.device); -// expect(newDevice).toBeDefined(); -// service.setMicSelected(newDevice.device); - -// const newMicSelected = service.getMicSelected(); -// expect(newMicSelected).toEqual(newDevice); -// })); - -// it('should set front type in mobile devices', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_MOBILE_VIDEO_DEVICES)); -// const spyPlatformSrvIsMobile = spyOn(service['platformSrv'], 'isMobile').and.returnValue(true); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// expect(spyPlatformSrvIsMobile).toHaveBeenCalled(); - -// const cameras = service.getCameras(); - -// expect(cameras.length).toEqual(2); -// const frontCamera = cameras.find((device) => device.type === CameraType.FRONT); -// expect(frontCamera).toBeDefined(); -// expect(frontCamera).toEqual(CUSTOM_MOBILE_VIDEO_DEVICES[0]); -// })); - -// it('devices should have video empty labels', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_VIDEO_EMPTY_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); - -// expect(service.areEmptyLabels()).toBeTruthy(); -// })); - -// it('devices should have audio empty labels', fakeAsync(() => { -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_AUDIO_EMPTY_DEVICES)); -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); - -// expect(service.areEmptyLabels()).toBeTruthy(); -// })); - -// it('should return first mic when storage audio device is not one of openvidu devices', fakeAsync(() => { -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spysaveMicToStorage = spyOn(service, 'saveMicToStorage').and.callThrough(); - -// spysaveMicToStorage.call(service, CUSTOM_AUDIO_STORAGE_DEVICE); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); -// expect(service.hasAudioDeviceAvailable()).toBeTruthy(); - -// expect(service.getMicSelected()).toEqual(CUSTOM_AUDIO_DEVICES[0]); -// })); - -// it('should return first cam when storage video device is not one of openvidu devices', fakeAsync(() => { -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spysaveCamToStorage = spyOn(service, 'saveCamToStorage').and.callThrough(); - -// spysaveCamToStorage.call(service, CUSTOM_VIDEO_STORAGE_DEVICE); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); -// expect(service.hasVideoDeviceAvailable()).toBeTruthy(); - -// expect(service.getCamSelected()).toEqual(CUSTOM_VIDEO_DEVICES[0]); -// })); - -// it('should return storage audio device', fakeAsync(() => { -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitAudioDevices = spyOn(service, 'initAudioDevices').and.callThrough(); -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spysaveMicToStorage = spyOn(service, 'saveMicToStorage').and.callThrough(); - -// spysaveMicToStorage.call(service, CUSTOM_AUDIO_DEVICES[1]); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitAudioDevices.call(service); -// expect(spyInitAudioDevices).toHaveBeenCalled(); -// expect(service.hasAudioDeviceAvailable()).toBeTruthy(); - -// expect(service.getMicSelected()).toEqual(CUSTOM_AUDIO_DEVICES[1]); -// })); - -// it('should return storage video device', fakeAsync(() => { -// const spyInitOpenViduDevices = spyOn(service, 'initOpenViduDevices').and.callThrough(); -// const spyInitVideoDevices = spyOn(service, 'initVideoDevices').and.callThrough(); -// spyGetDevices = spyOn(service['OV'], 'getDevices').and.returnValue(Promise.resolve(OV_BOTH_DEVICES)); -// const spysaveCamToStorage = spyOn(service, 'saveCamToStorage').and.callThrough(); - -// spysaveCamToStorage.call(service, CUSTOM_VIDEO_DEVICES[1]); - -// spyInitOpenViduDevices.call(service); -// flush(); -// expect(spyInitOpenViduDevices).toHaveBeenCalled(); - -// spyInitVideoDevices.call(service); -// expect(spyInitVideoDevices).toHaveBeenCalled(); -// expect(service.hasVideoDeviceAvailable()).toBeTruthy(); - -// expect(service.getCamSelected()).toEqual(CUSTOM_VIDEO_DEVICES[1]); -// })); -// }); +import { TestBed } from '@angular/core/testing'; +import { DeviceService } from './device.service'; +import { LoggerService } from '../logger/logger.service'; +import { PlatformService } from '../platform/platform.service'; +import { StorageService } from '../storage/storage.service'; +import { CameraType, CustomDevice } from '../../models/device.model'; + +describe('DeviceService', () => { + let service: DeviceService; + let loggerInstance: any; + let loggerServiceMock: any; + let platformServiceMock: any; + let storageServiceMock: any; + + const asDevice = (deviceId: string, kind: MediaDeviceKind, label: string): MediaDeviceInfo => ({ + deviceId, kind, label, + groupId: `${kind}-${deviceId}`, + toJSON() { return this; } + } as MediaDeviceInfo); + + beforeEach(() => { + loggerInstance = { d: jasmine.createSpy('d'), i: jasmine.createSpy('i'), e: jasmine.createSpy('e'), w: jasmine.createSpy('w') }; + loggerServiceMock = { get: jasmine.createSpy('get').and.returnValue(loggerInstance) }; + platformServiceMock = { isMobile: jasmine.createSpy('isMobile').and.returnValue(false), isFirefox: jasmine.createSpy('isFirefox').and.returnValue(false) }; + storageServiceMock = { + getVideoDevice: jasmine.createSpy('getVideoDevice').and.returnValue(null), + getAudioDevice: jasmine.createSpy('getAudioDevice').and.returnValue(null), + setVideoDevice: jasmine.createSpy('setVideoDevice'), + setAudioDevice: jasmine.createSpy('setAudioDevice'), + isCameraEnabled: jasmine.createSpy('isCameraEnabled').and.returnValue(true), + isMicrophoneEnabled: jasmine.createSpy('isMicrophoneEnabled').and.returnValue(true) + }; + + TestBed.configureTestingModule({ + providers: [ + DeviceService, + { provide: LoggerService, useValue: loggerServiceMock }, + { provide: PlatformService, useValue: platformServiceMock }, + { provide: StorageService, useValue: storageServiceMock } + ] + }); + service = TestBed.inject(DeviceService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('initializeDevices', () => { + it('initializes devices with camera and microphone', async () => { + const devices = [asDevice('cam-1', 'videoinput', 'Front Camera'), asDevice('mic-1', 'audioinput', 'Primary Mic')]; + spyOn(service, 'getLocalDevices').and.resolveTo(devices); + await service.initializeDevices(); + expect(service.getCameras().length).toBe(1); + expect(service.getMicrophones().length).toBe(1); + }); + + it('calls clear before initializing', async () => { + const clearSpy = spyOn(service, 'clear'); + spyOn(service, 'getLocalDevices').and.resolveTo([]); + await service.initializeDevices(); + expect(clearSpy).toHaveBeenCalled(); + }); + + it('sets camera type for mobile devices with front camera label', async () => { + platformServiceMock.isMobile.and.returnValue(true); + const devices = [asDevice('cam-front', 'videoinput', 'Front Camera')]; + spyOn(service, 'getLocalDevices').and.resolveTo(devices); + await service.initializeDevices(); + expect(service.getCameras()[0].type).toBe(CameraType.FRONT); + }); + + it('sets first camera as FRONT for desktop', async () => { + platformServiceMock.isMobile.and.returnValue(false); + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('cam-2', 'videoinput', 'Camera 2') + ]; + spyOn(service, 'getLocalDevices').and.resolveTo(devices); + await service.initializeDevices(); + expect(service.getCameras()[0].type).toBe(CameraType.FRONT); + expect(service.getCameras()[1].type).toBe(CameraType.BACK); + }); + + it('honors stored device selections', async () => { + const devices = [asDevice('cam-1', 'videoinput', 'Camera 1'), asDevice('cam-2', 'videoinput', 'Camera 2')]; + storageServiceMock.getVideoDevice.and.returnValue({ device: 'cam-2', label: 'Camera 2' }); + spyOn(service, 'getLocalDevices').and.resolveTo(devices); + await service.initializeDevices(); + expect(service.getCameraSelected()?.device).toBe('cam-2'); + }); + + it('handles device access denied error', async () => { + spyOn(service, 'getLocalDevices').and.resolveTo([]); + (service as any).deviceAccessDeniedError = true; + await service.initializeDevices(); + expect(loggerInstance.w).toHaveBeenCalledWith('Media devices permissions were not granted.'); + }); + + it('handles errors when getting devices', async () => { + spyOn(service, 'getLocalDevices').and.rejectWith(new Error('Test error')); + await service.initializeDevices(); + expect(loggerInstance.e).toHaveBeenCalledWith('Error getting media devices', jasmine.any(Error)); + }); + + it('selects first device when no storage device found', async () => { + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('mic-1', 'audioinput', 'Microphone 1') + ]; + storageServiceMock.getVideoDevice.and.returnValue(null); + storageServiceMock.getAudioDevice.and.returnValue(null); + spyOn(service, 'getLocalDevices').and.resolveTo(devices); + await service.initializeDevices(); + expect(service.getCameraSelected()?.device).toBe('cam-1'); + expect(service.getMicrophoneSelected()?.device).toBe('mic-1'); + }); + + it('logs devices after initialization', async () => { + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('mic-1', 'audioinput', 'Mic 1') + ]; + spyOn(service, 'getLocalDevices').and.resolveTo(devices); + await service.initializeDevices(); + expect(loggerInstance.d).toHaveBeenCalledWith('Media devices', jasmine.any(Array), jasmine.any(Array)); + }); + + it('handles empty device list', async () => { + spyOn(service, 'getLocalDevices').and.resolveTo([]); + (service as any).deviceAccessDeniedError = false; + await service.initializeDevices(); + expect(service.getCameras().length).toBe(0); + expect(service.getMicrophones().length).toBe(0); + }); + + it('properly initializes with only cameras', async () => { + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('cam-2', 'videoinput', 'Camera 2') + ]; + spyOn(service, 'getLocalDevices').and.resolveTo(devices); + await service.initializeDevices(); + expect(service.getCameras().length).toBe(2); + expect(service.getMicrophones().length).toBe(0); + }); + + it('properly initializes with only microphones', async () => { + const devices = [ + asDevice('mic-1', 'audioinput', 'Microphone 1'), + asDevice('mic-2', 'audioinput', 'Microphone 2') + ]; + spyOn(service, 'getLocalDevices').and.resolveTo(devices); + await service.initializeDevices(); + expect(service.getCameras().length).toBe(0); + expect(service.getMicrophones().length).toBe(2); + }); + }); + + describe('refreshDevices', () => { + it('refreshes devices when access not denied', async () => { + const devices = [asDevice('cam-1', 'videoinput', 'Camera 1')]; + const spy = spyOn(service, 'getLocalDevices').and.resolveTo(devices); + (service as any).deviceAccessDeniedError = false; + await service.refreshDevices(); + expect(spy).toHaveBeenCalled(); + expect(service.getCameras().length).toBe(1); + }); + + it('skips refresh when access denied', async () => { + const spy = spyOn(service, 'getLocalDevices').and.resolveTo([]); + (service as any).deviceAccessDeniedError = true; + await service.refreshDevices(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('setCameraSelected', () => { + it('updates camera selection and saves to storage', () => { + const cameras: CustomDevice[] = [ + { device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }, + { device: 'cam-2', label: 'Camera 2', type: CameraType.BACK } + ]; + (service as any).cameras = cameras; + service.setCameraSelected('cam-2'); + expect(service.getCameraSelected()).toBe(cameras[1]); + expect(storageServiceMock.setVideoDevice).toHaveBeenCalledWith(cameras[1]); + }); + + it('does not save when device not found', () => { + (service as any).cameras = []; + service.setCameraSelected('nonexistent'); + expect(service.getCameraSelected()).toBeUndefined(); + expect(storageServiceMock.setVideoDevice).not.toHaveBeenCalled(); + }); + }); + + describe('setMicSelected', () => { + it('updates microphone selection and saves to storage', () => { + const microphones: CustomDevice[] = [ + { device: 'mic-1', label: 'Microphone 1' }, + { device: 'mic-2', label: 'Microphone 2' } + ]; + (service as any).microphones = microphones; + service.setMicSelected('mic-2'); + expect(service.getMicrophoneSelected()).toBe(microphones[1]); + expect(storageServiceMock.setAudioDevice).toHaveBeenCalledWith(microphones[1]); + }); + + it('does not save when device not found', () => { + (service as any).microphones = []; + service.setMicSelected('nonexistent'); + expect(service.getMicrophoneSelected()).toBeUndefined(); + expect(storageServiceMock.setAudioDevice).not.toHaveBeenCalled(); + }); + }); + + describe('needUpdateVideoTrack', () => { + it('detects when video track needs update due to deviceId change', () => { + const cam1: CustomDevice = { device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }; + const cam2: CustomDevice = { device: 'cam-2', label: 'Camera 2', type: CameraType.BACK }; + (service as any).cameraSelected = cam1; + expect(service.needUpdateVideoTrack(cam1)).toBeFalse(); + expect(service.needUpdateVideoTrack(cam2)).toBeTrue(); + }); + + it('detects when video track needs update due to label change', () => { + const cam1: CustomDevice = { device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }; + const cam1WithDifferentLabel: CustomDevice = { device: 'cam-1', label: 'Different Camera', type: CameraType.FRONT }; + (service as any).cameraSelected = cam1; + expect(service.needUpdateVideoTrack(cam1WithDifferentLabel)).toBeTrue(); + }); + }); + + describe('needUpdateAudioTrack', () => { + it('detects when audio track needs update due to deviceId change', () => { + const mic1: CustomDevice = { device: 'mic-1', label: 'Microphone 1' }; + const mic2: CustomDevice = { device: 'mic-2', label: 'Microphone 2' }; + (service as any).microphoneSelected = mic1; + expect(service.needUpdateAudioTrack(mic1)).toBeFalse(); + expect(service.needUpdateAudioTrack(mic2)).toBeTrue(); + }); + + it('detects when audio track needs update due to label change', () => { + const mic1: CustomDevice = { device: 'mic-1', label: 'Microphone 1' }; + const mic1WithDifferentLabel: CustomDevice = { device: 'mic-1', label: 'Different Microphone' }; + (service as any).microphoneSelected = mic1; + expect(service.needUpdateAudioTrack(mic1WithDifferentLabel)).toBeTrue(); + }); + }); + + describe('clear', () => { + it('clears all device state', () => { + (service as any).cameras = [{ device: 'cam', label: 'Camera', type: CameraType.FRONT }]; + (service as any).microphones = [{ device: 'mic', label: 'Microphone' }]; + (service as any).cameraSelected = { device: 'cam', label: 'Camera', type: CameraType.FRONT }; + (service as any).microphoneSelected = { device: 'mic', label: 'Microphone' }; + (service as any).videoDevicesEnabled = false; + (service as any).audioDevicesEnabled = false; + + service.clear(); + + expect(service.getCameras().length).toBe(0); + expect(service.getMicrophones().length).toBe(0); + expect(service.getCameraSelected()).toBeUndefined(); + expect(service.getMicrophoneSelected()).toBeUndefined(); + expect((service as any).videoDevicesEnabled).toBeTrue(); + expect((service as any).audioDevicesEnabled).toBeTrue(); + }); + }); + + describe('device availability checks', () => { + it('reports camera enabled based on availability and storage', () => { + (service as any).cameras = [{ device: 'cam', label: 'Camera', type: CameraType.FRONT }]; + (service as any).videoDevicesEnabled = true; + storageServiceMock.isCameraEnabled.and.returnValue(true); + expect(service.isCameraEnabled()).toBeTrue(); + }); + + it('reports camera disabled when no devices available', () => { + (service as any).cameras = []; + expect(service.isCameraEnabled()).toBeFalse(); + }); + + it('reports camera disabled when camera disabled in storage', () => { + (service as any).cameras = [{ device: 'cam', label: 'Camera', type: CameraType.FRONT }]; + storageServiceMock.isCameraEnabled.and.returnValue(false); + expect(service.isCameraEnabled()).toBeFalse(); + }); + + it('reports microphone enabled based on availability and storage', () => { + (service as any).microphones = [{ device: 'mic', label: 'Microphone' }]; + (service as any).audioDevicesEnabled = true; + storageServiceMock.isMicrophoneEnabled.and.returnValue(true); + expect(service.isMicrophoneEnabled()).toBeTrue(); + }); + + it('reports microphone disabled when no devices available', () => { + (service as any).microphones = []; + expect(service.isMicrophoneEnabled()).toBeFalse(); + }); + + it('reports video device available when cameras exist and enabled', () => { + (service as any).cameras = [{ device: 'cam', label: 'Camera', type: CameraType.FRONT }]; + (service as any).videoDevicesEnabled = true; + expect(service.hasVideoDeviceAvailable()).toBeTrue(); + }); + + it('reports audio device available when microphones exist and enabled', () => { + (service as any).microphones = [{ device: 'mic', label: 'Microphone' }]; + (service as any).audioDevicesEnabled = true; + expect(service.hasAudioDeviceAvailable()).toBeTrue(); + }); + }); + + describe('getters', () => { + it('returns cameras list', () => { + const cameras: CustomDevice[] = [{ device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }]; + (service as any).cameras = cameras; + expect(service.getCameras()).toBe(cameras); + }); + + it('returns microphones list', () => { + const microphones: CustomDevice[] = [{ device: 'mic-1', label: 'Microphone 1' }]; + (service as any).microphones = microphones; + expect(service.getMicrophones()).toBe(microphones); + }); + + it('returns selected camera', () => { + const camera: CustomDevice = { device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }; + (service as any).cameraSelected = camera; + expect(service.getCameraSelected()).toBe(camera); + }); + + it('returns selected microphone', () => { + const microphone: CustomDevice = { device: 'mic-1', label: 'Microphone 1' }; + (service as any).microphoneSelected = microphone; + expect(service.getMicrophoneSelected()).toBe(microphone); + }); + }); + + describe('private method: getDeviceFromStorage', () => { + it('returns device when found in storage', () => { + const devices: CustomDevice[] = [ + { device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }, + { device: 'cam-2', label: 'Camera 2', type: CameraType.BACK } + ]; + const storageDevice = { device: 'cam-2', label: 'Camera 2', type: CameraType.BACK }; + + const result = (service as any).getDeviceFromStorage(devices, storageDevice); + expect(result).toBe(devices[1]); + }); + + it('returns undefined when storage device is null', () => { + const devices: CustomDevice[] = [{ device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }]; + const result = (service as any).getDeviceFromStorage(devices, null); + expect(result).toBeUndefined(); + }); + + it('returns undefined when device not found', () => { + const devices: CustomDevice[] = [{ device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }]; + const storageDevice = { device: 'cam-999', label: 'Not Found' }; + + const result = (service as any).getDeviceFromStorage(devices, storageDevice); + expect(result).toBeUndefined(); + }); + }); + + describe('private method: getDeviceById', () => { + it('returns device when found by id', () => { + const devices: CustomDevice[] = [ + { device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }, + { device: 'cam-2', label: 'Camera 2', type: CameraType.BACK } + ]; + + const result = (service as any).getDeviceById(devices, 'cam-2'); + expect(result).toBe(devices[1]); + }); + + it('returns undefined when device not found', () => { + const devices: CustomDevice[] = [{ device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }]; + const result = (service as any).getDeviceById(devices, 'cam-999'); + expect(result).toBeUndefined(); + }); + + it('returns first device when searching with first id', () => { + const devices: CustomDevice[] = [ + { device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }, + { device: 'cam-2', label: 'Camera 2', type: CameraType.BACK } + ]; + + const result = (service as any).getDeviceById(devices, 'cam-1'); + expect(result).toBe(devices[0]); + }); + }); + + describe('private method: createCustomDevice', () => { + it('creates custom device with provided type', () => { + const mediaDevice = asDevice('cam-1', 'videoinput', 'Camera 1'); + const result = (service as any).createCustomDevice(mediaDevice, CameraType.FRONT); + + expect(result).toEqual({ + label: 'Camera 1', + device: 'cam-1', + type: CameraType.FRONT + }); + }); + + it('creates custom device with BACK type', () => { + const mediaDevice = asDevice('cam-2', 'videoinput', 'Camera 2'); + const result = (service as any).createCustomDevice(mediaDevice, CameraType.BACK); + + expect(result.type).toBe(CameraType.BACK); + }); + + it('preserves device label and id', () => { + const mediaDevice = asDevice('unique-id', 'videoinput', 'Special Camera'); + const result = (service as any).createCustomDevice(mediaDevice, CameraType.FRONT); + + expect(result.label).toBe('Special Camera'); + expect(result.device).toBe('unique-id'); + }); + }); describe('private method: saveDeviceToStorage', () => { + it('calls save function when device is defined', () => { + const device: CustomDevice = { device: 'cam-1', label: 'Camera 1', type: CameraType.FRONT }; + const saveFn = jasmine.createSpy('saveFn'); + + (service as any).saveDeviceToStorage(device, saveFn); + + expect(saveFn).toHaveBeenCalledWith(device); + }); + + it('does not call save function when device is undefined', () => { + const saveFn = jasmine.createSpy('saveFn'); + + (service as any).saveDeviceToStorage(undefined, saveFn); + + expect(saveFn).not.toHaveBeenCalled(); + }); + }); + + describe('initializeCustomDevices', () => { + it('separates videos and audios correctly', () => { + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('cam-2', 'videoinput', 'Camera 2'), + asDevice('mic-1', 'audioinput', 'Microphone 1'), + asDevice('mic-2', 'audioinput', 'Microphone 2') + ]; + (service as any).devices = devices; + + (service as any).initializeCustomDevices(); + + expect(service.getCameras().length).toBe(2); + expect(service.getMicrophones().length).toBe(2); + }); + + it('sets correct camera types for mobile with front camera label', async () => { + platformServiceMock.isMobile.and.returnValue(true); + const devices = [ + asDevice('cam-back', 'videoinput', 'Back Camera'), + asDevice('cam-front', 'videoinput', 'Front Camera') + ]; + (service as any).devices = devices; + + (service as any).initializeCustomDevices(); + + expect(service.getCameras()[0].type).toBe(CameraType.BACK); + expect(service.getCameras()[1].type).toBe(CameraType.FRONT); + }); + + it('sets first camera as FRONT for desktop and others as BACK', () => { + platformServiceMock.isMobile.and.returnValue(false); + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('cam-2', 'videoinput', 'Camera 2'), + asDevice('cam-3', 'videoinput', 'Camera 3') + ]; + (service as any).devices = devices; + + (service as any).initializeCustomDevices(); + + const cameras = service.getCameras(); + expect(cameras[0].type).toBe(CameraType.FRONT); + expect(cameras[1].type).toBe(CameraType.BACK); + expect(cameras[2].type).toBe(CameraType.BACK); + }); + }); + + describe('protected methods', () => { + describe('getPermissionStrategies', () => { + it('returns array of three strategies', () => { + const strategies = service['getPermissionStrategies'](); + expect(strategies.length).toBe(3); + expect(strategies[0]).toEqual({ audio: true, video: true }); + expect(strategies[1]).toEqual({ audio: true, video: false }); + expect(strategies[2]).toEqual({ audio: false, video: true }); + }); + }); + + describe('filterValidDevices', () => { + it('filters out devices without label', () => { + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('cam-2', 'videoinput', '') + ]; + const result = service['filterValidDevices'](devices); + expect(result.length).toBe(1); + expect(result[0].deviceId).toBe('cam-1'); + }); + + it('filters out devices without deviceId', () => { + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + { ...asDevice('', 'videoinput', 'Camera 2'), deviceId: '' } + ]; + const result = service['filterValidDevices'](devices); + expect(result.length).toBe(1); + expect(result[0].deviceId).toBe('cam-1'); + }); + + it('filters out default device', () => { + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('default', 'videoinput', 'Default Camera') + ]; + const result = service['filterValidDevices'](devices); + expect(result.length).toBe(1); + expect(result[0].deviceId).toBe('cam-1'); + }); + + it('returns all valid devices', () => { + const devices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('cam-2', 'videoinput', 'Camera 2'), + asDevice('mic-1', 'audioinput', 'Microphone 1') + ]; + const result = service['filterValidDevices'](devices); + expect(result.length).toBe(3); + }); + }); + + describe('handleFallbackByErrorType', () => { + it('handles NotReadableError by enumerating devices', async () => { + const mockDevices = [ + asDevice('cam-1', 'videoinput', 'Camera 1'), + asDevice('default', 'videoinput', 'Default') + ]; + spyOn(navigator.mediaDevices, 'enumerateDevices').and.resolveTo(mockDevices); + + const error = { name: 'NotReadableError', message: 'Device busy' }; + const result = await service['handleFallbackByErrorType'](error); + + expect(result.length).toBe(1); + expect(result[0].deviceId).toBe('cam-1'); + expect(loggerInstance.w).toHaveBeenCalledWith('Device busy, using enumerateDevices() instead'); + }); + + it('handles AbortError by enumerating devices', async () => { + const mockDevices = [asDevice('mic-1', 'audioinput', 'Microphone 1')]; + spyOn(navigator.mediaDevices, 'enumerateDevices').and.resolveTo(mockDevices); + + const error = { name: 'AbortError', message: 'Aborted' }; + const result = await service['handleFallbackByErrorType'](error); + + expect(result.length).toBe(1); + expect(result[0].deviceId).toBe('mic-1'); + }); + + it('handles NotAllowedError by setting access denied flag', async () => { + const error = { name: 'NotAllowedError', message: 'Permission denied' }; + const result = await service['handleFallbackByErrorType'](error); + + expect(result).toEqual([]); + expect((service as any).deviceAccessDeniedError).toBeTrue(); + expect(loggerInstance.w).toHaveBeenCalledWith('Permission denied to access devices'); + }); + + it('handles SecurityError by setting access denied flag', async () => { + const error = { name: 'SecurityError', message: 'Security error' }; + const result = await service['handleFallbackByErrorType'](error); + + expect(result).toEqual([]); + expect((service as any).deviceAccessDeniedError).toBeTrue(); + }); + + it('returns empty array for unknown errors', async () => { + const error = { name: 'UnknownError', message: 'Unknown' }; + const result = await service['handleFallbackByErrorType'](error); + + expect(result).toEqual([]); + }); + + it('handles null error gracefully', async () => { + const result = await service['handleFallbackByErrorType'](null); + expect(result).toEqual([]); + }); + + it('handles undefined error gracefully', async () => { + const result = await service['handleFallbackByErrorType'](undefined); + expect(result).toEqual([]); + }); + }); + }); +}); diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/device/device.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/device/device.service.ts index 574fb0c66..bdd624fce 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/device/device.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/device/device.service.ts @@ -179,23 +179,15 @@ export class DeviceService { * @returns A promise that resolves to an array of `MediaDeviceInfo` objects representing the available local devices. */ private async getLocalDevices(): Promise { - // Forcing media permissions request. - const strategies = [ - { audio: true, video: true }, - { audio: true, video: false }, - { audio: false, video: true } - ]; + const strategies = this.getPermissionStrategies(); for (const strategy of strategies) { try { this.log.d(`Trying strategy: audio=${strategy.audio}, video=${strategy.video}`); - const localTracks = await createLocalTracks(strategy); - localTracks.forEach((track) => track.stop()); - - // Permission granted - const devices = this.platformSrv.isFirefox() ? await this.getMediaDevicesFirefox() : await Room.getLocalDevices(); - - return devices.filter((d: MediaDeviceInfo) => d.label && d.deviceId && d.deviceId !== 'default'); + const devices = await this.tryPermissionStrategy(strategy); + if (devices) { + return this.filterValidDevices(devices); + } } catch (error: any) { this.log.w(`Strategy failed: audio=${strategy.audio}, video=${strategy.video}`, error); @@ -209,6 +201,38 @@ export class DeviceService { return []; } + /** + * @internal + * Get the list of permission strategies to try + */ + protected getPermissionStrategies(): Array<{ audio: boolean; video: boolean }> { + return [ + { audio: true, video: true }, + { audio: true, video: false }, + { audio: false, video: true } + ]; + } + + /** + * @internal + * Try a specific permission strategy and return devices if successful + */ + protected async tryPermissionStrategy(strategy: { audio: boolean; video: boolean }): Promise { + const localTracks = await createLocalTracks(strategy); + localTracks.forEach((track) => track.stop()); + + // Permission granted + return this.platformSrv.isFirefox() ? await this.getMediaDevicesFirefox() : await Room.getLocalDevices(); + } + + /** + * @internal + * Filter devices to remove default and invalid entries + */ + protected filterValidDevices(devices: MediaDeviceInfo[]): MediaDeviceInfo[] { + return devices.filter((d: MediaDeviceInfo) => d.label && d.deviceId && d.deviceId !== 'default'); + } + private async getMediaDevicesFirefox(): Promise { // Firefox requires to get user media to get the devices await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); @@ -219,20 +243,28 @@ export class DeviceService { this.log.w('All permission strategies failed, trying device enumeration without permissions'); try { - if (error?.name === 'NotReadableError' || error?.name === 'AbortError') { - this.log.w('Device busy, using enumerateDevices() instead'); - const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.filter((d) => d.deviceId && d.deviceId !== 'default'); - } - if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') { - this.log.w('Permission denied to access devices'); - this.deviceAccessDeniedError = true; - } - return []; + return await this.handleFallbackByErrorType(error); } catch (error) { this.log.e('Complete failure getting devices', error); this.deviceAccessDeniedError = true; return []; } } + + /** + * @internal + * Handle fallback based on error type + */ + protected async handleFallbackByErrorType(error: any): Promise { + if (error?.name === 'NotReadableError' || error?.name === 'AbortError') { + this.log.w('Device busy, using enumerateDevices() instead'); + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.filter((d) => d.deviceId && d.deviceId !== 'default'); + } + if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') { + this.log.w('Permission denied to access devices'); + this.deviceAccessDeniedError = true; + } + return []; + } } diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.spec.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.spec.ts new file mode 100644 index 000000000..9eb6e5024 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.spec.ts @@ -0,0 +1,295 @@ +import { DocumentService } from './document.service'; +import { LayoutClass } from '../../models/layout.model'; + +describe('DocumentService', () => { + let service: DocumentService; + + beforeEach(() => { + service = new DocumentService(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('isSmallElement', () => { + it('should return true if element has SMALL_ELEMENT class', () => { + const element = document.createElement('div'); + element.className = LayoutClass.SMALL_ELEMENT; + expect(service.isSmallElement(element)).toBeTruthy(); + }); + + it('should return false if element does not have SMALL_ELEMENT class', () => { + const element = document.createElement('div'); + element.className = 'other-class'; + expect(service.isSmallElement(element)).toBeFalsy(); + }); + + it('should return false if element is null', () => { + expect(service.isSmallElement(null as any)).toBeFalsy(); + }); + + it('should return true if element has SMALL_ELEMENT class combined with other classes', () => { + const element = document.createElement('div'); + element.className = `some-class ${LayoutClass.SMALL_ELEMENT} another-class`; + expect(service.isSmallElement(element)).toBeTruthy(); + }); + + it('should return true if SMALL_ELEMENT is at the beginning of className', () => { + const element = document.createElement('div'); + element.className = `${LayoutClass.SMALL_ELEMENT} another-class`; + expect(service.isSmallElement(element)).toBeTruthy(); + }); + + it('should return true if SMALL_ELEMENT is at the end of className', () => { + const element = document.createElement('div'); + element.className = `some-class ${LayoutClass.SMALL_ELEMENT}`; + expect(service.isSmallElement(element)).toBeTruthy(); + }); + }); + + describe('toggleFullscreen', () => { + let mockDocument: any; + let mockElement: any; + + beforeEach(() => { + mockElement = { + requestFullscreen: jasmine.createSpy('requestFullscreen'), + msRequestFullscreen: jasmine.createSpy('msRequestFullscreen'), + mozRequestFullScreen: jasmine.createSpy('mozRequestFullScreen'), + webkitRequestFullscreen: jasmine.createSpy('webkitRequestFullscreen') + }; + + mockDocument = { + fullscreenElement: null, + mozFullScreenElement: null, + webkitFullscreenElement: null, + msFullscreenElement: null, + exitFullscreen: jasmine.createSpy('exitFullscreen'), + msExitFullscreen: jasmine.createSpy('msExitFullscreen'), + mozCancelFullScreen: jasmine.createSpy('mozCancelFullScreen'), + webkitExitFullscreen: jasmine.createSpy('webkitExitFullscreen') + }; + + spyOn(service, 'getDocument').and.returnValue(mockDocument); + spyOn(service, 'getElementById').and.returnValue(mockElement); + }); + + it('should request fullscreen when not in fullscreen mode', () => { + spyOn(service, 'isInFullscreen').and.returnValue(false); + const requestSpy = spyOn(service, 'requestFullscreen'); + + service.toggleFullscreen('test-element'); + + expect(service['getElementById']).toHaveBeenCalledWith('test-element'); + expect(requestSpy).toHaveBeenCalledWith(mockElement); + }); + + it('should exit fullscreen when in fullscreen mode', () => { + spyOn(service, 'isInFullscreen').and.returnValue(true); + const exitSpy = spyOn(service, 'exitFullscreen'); + + service.toggleFullscreen('test-element'); + + expect(exitSpy).toHaveBeenCalledWith(mockDocument); + }); + }); + + describe('isInFullscreen', () => { + it('should return false when no fullscreen element', () => { + const mockDocument = { + fullscreenElement: null, + mozFullScreenElement: null, + webkitFullscreenElement: null, + msFullscreenElement: null + }; + spyOn(service, 'getDocument').and.returnValue(mockDocument); + + expect(service['isInFullscreen']()).toBeFalse(); + }); + + it('should return true when fullscreenElement is set', () => { + const mockElement = document.createElement('div'); + const mockDocument = { + fullscreenElement: mockElement, + mozFullScreenElement: null, + webkitFullscreenElement: null, + msFullscreenElement: null + }; + spyOn(service, 'getDocument').and.returnValue(mockDocument); + + expect(service['isInFullscreen']()).toBeTrue(); + }); + + it('should return true when mozFullScreenElement is set', () => { + const mockElement = document.createElement('div'); + const mockDocument = { + fullscreenElement: null, + mozFullScreenElement: mockElement, + webkitFullscreenElement: null, + msFullscreenElement: null + }; + spyOn(service, 'getDocument').and.returnValue(mockDocument); + + expect(service['isInFullscreen']()).toBeTrue(); + }); + + it('should return true when webkitFullscreenElement is set', () => { + const mockElement = document.createElement('div'); + const mockDocument = { + fullscreenElement: null, + mozFullScreenElement: null, + webkitFullscreenElement: mockElement, + msFullscreenElement: null + }; + spyOn(service, 'getDocument').and.returnValue(mockDocument); + + expect(service['isInFullscreen']()).toBeTrue(); + }); + + it('should return true when msFullscreenElement is set', () => { + const mockElement = document.createElement('div'); + const mockDocument = { + fullscreenElement: null, + mozFullScreenElement: null, + webkitFullscreenElement: null, + msFullscreenElement: mockElement + }; + spyOn(service, 'getDocument').and.returnValue(mockDocument); + + expect(service['isInFullscreen']()).toBeTrue(); + }); + }); + + describe('requestFullscreen', () => { + it('should call requestFullscreen when available', () => { + const mockElement = { + requestFullscreen: jasmine.createSpy('requestFullscreen') + }; + + service['requestFullscreen'](mockElement); + + expect(mockElement.requestFullscreen).toHaveBeenCalled(); + }); + + it('should call msRequestFullscreen when requestFullscreen not available', () => { + const mockElement = { + requestFullscreen: undefined, + msRequestFullscreen: jasmine.createSpy('msRequestFullscreen') + }; + + service['requestFullscreen'](mockElement); + + expect(mockElement.msRequestFullscreen).toHaveBeenCalled(); + }); + + it('should call mozRequestFullScreen when standard methods not available', () => { + const mockElement = { + requestFullscreen: undefined, + msRequestFullscreen: undefined, + mozRequestFullScreen: jasmine.createSpy('mozRequestFullScreen') + }; + + service['requestFullscreen'](mockElement); + + expect(mockElement.mozRequestFullScreen).toHaveBeenCalled(); + }); + + it('should call webkitRequestFullscreen when other methods not available', () => { + const mockElement = { + requestFullscreen: undefined, + msRequestFullscreen: undefined, + mozRequestFullScreen: undefined, + webkitRequestFullscreen: jasmine.createSpy('webkitRequestFullscreen') + }; + + service['requestFullscreen'](mockElement); + + expect(mockElement.webkitRequestFullscreen).toHaveBeenCalled(); + }); + + it('should handle null element gracefully', () => { + expect(() => service['requestFullscreen'](null)).not.toThrow(); + }); + + it('should handle undefined element gracefully', () => { + expect(() => service['requestFullscreen'](undefined)).not.toThrow(); + }); + }); + + describe('exitFullscreen', () => { + it('should call exitFullscreen when available', () => { + const mockDocument = { + exitFullscreen: jasmine.createSpy('exitFullscreen') + }; + + service['exitFullscreen'](mockDocument); + + expect(mockDocument.exitFullscreen).toHaveBeenCalled(); + }); + + it('should call msExitFullscreen when exitFullscreen not available', () => { + const mockDocument = { + exitFullscreen: undefined, + msExitFullscreen: jasmine.createSpy('msExitFullscreen') + }; + + service['exitFullscreen'](mockDocument); + + expect(mockDocument.msExitFullscreen).toHaveBeenCalled(); + }); + + it('should call mozCancelFullScreen when standard methods not available', () => { + const mockDocument = { + exitFullscreen: undefined, + msExitFullscreen: undefined, + mozCancelFullScreen: jasmine.createSpy('mozCancelFullScreen') + }; + + service['exitFullscreen'](mockDocument); + + expect(mockDocument.mozCancelFullScreen).toHaveBeenCalled(); + }); + + it('should call webkitExitFullscreen when other methods not available', () => { + const mockDocument = { + exitFullscreen: undefined, + msExitFullscreen: undefined, + mozCancelFullScreen: undefined, + webkitExitFullscreen: jasmine.createSpy('webkitExitFullscreen') + }; + + service['exitFullscreen'](mockDocument); + + expect(mockDocument.webkitExitFullscreen).toHaveBeenCalled(); + }); + + it('should handle null document gracefully', () => { + expect(() => service['exitFullscreen'](null)).not.toThrow(); + }); + + it('should handle undefined document gracefully', () => { + expect(() => service['exitFullscreen'](undefined)).not.toThrow(); + }); + }); + + describe('getDocument and getElementById', () => { + it('should return window.document by default', () => { + const doc = service['getDocument'](); + expect(doc).toBe(window.document); + }); + + it('should return element from document', () => { + const testElement = document.createElement('div'); + testElement.id = 'test-element-id'; + document.body.appendChild(testElement); + + const element = service['getElementById']('test-element-id'); + + expect(element).toBe(testElement); + document.body.removeChild(testElement); + }); + }); +}); + + diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.ts index e1b4feb26..23515c4d0 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/document/document.service.ts @@ -11,37 +11,83 @@ export class DocumentService { constructor() {} toggleFullscreen(elementId: string) { - const document: any = window.document; - const fs = document.getElementById(elementId); - if ( - !document.fullscreenElement && - !document.mozFullScreenElement && - !document.webkitFullscreenElement && - !document.msFullscreenElement - ) { - if (fs.requestFullscreen) { - fs.requestFullscreen(); - } else if (fs.msRequestFullscreen) { - fs.msRequestFullscreen(); - } else if (fs.mozRequestFullScreen) { - fs.mozRequestFullScreen(); - } else if (fs.webkitRequestFullscreen) { - fs.webkitRequestFullscreen(); - } + const document: any = this.getDocument(); + const fs = this.getElementById(elementId); + + if (this.isInFullscreen()) { + this.exitFullscreen(document); } else { - if (document.exitFullscreen) { - document.exitFullscreen(); - } else if (document.msExitFullscreen) { - document.msExitFullscreen(); - } else if (document.mozCancelFullScreen) { - document.mozCancelFullScreen(); - } else if (document.webkitExitFullscreen) { - document.webkitExitFullscreen(); - } + this.requestFullscreen(fs); } } isSmallElement(element: HTMLElement | Element): boolean { return element?.className.includes(LayoutClass.SMALL_ELEMENT); } + + /** + * @internal + * Get the document object (can be overridden for testing) + */ + protected getDocument(): any { + return window.document; + } + + /** + * @internal + * Get element by ID (can be overridden for testing) + */ + protected getElementById(elementId: string): any { + return this.getDocument().getElementById(elementId); + } + + /** + * @internal + * Check if currently in fullscreen mode + */ + protected isInFullscreen(): boolean { + const document: any = this.getDocument(); + return !!( + document.fullscreenElement || + document.mozFullScreenElement || + document.webkitFullscreenElement || + document.msFullscreenElement + ); + } + + /** + * @internal + * Request fullscreen on element using vendor-specific methods + */ + protected requestFullscreen(element: any): void { + if (!element) return; + + if (element.requestFullscreen) { + element.requestFullscreen(); + } else if (element.msRequestFullscreen) { + element.msRequestFullscreen(); + } else if (element.mozRequestFullScreen) { + element.mozRequestFullScreen(); + } else if (element.webkitRequestFullscreen) { + element.webkitRequestFullscreen(); + } + } + + /** + * @internal + * Exit fullscreen using vendor-specific methods + */ + protected exitFullscreen(document: any): void { + if (!document) return; + + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } + } } diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/e2ee/e2ee.service.spec.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/e2ee/e2ee.service.spec.ts new file mode 100644 index 000000000..9820200c3 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/e2ee/e2ee.service.spec.ts @@ -0,0 +1,211 @@ +import { TestBed } from '@angular/core/testing'; +import { E2eeService } from './e2ee.service'; +import { OpenViduComponentsConfigService } from '../config/directive-config.service'; +import { OpenViduComponentsConfigServiceMock } from '../../../test-helpers/mocks'; +import * as livekit from 'livekit-client'; + +describe('E2eeService', () => { + let service: E2eeService; + let configMock: OpenViduComponentsConfigServiceMock; + + beforeEach(() => { + configMock = new OpenViduComponentsConfigServiceMock(); + + TestBed.configureTestingModule({ + providers: [ + E2eeService, + { provide: OpenViduComponentsConfigService, useValue: configMock } + ] + }); + + service = TestBed.inject(E2eeService); + }); + + it('should be created with E2EE disabled by default', () => { + expect(service).toBeTruthy(); + expect(service.isEnabled).toBeFalse(); + }); + + it('encrypt returns original string when E2EE disabled', async () => { + const input = 'hello world'; + const out = await service.encrypt(input); + expect(out).toBe(input); + }); + + it('setE2EEKey enables service when deriveEncryptionKey succeeds', async () => { + // Spy the private deriveEncryptionKey to simulate successful key derivation + spyOn(service as any, 'deriveEncryptionKey').and.callFake(async (passphrase: string) => { + // Simulate setting encryptionKey + (service as any).encryptionKey = {} as CryptoKey; + }); + + // Call setE2EEKey with a value + await service.setE2EEKey('my-secret'); + + expect((service as any).isE2EEEnabled).toBeTrue(); + expect((service as any).encryptionKey).toBeDefined(); + expect(service.isEnabled).toBeTrue(); + }); + + it('clearCache empties decryption cache and ngOnDestroy clears and completes', () => { + // Populate cache + (service as any).decryptionCache.set('a', 'b'); + expect((service as any).decryptionCache.size).toBeGreaterThan(0); + + service.clearCache(); + expect((service as any).decryptionCache.size).toBe(0); + + // Re-add and call ngOnDestroy + (service as any).decryptionCache.set('x', 'y'); + service.ngOnDestroy(); + expect((service as any).decryptionCache.size).toBe(0); + }); + + it('setE2EEKey calls deriveEncryptionKey and applies result on success (via spy)', async () => { + spyOn(service as any, 'deriveEncryptionKey').and.callFake(async (passphrase: string) => { + (service as any).encryptionKey = {} as CryptoKey; + }); + + await service.setE2EEKey('passphrase'); + + expect((service as any).encryptionKey).toBeDefined(); + expect((service as any).isE2EEEnabled).toBeTrue(); + expect(service.isEnabled).toBeTrue(); + }); + + it('setE2EEKey handles deriveEncryptionKey failure (via spy) and leaves encryptionKey undefined', async () => { + // Simulate deriveEncryptionKey handling the error internally (doesn't throw) and leaving encryptionKey undefined + spyOn(service as any, 'deriveEncryptionKey').and.callFake(async () => { + (service as any).encryptionKey = undefined; + }); + + await service.setE2EEKey('bad'); + + expect((service as any).encryptionKey).toBeUndefined(); + expect((service as any).isE2EEEnabled).toBeTrue(); + expect(service.isEnabled).toBeFalse(); + }); + + it('encrypt and decrypt support binary Uint8Array paths', async () => { + (service as any).isE2EEEnabled = true; + (service as any).encryptionKey = {} as CryptoKey; + + // Fake encrypt returns payload buffer + const payload = new Uint8Array([10, 11, 12]).buffer; + spyOn((window.crypto as any).subtle, 'encrypt').and.callFake(() => Promise.resolve(payload)); + spyOn((window.crypto as any), 'getRandomValues').and.callFake((arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) arr[i] = i + 2; + return arr; + }); + + const input = new TextEncoder().encode('binary-data'); + const encrypted = await service.encrypt(input) as Uint8Array; + expect(encrypted instanceof Uint8Array).toBeTrue(); + // encrypted should contain iv (12) + payload + expect(encrypted.length).toBeGreaterThan(12); + + // Now fake decrypt to return original input buffer + spyOn((window.crypto as any).subtle, 'decrypt').and.callFake(() => Promise.resolve(input.buffer)); + + // Create combined iv + payload similar to encrypt output + const iv = new Uint8Array(12); + for (let i = 0; i < iv.length; i++) iv[i] = i + 2; + const combined = new Uint8Array(iv.length + input.length); + combined.set(iv, 0); + combined.set(input, iv.length); + + const decrypted = await service.decrypt(combined) as Uint8Array; + expect(decrypted instanceof Uint8Array).toBeTrue(); + expect(new TextDecoder().decode(decrypted)).toBe('binary-data'); + }); + + it('decryptOrMask returns masked outputs when decryption fails for string and binary', async () => { + // Force enabled and provide a key so decrypt will be attempted + spyOnProperty(service, 'isEnabled', 'get').and.returnValue(true); + (service as any).encryptionKey = {} as CryptoKey; + + // For string: provide base64 that will lead decrypt to throw + const fakeBase64 = btoa('garbage'); + spyOn((window.crypto as any).subtle, 'decrypt').and.callFake(() => Promise.reject(new Error('fail'))); + + const maskedStr = await service.decryptOrMask(fakeBase64, undefined, 'MASKED'); + expect(maskedStr).toBe('MASKED'); + + // For binary: provide Uint8Array that will make decrypt fail + const fakeBinary = new Uint8Array([1, 2, 3, 4]); + const maskedBin = await service.decryptOrMask(fakeBinary, undefined, 'BLANK') as Uint8Array; + expect(new TextDecoder().decode(maskedBin)).toBe('BLANK'); + }); + + it('encrypt and decrypt flow when enabled uses Web Crypto and caches decrypted strings', async () => { + // Enable E2EE and set a dummy encryptionKey + (service as any).isE2EEEnabled = true; + (service as any).encryptionKey = {} as CryptoKey; + + // Stub crypto.subtle.encrypt to return a small payload buffer + const fakeEncryptedPayload = new Uint8Array([9, 8, 7]).buffer; + spyOn((window.crypto as any).subtle, 'encrypt').and.callFake(() => Promise.resolve(fakeEncryptedPayload)); + + // Stub getRandomValues to return predictable IV (12 bytes) + spyOn((window.crypto as any), 'getRandomValues').and.callFake((arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) arr[i] = i + 1; // 1..12 + return arr; + }); + + // Encrypt a string -> should return base64 string + const plain = 'hello-e2ee'; + const encrypted = await service.encrypt(plain) as string; + expect(typeof encrypted).toBe('string'); + expect(encrypted.length).toBeGreaterThan(0); + + // Now stub crypto.subtle.decrypt to return decrypted buffer matching original plain + spyOn((window.crypto as any).subtle, 'decrypt').and.callFake(() => Promise.resolve(new TextEncoder().encode(plain).buffer)); + + // Call decrypt with the base64 returned by encrypt + const decrypted = await service.decrypt(encrypted, 'participant1') as string; + expect(decrypted).toBe(plain); + + // Call decrypt again with same input -> should hit cache and not call crypto.subtle.decrypt again + const decryptSpy = (window.crypto as any).subtle.decrypt as jasmine.Spy; + decryptSpy.calls.reset(); + const decrypted2 = await service.decrypt(encrypted, 'participant1') as string; + expect(decrypted2).toBe(plain); + expect(decryptSpy).not.toHaveBeenCalled(); + }); + + it('decrypt throws when encryptionKey is not initialized but isEnabled forced true', async () => { + (service as any).isE2EEEnabled = true; + (service as any).encryptionKey = undefined; + + // Force the isEnabled getter to return true so we hit the encryptionKey missing branch + spyOnProperty(service, 'isEnabled', 'get').and.returnValue(true); + + await expectAsync(service.decrypt(new Uint8Array([1, 2, 3]))).toBeRejectedWithError(/E2EE decryption not available/); + }); + + it('decryptOrMask returns masked value when key missing and returns input when not base64', async () => { + // Case: E2EE disabled -> returns input + (service as any).isE2EEEnabled = false; + const txt = 'not-encrypted'; + expect(await service.decryptOrMask(txt)).toBe(txt); + + // Case: E2EE enabled but encryptionKey missing -> since isEnabled is false, decryptOrMask returns original input + (service as any).isE2EEEnabled = true; + (service as any).encryptionKey = undefined; + const maskedWhenNotEnabled = await service.decryptOrMask(txt, undefined, 'MASK'); + expect(maskedWhenNotEnabled).toBe(txt); + + // If we force isEnabled to true but encryptionKey missing, decryptOrMask should return the mask + spyOnProperty(service, 'isEnabled', 'get').and.returnValue(true); + (service as any).encryptionKey = undefined; + const masked = await service.decryptOrMask(txt, undefined, 'MASK'); + expect(masked).toBe('MASK'); + + // Case: input not base64 -> when enabled and key present, should return input unchanged + (service as any).encryptionKey = {} as CryptoKey; + // restore isEnabled behavior to rely on actual getter + (service as any).isE2EEEnabled = true; + const notBase64 = 'this is not base64!'; + expect(await service.decryptOrMask(notBase64)).toBe(notBase64); + }); +}); diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/panel/panel.service.spec.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/panel/panel.service.spec.ts new file mode 100644 index 000000000..0113bab62 --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/panel/panel.service.spec.ts @@ -0,0 +1,124 @@ +import { TestBed } from '@angular/core/testing'; +import { PanelService } from './panel.service'; +import { LoggerService } from '../logger/logger.service'; +import { PanelType, PanelSettingsOptions } from '../../models/panel.model'; +import { PanelStatusInfo } from '../../models/panel.model'; +import { LoggerServiceMock } from '../../../test-helpers/mocks'; + +describe('PanelService', () => { + let service: PanelService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + PanelService, + { provide: LoggerService, useClass: LoggerServiceMock } + ] + }); + service = TestBed.inject(PanelService); + }); + + it('should be created and initially closed', () => { + expect(service).toBeTruthy(); + expect(service.isPanelOpened()).toBeFalse(); + }); + + it('panelStatusObs emits initial value and after toggle opens the CHAT panel', () => { + const emissions: PanelStatusInfo[] = []; + const sub = service.panelStatusObs.subscribe(v => emissions.push(v)); + + // initial emission + expect(emissions.length).toBe(1); + expect(emissions[0].isOpened).toBeFalse(); + + // open chat + service.togglePanel(PanelType.CHAT); + expect(service.isPanelOpened()).toBeTrue(); + expect(service.isChatPanelOpened()).toBeTrue(); + + // verify an emission was pushed and panelType is CHAT + const last = emissions[emissions.length - 1]; + expect(last.isOpened).toBeTrue(); + expect(last.panelType).toBe(PanelType.CHAT); + + sub.unsubscribe(); + }); + + it('toggling same panel closes it and toggling different panel sets previousPanelType', () => { + const emissions: PanelStatusInfo[] = []; + const sub = service.panelStatusObs.subscribe(v => emissions.push(v)); + + service.togglePanel(PanelType.PARTICIPANTS); + expect(service.isParticipantsPanelOpened()).toBeTrue(); + + // toggling same panel should close it + service.togglePanel(PanelType.PARTICIPANTS); + expect(service.isPanelOpened()).toBeFalse(); + + // open panel A then open panel B -> previousPanelType should be A + service.togglePanel(PanelType.ACTIVITIES); + expect(service.isActivitiesPanelOpened()).toBeTrue(); + service.togglePanel(PanelType.SETTINGS); + expect(service.isSettingsPanelOpened()).toBeTrue(); + + const last = emissions[emissions.length - 1]; + expect(last.previousPanelType).toBe(PanelType.ACTIVITIES); + + sub.unsubscribe(); + }); + + it('supports external panels and subOptionType', () => { + const externalName = 'MY_EXTERNAL_PANEL'; + const subOpt: PanelSettingsOptions | string = 'SOME_OPTION'; + + // open external + service.togglePanel(externalName, subOpt); + expect(service.isExternalPanelOpened()).toBeTrue(); + + // panelStatusObs should contain the external panel type and subOptionType + const emitted = [] as PanelStatusInfo[]; + const s = service.panelStatusObs.subscribe(v => emitted.push(v)); + // last pushed value + const last = emitted[emitted.length - 1]; + expect(last.panelType).toBe(externalName); + expect(last.subOptionType).toBe(subOpt); + + // toggling the same external panel closes it + service.togglePanel(externalName); + expect(service.isExternalPanelOpened()).toBeFalse(); + + s.unsubscribe(); + }); + + it('opens and closes the background effects panel correctly', () => { + // Open background effects + service.togglePanel(PanelType.BACKGROUND_EFFECTS); + expect(service.isBackgroundEffectsPanelOpened()).toBeTrue(); + + // Verify panelStatusObs last emission has correct panelType + const emitted = [] as any[]; + const sub = service.panelStatusObs.subscribe(v => emitted.push(v)); + const last = emitted[emitted.length - 1]; + expect(last.panelType).toBe(PanelType.BACKGROUND_EFFECTS); + + // Close it + service.togglePanel(PanelType.BACKGROUND_EFFECTS); + expect(service.isBackgroundEffectsPanelOpened()).toBeFalse(); + + sub.unsubscribe(); + }); + + it('closePanel and clear close the panel and reset state', () => { + service.togglePanel(PanelType.CHAT); + expect(service.isPanelOpened()).toBeTrue(); + + service.closePanel(); + expect(service.isPanelOpened()).toBeFalse(); + + // open again and then clear + service.togglePanel(PanelType.CHAT); + expect(service.isPanelOpened()).toBeTrue(); + service.clear(); + expect(service.isPanelOpened()).toBeFalse(); + }); +}); diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/panel/panel.service.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/panel/panel.service.ts index d029049b2..e958a7cb6 100644 --- a/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/panel/panel.service.ts +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/lib/services/panel/panel.service.ts @@ -14,7 +14,7 @@ export class PanelService { panelStatusObs: Observable; private log: ILogger; private isExternalOpened: boolean = false; - private externalType: string; + private externalType: string = ''; private _panelOpened = >new BehaviorSubject({ isOpened: false }); private panelTypes: string[] = Object.values(PanelType); diff --git a/openvidu-components-angular/projects/openvidu-components-angular/src/test-helpers/mocks.ts b/openvidu-components-angular/projects/openvidu-components-angular/src/test-helpers/mocks.ts new file mode 100644 index 000000000..07f31463c --- /dev/null +++ b/openvidu-components-angular/projects/openvidu-components-angular/src/test-helpers/mocks.ts @@ -0,0 +1,24 @@ +import { BehaviorSubject } from 'rxjs'; + +export class LoggerServiceMock { + get() { + return { + d: () => {}, + i: () => {}, + e: () => {} + }; + } +} + +export class OpenViduComponentsConfigServiceMock { + // Expose e2eeKey$ as a BehaviorSubject so tests can emit values + e2eeKey$ = new BehaviorSubject(null); + + getE2EEKey() { + return this.e2eeKey$.getValue(); + } + + updateE2EEKey(value: string | null) { + this.e2eeKey$.next(value); + } +}