diff --git a/libs/common/spec/observable-tracker.ts b/libs/common/spec/observable-tracker.ts index 16fad869c3..9bf0475bee 100644 --- a/libs/common/spec/observable-tracker.ts +++ b/libs/common/spec/observable-tracker.ts @@ -1,10 +1,11 @@ -import { Observable, Subscription, firstValueFrom, throwError, timeout } from "rxjs"; +import { Observable, Subject, Subscription, firstValueFrom, throwError, timeout } from "rxjs"; /** Test class to enable async awaiting of observable emissions */ export class ObservableTracker { private subscription: Subscription; + private emissionReceived = new Subject(); emissions: T[] = []; - constructor(private observable: Observable) { + constructor(observable: Observable) { this.emissions = this.trackEmissions(observable); } @@ -21,7 +22,7 @@ export class ObservableTracker { */ async expectEmission(msTimeout = 50): Promise { return await firstValueFrom( - this.observable.pipe( + this.emissionReceived.pipe( timeout({ first: msTimeout, with: () => throwError(() => new Error("Timeout exceeded waiting for another emission.")), @@ -34,40 +35,38 @@ export class ObservableTracker { * @param count The number of emissions to wait for */ async pauseUntilReceived(count: number, msTimeout = 50): Promise { - for (let i = 0; i < count - this.emissions.length; i++) { + while (this.emissions.length < count) { await this.expectEmission(msTimeout); } return this.emissions; } - private trackEmissions(observable: Observable): T[] { + private trackEmissions(observable: Observable): T[] { const emissions: T[] = []; this.subscription = observable.subscribe((value) => { - switch (value) { - case undefined: - case null: - emissions.push(value); - return; - default: - // process by type - break; + if (value == null) { + this.emissionReceived.next(null); + return; } switch (typeof value) { case "string": case "number": case "boolean": - emissions.push(value); + this.emissionReceived.next(value); break; case "symbol": // Cheating types to make symbols work at all - emissions.push(value.toString() as T); + this.emissionReceived.next(value as T); break; default: { - emissions.push(clone(value)); + this.emissionReceived.next(clone(value)); } } }); + this.emissionReceived.subscribe((value) => { + emissions.push(value); + }); return emissions; } } diff --git a/libs/common/src/platform/services/app-id.service.spec.ts b/libs/common/src/platform/services/app-id.service.spec.ts index 10fb153fda..62806204db 100644 --- a/libs/common/src/platform/services/app-id.service.spec.ts +++ b/libs/common/src/platform/services/app-id.service.spec.ts @@ -1,20 +1,23 @@ -import { FakeGlobalStateProvider } from "../../../spec"; +import { FakeGlobalState, FakeGlobalStateProvider, ObservableTracker } from "../../../spec"; import { Utils } from "../misc/utils"; import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service"; describe("AppIdService", () => { - const globalStateProvider = new FakeGlobalStateProvider(); - const appIdState = globalStateProvider.getFake(APP_ID_KEY); - const anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY); + let globalStateProvider: FakeGlobalStateProvider; + let appIdState: FakeGlobalState; + let anonymousAppIdState: FakeGlobalState; let sut: AppIdService; beforeEach(() => { + globalStateProvider = new FakeGlobalStateProvider(); + appIdState = globalStateProvider.getFake(APP_ID_KEY); + anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY); sut = new AppIdService(globalStateProvider); }); afterEach(() => { - jest.restoreAllMocks(); + jest.resetAllMocks(); }); describe("getAppId", () => { @@ -26,19 +29,18 @@ describe("AppIdService", () => { expect(appId).toBe("existingAppId"); }); - it.each([null, undefined])( - "uses the util function to create a new id when it AppId does not exist", - async (value) => { - appIdState.stateSubject.next(value); - const spy = jest.spyOn(Utils, "newGuid"); + it("creates a new appId only once", async () => { + appIdState.stateSubject.next(null); - await sut.getAppId(); + const appIds: string[] = []; + const promises = [async () => appIds.push(await sut.getAppId())]; + promises.push(async () => appIds.push(await sut.getAppId())); + await Promise.all(promises); - expect(spy).toHaveBeenCalledTimes(1); - }, - ); + expect(appIds[0]).toBe(appIds[1]); + }); - it.each([null, undefined])("returns a new appId when it does not exist", async (value) => { + it.each([null, undefined])("returns a new appId when %s", async (value) => { appIdState.stateSubject.next(value); const appId = await sut.getAppId(); @@ -46,16 +48,23 @@ describe("AppIdService", () => { expect(appId).toMatch(Utils.guidRegex); }); - it.each([null, undefined])( - "stores the new guid when it an existing one is not found", - async (value) => { - appIdState.stateSubject.next(value); + it.each([null, undefined])("stores the new guid when %s", async (value) => { + appIdState.stateSubject.next(value); - const appId = await sut.getAppId(); + const appId = await sut.getAppId(); - expect(appIdState.nextMock).toHaveBeenCalledWith(appId); - }, - ); + expect(appIdState.nextMock).toHaveBeenCalledWith(appId); + }); + + it("emits only once when creating a new appId", async () => { + appIdState.stateSubject.next(null); + + const tracker = new ObservableTracker(sut.appId$); + const appId = await sut.getAppId(); + + expect(tracker.emissions).toEqual([appId]); + await expect(tracker.pauseUntilReceived(2, 50)).rejects.toThrow("Timeout exceeded"); + }); }); describe("getAnonymousAppId", () => { @@ -67,17 +76,16 @@ describe("AppIdService", () => { expect(appId).toBe("existingAppId"); }); - it.each([null, undefined])( - "uses the util function to create a new id when it AppId does not exist", - async (value) => { - anonymousAppIdState.stateSubject.next(value); - const spy = jest.spyOn(Utils, "newGuid"); + it("creates a new anonymousAppId only once", async () => { + anonymousAppIdState.stateSubject.next(null); - await sut.getAnonymousAppId(); + const appIds: string[] = []; + const promises = [async () => appIds.push(await sut.getAnonymousAppId())]; + promises.push(async () => appIds.push(await sut.getAnonymousAppId())); + await Promise.all(promises); - expect(spy).toHaveBeenCalledTimes(1); - }, - ); + expect(appIds[0]).toBe(appIds[1]); + }); it.each([null, undefined])("returns a new appId when it does not exist", async (value) => { anonymousAppIdState.stateSubject.next(value); @@ -97,5 +105,15 @@ describe("AppIdService", () => { expect(anonymousAppIdState.nextMock).toHaveBeenCalledWith(appId); }, ); + + it("emits only once when creating a new anonymousAppId", async () => { + anonymousAppIdState.stateSubject.next(null); + + const tracker = new ObservableTracker(sut.anonymousAppId$); + const appId = await sut.getAnonymousAppId(); + + expect(tracker.emissions).toEqual([appId]); + await expect(tracker.pauseUntilReceived(2, 50)).rejects.toThrow("Timeout exceeded"); + }); }); }); diff --git a/libs/common/src/platform/services/app-id.service.ts b/libs/common/src/platform/services/app-id.service.ts index 630e629749..56e9516bce 100644 --- a/libs/common/src/platform/services/app-id.service.ts +++ b/libs/common/src/platform/services/app-id.service.ts @@ -1,4 +1,4 @@ -import { Observable, filter, firstValueFrom, tap } from "rxjs"; +import { Observable, concatMap, distinctUntilChanged, firstValueFrom, share } from "rxjs"; import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service"; import { Utils } from "../misc/utils"; @@ -19,20 +19,28 @@ export class AppIdService implements AppIdServiceAbstraction { const appIdState = globalStateProvider.get(APP_ID_KEY); const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY); this.appId$ = appIdState.state$.pipe( - tap(async (appId) => { + concatMap(async (appId) => { if (!appId) { - await appIdState.update(() => Utils.newGuid()); + return await appIdState.update(() => Utils.newGuid(), { + shouldUpdate: (v) => v == null, + }); } + return appId; }), - filter((appId) => !!appId), + distinctUntilChanged(), + share(), ); this.anonymousAppId$ = anonymousAppIdState.state$.pipe( - tap(async (appId) => { + concatMap(async (appId) => { if (!appId) { - await anonymousAppIdState.update(() => Utils.newGuid()); + return await anonymousAppIdState.update(() => Utils.newGuid(), { + shouldUpdate: (v) => v == null, + }); } + return appId; }), - filter((appId) => !!appId), + distinctUntilChanged(), + share(), ); }