From 27c700165c699b8d18f2f91eff5704d5021e8ba4 Mon Sep 17 00:00:00 2001 From: Bartosz Sekula Date: Mon, 7 Sep 2026 14:21:03 +0200 Subject: [PATCH] AAE-50901 Allow provideMockFeatureFlags to receive observables (#12221) * AAE-50901 Allow provideMockFeatureFlags to receive observables * update * update * cr * cr --- .../src/lib/interfaces/features.interface.ts | 10 +- .../features-service-mock.factory.spec.ts | 133 ++++++++++++++++++ .../mocks/features-service-mock.factory.ts | 105 ++++++++++++-- 3 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.spec.ts diff --git a/lib/core/feature-flags/src/lib/interfaces/features.interface.ts b/lib/core/feature-flags/src/lib/interfaces/features.interface.ts index 6ec4d92e7b..7676cce16e 100644 --- a/lib/core/feature-flags/src/lib/interfaces/features.interface.ts +++ b/lib/core/feature-flags/src/lib/interfaces/features.interface.ts @@ -32,11 +32,13 @@ export interface QaFeaturesHelperConfig { helperExposeKeyOnDocument?: string; } +export interface FlagChangesetValues { + current: any; + previous: any; +} + export interface FlagChangeset { - [key: string]: { - current: any; - previous: any; - }; + [key: string]: FlagChangesetValues; } export interface WritableFlagChangeset { diff --git a/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.spec.ts b/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.spec.ts new file mode 100644 index 0000000000..6dc6f88d1d --- /dev/null +++ b/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.spec.ts @@ -0,0 +1,133 @@ +/*! + * @license + * Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs'; +import { FeaturesServiceToken, IFeaturesService } from '../interfaces/features.interface'; +import { MockFeatureFlags, provideMockFeatureFlags } from './features-service-mock.factory'; + +describe('provideMockFeatureFlags', () => { + let featureValue$: BehaviorSubject; + const feature1 = 'feature1'; + + const setupFeatureService = (featureFlags: MockFeatureFlags | string | string[]): IFeaturesService => { + TestBed.configureTestingModule({ + providers: [provideMockFeatureFlags(featureFlags)] + }); + + return TestBed.inject(FeaturesServiceToken); + }; + + beforeEach(() => { + featureValue$ = new BehaviorSubject(false); + }); + + it('should emit the updated value when an observable feature flag changes', async () => { + const featuresService = setupFeatureService({ [feature1]: featureValue$ }); + + expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(false); + expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(true); + + featureValue$.next(true); + + expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(true); + expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(false); + + featureValue$.next(false); + + expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(false); + expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(true); + }); + + it('should not let a consumer change the feature flag value through the observable returned by isOn$', async () => { + const originalValue$ = new BehaviorSubject(true); + const service = setupFeatureService({ [feature1]: originalValue$ }); + + const isOn$ = service.isOn$(feature1) as Subject; + + expect(isOn$).not.toBe(originalValue$); + expect(() => isOn$.next(false)).toThrow(); + expect(await firstValueFrom(service.isOn$(feature1))).toBe(true); + expect(await firstValueFrom(originalValue$)).toBe(true); + }); + + it('should mock a string feature flag as enabled', async () => { + const service = setupFeatureService(feature1); + + expect(await firstValueFrom(service.isOn$(feature1))).toBe(true); + expect(await firstValueFrom(service.isOff$(feature1))).toBe(false); + }); + + it('should mock every feature flag in an array as enabled', async () => { + const feature2 = 'feature2'; + const service = setupFeatureService([feature1, feature2]); + + expect(await firstValueFrom(service.getFlags$())).toEqual({ + [feature1]: { current: true, previous: null }, + [feature2]: { current: true, previous: null } + }); + }); + + it('should emit an empty changeset when no feature flags are provided', async () => { + const service = setupFeatureService({}); + + expect(await firstValueFrom(service.getFlags$())).toEqual({}); + expect(await firstValueFrom(service.init())).toEqual({}); + }); + + it('should emit an empty changeset when an empty feature flag array is provided', async () => { + const service = setupFeatureService([]); + + expect(await firstValueFrom(service.getFlags$())).toEqual({}); + expect(await firstValueFrom(service.init())).toEqual({}); + }); + + it('should return the expected state when the feature flag is true', async () => { + const service = setupFeatureService({ [feature1]: true }); + + expect(await firstValueFrom(service.init())).toEqual({ + [feature1]: { current: true, previous: null } + }); + expect(await firstValueFrom(service.isOn$(feature1))).toBe(true); + expect(await firstValueFrom(service.isOff$(feature1))).toBe(false); + }); + + it('should return the expected state when the feature flag is false', async () => { + const service = setupFeatureService({ [feature1]: false }); + + expect(await firstValueFrom(service.init())).toEqual({ + [feature1]: { current: false, previous: null } + }); + expect(await firstValueFrom(service.isOn$(feature1))).toBe(false); + expect(await firstValueFrom(service.isOff$(feature1))).toBe(true); + }); + + it('should resolve observable values in the complete feature flags result', async () => { + const service = setupFeatureService({ [feature1]: of(true) }); + + expect(await firstValueFrom(service.getFlags$())).toEqual({ + [feature1]: { current: true, previous: null } + }); + }); + + it('should throw when a feature flag has not been mocked', () => { + const featuresService = setupFeatureService({ [feature1]: true }); + + expect(() => featuresService.isOn$('missing-feature')).toThrowError(/missing-feature.*not mocked/); + expect(() => featuresService.isOff$('missing-feature')).toThrowError(/missing-feature.*not mocked/); + }); +}); diff --git a/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.ts b/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.ts index ab36f26f26..fca375ae61 100644 --- a/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.ts +++ b/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.ts @@ -15,11 +15,27 @@ * limitations under the License. */ -import { of } from 'rxjs'; -import { FeaturesServiceToken, FlagChangeset, IFeaturesService } from '../interfaces/features.interface'; +import { of, Observable, map, combineLatest, take, defer } from 'rxjs'; +import { FeaturesServiceToken, FlagChangeset, FlagChangesetValues, IFeaturesService } from '../interfaces/features.interface'; +/** + * Feature flags to mock. A boolean sets a fixed value, an observable lets the test change the value over time. + * + * Observable flags must have a current value, use 'BehaviorSubject' or 'of(...)'. + * A bare 'Subject' has no value until it emits, and 'getFlags$()'/'init()' withhold the whole changeset + * until every observable flag has emitted at least once. + */ export interface MockFeatureFlags { - [key: string]: boolean; + [key: string]: boolean | Observable; +} + +interface MockFlagChangesetValues extends FlagChangesetValues { + current: boolean | Observable; + previous: null; +} + +interface MockFlagChangeset extends FlagChangeset { + [key: string]: MockFlagChangesetValues; } const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => { @@ -32,19 +48,72 @@ const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => { } }; -const mockFeaturesService = (flagChangeset: FlagChangeset): IFeaturesService => ({ - init: () => of(flagChangeset), +/** + * Calling 'pipe' on a 'Subject' returns an 'AnonymousSubject' that still writes through to the original 'Subject', + * so piping alone does not stop a consumer from pushing values into the mocked flag. + * 'defer' breaks that chain and gives back a plain, read only observable. + * + * @param value$ Observable feature flag value provided by the test + * @returns Observable that cannot be used to change the mocked value + */ +const toReadOnly = (value$: Observable): Observable => defer(() => value$); + +const mockFeaturesService = (flagChangeset: MockFlagChangeset): IFeaturesService => ({ + init: () => resolveFeatureFlagValues(flagChangeset).pipe(take(1)), isOn$: (key) => { assertFeatureFlag(flagChangeset, key); - return of(flagChangeset[key].current); + const featureFlagValue = flagChangeset[key].current; + + // In case of an observable, we do not want to return the original observable, so a consumer cannot 'next', 'error' or 'complete' it + return typeof featureFlagValue === 'boolean' ? of(featureFlagValue) : toReadOnly(featureFlagValue).pipe(map(Boolean)); }, isOff$: (key) => { assertFeatureFlag(flagChangeset, key); - return of(!flagChangeset[key].current); + const featureFlagValue = flagChangeset[key].current; + + return typeof featureFlagValue === 'boolean' ? of(!featureFlagValue) : toReadOnly(featureFlagValue).pipe(map((value) => !value)); }, - getFlags$: () => of(flagChangeset) + getFlags$: () => resolveFeatureFlagValues(flagChangeset) }); +/** + * 'provideMockFeatureFlags' can receive observables, therefore we need to resolve these values + * + * @param mockFlagChangeset Mocked flag changeset + * @returns FlagChangeset + */ +const resolveFeatureFlagValues = (mockFlagChangeset: MockFlagChangeset): Observable => { + // No FF provided, just return empty object + if (Object.keys(mockFlagChangeset).length === 0) { + return of({}); + } + + const resolveFeatureFlagValues$ = Object.entries(mockFlagChangeset).map(([featureKey, values]) => { + if (typeof values.current === 'boolean') { + return of([featureKey, { ...values }] as const); + } + + // Value is observable, we need to resolve it + const observableValue$ = values.current; + + const resolveFlagValue$ = observableValue$.pipe(map((resolvedValue) => [featureKey, { current: resolvedValue, previous: null }] as const)); + + return resolveFlagValue$; + }); + + return combineLatest(resolveFeatureFlagValues$).pipe( + map((resolvedFlags) => { + const resolvedFeatureFlag: FlagChangeset = {}; + + resolvedFlags.forEach(([key, values]) => { + resolvedFeatureFlag[key] = values; + }); + + return resolvedFeatureFlag; + }) + ); +}; + const arrayToFlagChangeset = (featureFlags: string[]): FlagChangeset => { const flagChangeset: FlagChangeset = {}; featureFlags.forEach((featureFlag) => { @@ -62,6 +131,26 @@ const mockFeatureFlagsToFlagChangeset = (mockFeatureFlags: MockFeatureFlags) => return flagChangeset; }; +/** + * Mock the FeaturesService with the provided feature flags. + * A string or string[] sets every listed feature to true, a MockFeatureFlags object sets each value explicitly. + * Every flag the code under test asks for has to be mocked, otherwise 'isOn$'/'isOff$' throw. + * + * Use a 'BehaviorSubject' for a flag that changes during the test. A bare 'Subject' has no current value, + * so the flag stays silent and 'getFlags$()'/'init()' withhold the whole changeset, until it emits. + * + * @example + * + * const featureA$ = new BehaviorSubject(false); + * + * providers: [provideMockFeatureFlags('featureA')] + * providers: [provideMockFeatureFlags(['featureA', 'featureB'])] + * providers: [provideMockFeatureFlags({ featureA: true, featureB: false })] + * providers: [provideMockFeatureFlags({ featureA: featureA$ })] + * + * @param featureFlag The feature flag(s) to mock. Can be a single feature flag string, an array of feature flag strings, or a MockFeatureFlags object. + * @returns A provider object for the FeaturesServiceToken with the mocked feature flags. + */ export const provideMockFeatureFlags = (featureFlag: MockFeatureFlags | string | string[]) => { if (typeof featureFlag === 'string') { featureFlag = [featureFlag];