mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
AAE-50901 Allow provideMockFeatureFlags to receive observables (#12221)
* AAE-50901 Allow provideMockFeatureFlags to receive observables * update * update * cr * cr
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<boolean>;
|
||||
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<boolean>;
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
interface MockFlagChangesetValues extends FlagChangesetValues {
|
||||
current: boolean | Observable<boolean>;
|
||||
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<boolean>): Observable<boolean> => 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<FlagChangeset> => {
|
||||
// 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];
|
||||
|
||||
Reference in New Issue
Block a user