diff --git a/src/app/core/testing/classes/mock-global-store.service.ts b/src/app/core/testing/classes/mock-global-store.service.ts new file mode 100644 index 00000000000..d97646aeb9a --- /dev/null +++ b/src/app/core/testing/classes/mock-global-store.service.ts @@ -0,0 +1,79 @@ +import { + Injectable, + Type, + ExistingProvider, + FactoryProvider, + forwardRef, +} from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { MockGlobalStoreResponses } from 'app/core/testing/interfaces/mock-global-store-responses.interface'; +import { + ApiCallAndSubscribeMethod, + ApiCallAndSubscribeResponse, +} from 'app/interfaces/api/api-call-and-subscribe-directory.interface'; +import { + ApiCallMethod, + ApiCallResponse, +} from 'app/interfaces/api/api-call-directory.interface'; +import { ApiEventMethod, ApiEventTyped } from 'app/interfaces/api-message.interface'; +import { + GlobalStoreMembers, +} from 'app/services/global-store/global-store.service'; + +export function mockGlobalStore< + M1 extends ApiCallMethod, + M2 extends ApiEventMethod, + M3 extends ApiCallAndSubscribeMethod, +>( + stores: [ + store: Type>, + mockResponses?: MockGlobalStoreResponses, + ][], +): (FactoryProvider | ExistingProvider)[] { + return stores.map((store) => { + const mockStoreService = new (mockGlobalStoreService(store[1]))(); + return [ + { + provide: store[0], + useFactory: () => mockStoreService, + }, + { + provide: mockStoreService, + useExisting: forwardRef(() => store[0]), + }, + ]; + }).flat(); +} + +function mockGlobalStoreService< + M1 extends ApiCallMethod, + M2 extends ApiEventMethod, + M3 extends ApiCallAndSubscribeMethod, +>( + mockResponses?: MockGlobalStoreResponses, +): Type> { + @Injectable({ providedIn: 'root' }) + class MockGlobalStore implements GlobalStoreMembers { + get call(): Observable> { + return this.getResponse(mockResponses?.call); + } + + get subscribe(): Observable> { + return this.getResponse(mockResponses?.subscribe); + } + + get callAndSubscribe(): Observable[]> { + return this.getResponse(mockResponses?.callAndSubscribe); + } + + invalidate(): void {} + + private getResponse(mockResponse: R): Observable { + if (mockResponse === undefined) { + throw Error('Unmocked global store response'); + } + return of(mockResponse); + } + } + return MockGlobalStore; +} diff --git a/src/app/core/testing/interfaces/mock-global-store-responses.interface.ts b/src/app/core/testing/interfaces/mock-global-store-responses.interface.ts new file mode 100644 index 00000000000..2a08ddddb11 --- /dev/null +++ b/src/app/core/testing/interfaces/mock-global-store-responses.interface.ts @@ -0,0 +1,22 @@ +import { + ApiCallAndSubscribeMethod, + ApiCallAndSubscribeResponse, +} from 'app/interfaces/api/api-call-and-subscribe-directory.interface'; +import { + ApiCallMethod, + ApiCallResponse, +} from 'app/interfaces/api/api-call-directory.interface'; +import { + ApiEventMethod, + ApiEventTyped, +} from 'app/interfaces/api-message.interface'; + +export interface MockGlobalStoreResponses< + M1 extends ApiCallMethod, + M2 extends ApiEventMethod, + M3 extends ApiCallAndSubscribeMethod, +> { + call?: ApiCallResponse; + subscribe?: ApiEventTyped; + callAndSubscribe?: ApiCallAndSubscribeResponse[]; +} diff --git a/src/app/pages/dashboard/services/widget-resources.service.spec.ts b/src/app/pages/dashboard/services/widget-resources.service.spec.ts new file mode 100644 index 00000000000..83224b01cba --- /dev/null +++ b/src/app/pages/dashboard/services/widget-resources.service.spec.ts @@ -0,0 +1,57 @@ +import { + createServiceFactory, + SpectatorService, +} from '@ngneat/spectator/jest'; +import { mockGlobalStore } from 'app/core/testing/classes/mock-global-store.service'; +import { mockCall, mockWebSocket } from 'app/core/testing/utils/mock-websocket.utils'; +import { App } from 'app/interfaces/app.interface'; +import { Pool } from 'app/interfaces/pool.interface'; +import { WidgetResourcesService } from 'app/pages/dashboard/services/widget-resources.service'; +import { appStore, poolStore } from 'app/services/global-store/stores.constant'; + +const pools = [ + { id: 1, name: 'pool_1' }, + { id: 2, name: 'pool_2' }, +] as Pool[]; + +const apps = [ + { id: '1', name: 'app_1' }, + { id: '2', name: 'app_2' }, +] as App[]; + +describe('WidgetResourcesService', () => { + let spectator: SpectatorService; + const createService = createServiceFactory({ + service: WidgetResourcesService, + providers: [ + mockWebSocket([ + mockCall('replication.query'), + mockCall('rsynctask.query'), + mockCall('cloudsync.query'), + mockCall('webui.main.dashboard.sys_info'), + mockCall('interface.query'), + mockCall('update.check_available'), + ]), + mockGlobalStore([ + [poolStore, { callAndSubscribe: pools }], + [appStore, { call: apps }], + ]), + ], + }); + + beforeEach(() => { + spectator = createService(); + }); + + it('returns pools', () => { + let poolsResponse: Pool[]; + spectator.service.pools$.subscribe((response) => poolsResponse = response); + expect(poolsResponse).toEqual(pools); + }); + + it('returns apps', () => { + let appsResponse: App[]; + spectator.service.installedApps$.subscribe((response) => appsResponse = response.value); + expect(appsResponse).toEqual(apps); + }); +}); diff --git a/src/app/pages/dashboard/services/widget-resources.service.ts b/src/app/pages/dashboard/services/widget-resources.service.ts index e68a61f9ff3..33498a58889 100644 --- a/src/app/pages/dashboard/services/widget-resources.service.ts +++ b/src/app/pages/dashboard/services/widget-resources.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { subHours, subMinutes } from 'date-fns'; import { @@ -17,6 +17,7 @@ import { Pool } from 'app/interfaces/pool.interface'; import { ReportingData } from 'app/interfaces/reporting.interface'; import { VolumesData, VolumeData } from 'app/interfaces/volume-data.interface'; import { processNetworkInterfaces } from 'app/pages/dashboard/widgets/network/widget-interface/widget-interface.utils'; +import { appStore, poolStore } from 'app/services/global-store/stores.constant'; import { WebSocketService } from 'app/services/ws.service'; import { AppsState } from 'app/store'; import { waitForSystemInfo } from 'app/store/system-info/system-info.selectors'; @@ -67,9 +68,9 @@ export class WidgetResourcesService { shareReplay({ bufferSize: 1, refCount: true }), ); - readonly installedApps$ = this.ws.call('app.query').pipe(toLoadingState()); + readonly installedApps$ = inject(appStore).call.pipe(toLoadingState()); - readonly pools$ = this.ws.callAndSubscribe('pool.query').pipe( + readonly pools$ = inject(poolStore).callAndSubscribe.pipe( shareReplay({ bufferSize: 1, refCount: true }), ); diff --git a/src/app/services/global-store/global-store.service.spec.ts b/src/app/services/global-store/global-store.service.spec.ts new file mode 100644 index 00000000000..51d38f2d65e --- /dev/null +++ b/src/app/services/global-store/global-store.service.spec.ts @@ -0,0 +1,187 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '@ngneat/spectator/jest'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { ApiEvent } from 'app/interfaces/api-message.interface'; +import { Pool } from 'app/interfaces/pool.interface'; +import { QueryParams } from 'app/interfaces/query-api.interface'; +import { globalStore } from 'app/services/global-store/global-store.service'; +import { WebSocketService } from 'app/services/ws.service'; + +const poolResponse = [ + { id: 1, name: 'pool_1' }, + { id: 2, name: 'pool_2' }, +] as Pool[]; + +const params: QueryParams = [ + [ + ['id', '=', 1], + ['id', '=', 2], + ], +]; + +const poolStore = globalStore('pool.query', params); + +describe('GlobalStoreService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + mockProvider(TranslateService), + mockProvider(WebSocketService, { + call: jest.fn(() => of(poolResponse)), + subscribe: jest.fn(() => of({ + fields: poolResponse, + } as ApiEvent)), + callAndSubscribe: jest.fn(() => of(poolResponse)), + }), + ], + }); + }); + + describe('call', () => { + it('sends a request after subscription', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.call; + + const poolSubscription = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledWith('pool.query', params); + poolSubscription.unsubscribe(); + }); + + it('caches a request result', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.call; + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledTimes(0); + + const poolSubscription1 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledTimes(1); + poolSubscription1.unsubscribe(); + + const poolSubscription2 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledTimes(1); + poolSubscription2.unsubscribe(); + + const poolSubscription3 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledTimes(1); + poolSubscription3.unsubscribe(); + }); + + it('invalidates a request result', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.call; + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledTimes(0); + + const poolSubscription1 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledTimes(1); + poolSubscription1.unsubscribe(); + + const poolSubscription2 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledTimes(1); + poolSubscription2.unsubscribe(); + + poolStoreService.invalidate(); + + const poolSubscription3 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).call).toHaveBeenCalledTimes(2); + poolSubscription3.unsubscribe(); + }); + }); + + describe('subscribe', () => { + it('sends a request after subscription', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.subscribe; + + const poolSubscription = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledWith('pool.query'); + poolSubscription.unsubscribe(); + }); + + it('caches a request result', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.subscribe; + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledTimes(0); + + const poolSubscription1 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledTimes(1); + poolSubscription1.unsubscribe(); + + const poolSubscription2 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledTimes(1); + poolSubscription2.unsubscribe(); + + const poolSubscription3 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledTimes(1); + poolSubscription3.unsubscribe(); + }); + + it('invalidates a request result', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.subscribe; + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledTimes(0); + + const poolSubscription1 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledTimes(1); + poolSubscription1.unsubscribe(); + + const poolSubscription2 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledTimes(1); + poolSubscription2.unsubscribe(); + + poolStoreService.invalidate(); + + const poolSubscription3 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).subscribe).toHaveBeenCalledTimes(2); + poolSubscription3.unsubscribe(); + }); + }); + + describe('callAndSubscribe', () => { + it('sends a request after subscription', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.callAndSubscribe; + + const poolSubscription = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledWith('pool.query', params); + poolSubscription.unsubscribe(); + }); + + it('caches a request result', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.callAndSubscribe; + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledTimes(0); + + const poolSubscription1 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledTimes(1); + poolSubscription1.unsubscribe(); + + const poolSubscription2 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledTimes(1); + poolSubscription2.unsubscribe(); + + const poolSubscription3 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledTimes(1); + poolSubscription3.unsubscribe(); + }); + + it('invalidates a request result', () => { + const poolStoreService = TestBed.inject(poolStore); + const pools$ = poolStoreService.callAndSubscribe; + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledTimes(0); + + const poolSubscription1 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledTimes(1); + poolSubscription1.unsubscribe(); + + const poolSubscription2 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledTimes(1); + poolSubscription2.unsubscribe(); + + poolStoreService.invalidate(); + + const poolSubscription3 = pools$.subscribe(); + expect(TestBed.inject(WebSocketService).callAndSubscribe).toHaveBeenCalledTimes(2); + poolSubscription3.unsubscribe(); + }); + }); +}); diff --git a/src/app/services/global-store/global-store.service.ts b/src/app/services/global-store/global-store.service.ts new file mode 100644 index 00000000000..0069857f170 --- /dev/null +++ b/src/app/services/global-store/global-store.service.ts @@ -0,0 +1,82 @@ +import { inject, Injectable, Type } from '@angular/core'; +import { + BehaviorSubject, Observable, of, switchMap, tap, +} from 'rxjs'; +import { ApiCallAndSubscribeMethod, ApiCallAndSubscribeResponse } from 'app/interfaces/api/api-call-and-subscribe-directory.interface'; +import { ApiCallMethod, ApiCallParams, ApiCallResponse } from 'app/interfaces/api/api-call-directory.interface'; +import { ApiEventMethod, ApiEventTyped } from 'app/interfaces/api-message.interface'; +import { WebSocketService } from 'app/services/ws.service'; + +export interface GlobalStoreMembers< + M1 extends ApiCallMethod, + M2 extends ApiEventMethod, + M3 extends ApiCallAndSubscribeMethod, +> { + call: Observable>; + subscribe: Observable>; + callAndSubscribe: Observable[]>; + invalidate: () => void; +} + +export function globalStore< + M1 extends ApiCallMethod, + M2 extends ApiEventMethod, + M3 extends ApiCallAndSubscribeMethod, +>( + method: M1 | M2 | M3, + params?: ApiCallParams, +): Type> { + @Injectable({ providedIn: 'root' }) + class GlobalStore implements GlobalStoreMembers { + private ws = inject(WebSocketService); + private callResult$ = new BehaviorSubject>(undefined); + private subscribeResult$ = new BehaviorSubject>(undefined); + private callAndSubscribeResult$ = new BehaviorSubject[]>(undefined); + + get call(): Observable> { + return this.callResult$.pipe( + switchMap((callResult) => { + if (callResult === undefined) { + return this.ws + .call(method as M1, params as ApiCallParams) + .pipe(tap((result) => this.callResult$.next(result))); + } + return of(callResult); + }), + ); + } + + get subscribe(): Observable> { + return this.subscribeResult$.pipe( + switchMap((subscribeResult) => { + if (subscribeResult === undefined) { + return this.ws + .subscribe(method as M2) + .pipe(tap((result) => this.subscribeResult$.next(result))); + } + return of(subscribeResult); + }), + ); + } + + get callAndSubscribe(): Observable[]> { + return this.callAndSubscribeResult$.pipe( + switchMap((callAndSubscribeResult) => { + if (callAndSubscribeResult === undefined) { + return this.ws + .callAndSubscribe(method as M3, params as ApiCallParams) + .pipe(tap((result) => this.callAndSubscribeResult$.next(result))); + } + return of(callAndSubscribeResult); + }), + ); + } + + invalidate(): void { + this.callResult$.next(undefined); + this.subscribeResult$.next(undefined); + this.callAndSubscribeResult$.next(undefined); + } + } + return GlobalStore; +} diff --git a/src/app/services/global-store/stores.constant.ts b/src/app/services/global-store/stores.constant.ts new file mode 100644 index 00000000000..13b05cde4f6 --- /dev/null +++ b/src/app/services/global-store/stores.constant.ts @@ -0,0 +1,4 @@ +import { globalStore } from 'app/services/global-store/global-store.service'; + +export const poolStore = globalStore('pool.query'); +export const appStore = globalStore('app.query');