/* * Copyright (C) 2021-2022 Kristóf Marussy * * This file is part of Sophie. * * Sophie is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * SPDX-License-Identifier: AGPL-3.0-only */ import { mocked } from 'jest-mock'; import type { IJsonPatch } from 'mobx-state-tree'; import type { Action, SharedStoreSnapshotIn, SophieRenderer } from '@sophie/shared'; import { IpcRendererService } from '../../services/IpcRendererService'; import { createSophieRenderer } from '../SophieRendererImpl'; jest.mock('../../services/IpcRendererService'); const snapshot: SharedStoreSnapshotIn = { shouldUseDarkColors: true, }; const invalidSnapshot = { shouldUseDarkColors: -1, } as unknown as SharedStoreSnapshotIn; const patch: IJsonPatch = { op: 'replace', path: 'foo', value: 'bar', }; const action: Action = { action: 'set-theme-source', themeSource: 'dark', }; const invalidAction = { action: 'not-a-valid-action', } as unknown as Action; describe('constructor', () => { it('registers a shared store patch listener', () => { const service = new IpcRendererService(); createSophieRenderer(service); expect(service.onSharedStorePatch).toHaveBeenCalledTimes(1); }); }); describe('instance', () => { let sut: SophieRenderer; let service: IpcRendererService; let onSharedStorePatch: (patch: unknown) => void; let listener = { onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), onPatch: jest.fn((_patch: IJsonPatch) => {}), }; beforeEach(() => { service = new IpcRendererService(); sut = createSophieRenderer(service); onSharedStorePatch = mocked(service.onSharedStorePatch).mock.calls[0][0]; }); describe('onSharedStoreChange', () => { it('requests a snapshot from the service', async () => { mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); await sut.onSharedStoreChange(listener); expect(service.getSharedStoreSnapshot).toBeCalledTimes(1); }); it('passes the snapshot to the listener', async () => { mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); await sut.onSharedStoreChange(listener); expect(listener.onSnapshot).toBeCalledWith(snapshot); }); it('catches service errors without exposing them', async () => { mocked(service.getSharedStoreSnapshot).mockRejectedValue(new Error('s3cr3t')); await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( 'message', expect.stringMatching(/s3cr3t/), ); expect(listener.onSnapshot).toBeCalledTimes(0); }); it('does not pass on invalid snapshots', async () => { mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(invalidSnapshot); await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); expect(listener.onSnapshot).toBeCalledTimes(0); }); }); describe('dispatchAction', () => { it('dispatched valid actions', () => { sut.dispatchAction(action); expect(service.dispatchAction).toBeCalledWith(action); }); it('does not dispatch invalid actions', () => { expect(() => sut.dispatchAction(invalidAction)).toThrowError(); expect(service.dispatchAction).toBeCalledTimes(0); }); }); describe('when no listener is registered', () => { it('discards the received patch', () => { onSharedStorePatch(patch); }); }); function itRefusesToRegisterAnotherListener() { it('refuses to register another listener', async () => { await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); }); } function itDoesNotPassPatchesToTheListener() { it('does not pass patches to the listener', () => { onSharedStorePatch(patch); expect(listener.onPatch).toBeCalledTimes(0); }); } describe('when a listener registered successfully', () => { beforeEach(async () => { mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); await sut.onSharedStoreChange(listener); }); it('passes patches to the listener', () => { onSharedStorePatch(patch); expect(listener.onPatch).toBeCalledWith(patch); }); it('catches listener errors', () => { mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); onSharedStorePatch(patch); }); itRefusesToRegisterAnotherListener(); describe('that later threw an error', () => { beforeEach(() => { mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); onSharedStorePatch(patch); listener.onPatch.mockRestore(); }); itDoesNotPassPatchesToTheListener(); }); }); describe('when a listener failed to register due to service error', () => { beforeEach(async () => { mocked(service.getSharedStoreSnapshot).mockRejectedValue(new Error()); try { await sut.onSharedStoreChange(listener); } catch { // Ignore error. } }); itRefusesToRegisterAnotherListener(); itDoesNotPassPatchesToTheListener(); }); describe('when a listener failed to register due to an invalid snapshot', () => { beforeEach(async () => { mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(invalidSnapshot); try { await sut.onSharedStoreChange(listener); } catch { // Ignore error. } }); itRefusesToRegisterAnotherListener(); itDoesNotPassPatchesToTheListener(); }); describe('when a listener failed to register due to listener error', () => { beforeEach(async () => { mocked(service.getSharedStoreSnapshot).mockResolvedValueOnce(snapshot); mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); try { await sut.onSharedStoreChange(listener); } catch { // Ignore error. } }); itRefusesToRegisterAnotherListener(); itDoesNotPassPatchesToTheListener(); }); });