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;
|
helperExposeKeyOnDocument?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FlagChangesetValues {
|
||||||
|
current: any;
|
||||||
|
previous: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FlagChangeset {
|
export interface FlagChangeset {
|
||||||
[key: string]: {
|
[key: string]: FlagChangesetValues;
|
||||||
current: any;
|
|
||||||
previous: any;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WritableFlagChangeset {
|
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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { of } from 'rxjs';
|
import { of, Observable, map, combineLatest, take, defer } from 'rxjs';
|
||||||
import { FeaturesServiceToken, FlagChangeset, IFeaturesService } from '../interfaces/features.interface';
|
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 {
|
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 => {
|
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) => {
|
isOn$: (key) => {
|
||||||
assertFeatureFlag(flagChangeset, 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) => {
|
isOff$: (key) => {
|
||||||
assertFeatureFlag(flagChangeset, 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 arrayToFlagChangeset = (featureFlags: string[]): FlagChangeset => {
|
||||||
const flagChangeset: FlagChangeset = {};
|
const flagChangeset: FlagChangeset = {};
|
||||||
featureFlags.forEach((featureFlag) => {
|
featureFlags.forEach((featureFlag) => {
|
||||||
@@ -62,6 +131,26 @@ const mockFeatureFlagsToFlagChangeset = (mockFeatureFlags: MockFeatureFlags) =>
|
|||||||
return flagChangeset;
|
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[]) => {
|
export const provideMockFeatureFlags = (featureFlag: MockFeatureFlags | string | string[]) => {
|
||||||
if (typeof featureFlag === 'string') {
|
if (typeof featureFlag === 'string') {
|
||||||
featureFlag = [featureFlag];
|
featureFlag = [featureFlag];
|
||||||
|
|||||||
Reference in New Issue
Block a user