282 lines
9.3 KiB
TypeScript
282 lines
9.3 KiB
TypeScript
/**
|
|
* need to update test environment so structuredClone works appropriately
|
|
* @jest-environment ../../libs/shared/test.environment.ts
|
|
*/
|
|
|
|
import { mock } from "jest-mock-extended";
|
|
import { Subject, firstValueFrom, of } from "rxjs";
|
|
|
|
import {
|
|
FakeGlobalState,
|
|
FakeSingleUserState,
|
|
FakeStateProvider,
|
|
awaitAsync,
|
|
mockAccountServiceWith,
|
|
} from "../../../../spec";
|
|
import { subscribeTo } from "../../../../spec/observable-tracker";
|
|
import { UserId } from "../../../types/guid";
|
|
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
|
import { ServerConfig } from "../../abstractions/config/server-config";
|
|
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
|
import { LogService } from "../../abstractions/log.service";
|
|
import { Utils } from "../../misc/utils";
|
|
import { ServerConfigData } from "../../models/data/server-config.data";
|
|
import {
|
|
EnvironmentServerConfigResponse,
|
|
ServerConfigResponse,
|
|
ThirdPartyServerConfigResponse,
|
|
} from "../../models/response/server-config.response";
|
|
|
|
import {
|
|
ApiUrl,
|
|
DefaultConfigService,
|
|
RETRIEVAL_INTERVAL,
|
|
GLOBAL_SERVER_CONFIGURATIONS,
|
|
USER_SERVER_CONFIG,
|
|
} from "./default-config.service";
|
|
|
|
describe("ConfigService", () => {
|
|
const configApiService = mock<ConfigApiServiceAbstraction>();
|
|
const environmentService = mock<EnvironmentService>();
|
|
const logService = mock<LogService>();
|
|
let stateProvider: FakeStateProvider;
|
|
let globalState: FakeGlobalState<Record<ApiUrl, ServerConfig>>;
|
|
let userState: FakeSingleUserState<ServerConfig>;
|
|
const activeApiUrl = apiUrl(0);
|
|
const userId = "userId" as UserId;
|
|
const accountService = mockAccountServiceWith(userId);
|
|
const tooOld = new Date(Date.now() - 1.1 * RETRIEVAL_INTERVAL);
|
|
|
|
beforeEach(() => {
|
|
stateProvider = new FakeStateProvider(accountService);
|
|
globalState = stateProvider.global.getFake(GLOBAL_SERVER_CONFIGURATIONS);
|
|
userState = stateProvider.singleUser.getFake(userId, USER_SERVER_CONFIG);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.resetAllMocks();
|
|
});
|
|
|
|
describe.each([null, userId])("active user: %s", (activeUserId) => {
|
|
let sut: DefaultConfigService;
|
|
|
|
beforeAll(async () => {
|
|
await accountService.switchAccount(activeUserId);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
environmentService.environment$ = of(environmentFactory(activeApiUrl));
|
|
sut = new DefaultConfigService(
|
|
configApiService,
|
|
environmentService,
|
|
logService,
|
|
stateProvider,
|
|
);
|
|
});
|
|
|
|
describe("serverConfig$", () => {
|
|
it.each([{}, null])("handles null stored state", async (globalTestState) => {
|
|
globalState.stateSubject.next(globalTestState);
|
|
userState.nextState(null);
|
|
await expect(firstValueFrom(sut.serverConfig$)).resolves.not.toThrow();
|
|
});
|
|
|
|
describe.each(["stale", "missing"])("%s config", (configStateDescription) => {
|
|
const userStored =
|
|
configStateDescription === "missing"
|
|
? null
|
|
: serverConfigFactory(activeApiUrl + userId, tooOld);
|
|
const globalStored =
|
|
configStateDescription === "missing"
|
|
? {}
|
|
: {
|
|
[activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
globalState.stateSubject.next(globalStored);
|
|
userState.nextState(userStored);
|
|
});
|
|
|
|
// sanity check
|
|
test("authed and unauthorized state are different", () => {
|
|
expect(globalStored[activeApiUrl]).not.toEqual(userStored);
|
|
});
|
|
|
|
describe("fail to fetch", () => {
|
|
beforeEach(() => {
|
|
configApiService.get.mockRejectedValue(new Error("Unable to fetch"));
|
|
});
|
|
|
|
it("uses storage as fallback", async () => {
|
|
const actual = await firstValueFrom(sut.serverConfig$);
|
|
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
|
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not error out when fetch fails", async () => {
|
|
await expect(firstValueFrom(sut.serverConfig$)).resolves.not.toThrow();
|
|
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("logs an error when unable to fetch", async () => {
|
|
await firstValueFrom(sut.serverConfig$);
|
|
|
|
expect(logService.error).toHaveBeenCalledWith(
|
|
`Unable to fetch ServerConfig from ${activeApiUrl}: Unable to fetch`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("fetch success", () => {
|
|
const response = serverConfigResponseFactory();
|
|
const newConfig = new ServerConfig(new ServerConfigData(response));
|
|
|
|
it("should be a new config", async () => {
|
|
expect(newConfig).not.toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
|
});
|
|
|
|
it("fetches config from server when it's older than an hour", async () => {
|
|
await firstValueFrom(sut.serverConfig$);
|
|
|
|
expect(configApiService.get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns the updated config", async () => {
|
|
configApiService.get.mockResolvedValue(response);
|
|
|
|
const actual = await firstValueFrom(sut.serverConfig$);
|
|
|
|
// This is the time the response is converted to a config
|
|
expect(actual.utcDate).toAlmostEqual(newConfig.utcDate, 1000);
|
|
delete actual.utcDate;
|
|
delete newConfig.utcDate;
|
|
|
|
expect(actual).toEqual(newConfig);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("fresh configuration", () => {
|
|
const userStored = serverConfigFactory(activeApiUrl + userId);
|
|
const globalStored = {
|
|
[activeApiUrl]: serverConfigFactory(activeApiUrl),
|
|
};
|
|
beforeEach(() => {
|
|
globalState.stateSubject.next(globalStored);
|
|
userState.nextState(userStored);
|
|
});
|
|
it("does not fetch from server", async () => {
|
|
await firstValueFrom(sut.serverConfig$);
|
|
|
|
expect(configApiService.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses stored value", async () => {
|
|
const actual = await firstValueFrom(sut.serverConfig$);
|
|
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
|
});
|
|
|
|
it("does not complete after emit", async () => {
|
|
const emissions = [];
|
|
const subscription = sut.serverConfig$.subscribe((v) => emissions.push(v));
|
|
await awaitAsync();
|
|
expect(emissions.length).toBe(1);
|
|
expect(subscription.closed).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("environment change", () => {
|
|
let sut: DefaultConfigService;
|
|
let environmentSubject: Subject<Environment>;
|
|
|
|
beforeAll(async () => {
|
|
// updating environment with an active account is undefined behavior
|
|
await accountService.switchAccount(null);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
environmentSubject = new Subject<Environment>();
|
|
environmentService.environment$ = environmentSubject;
|
|
sut = new DefaultConfigService(
|
|
configApiService,
|
|
environmentService,
|
|
logService,
|
|
stateProvider,
|
|
);
|
|
});
|
|
|
|
describe("serverConfig$", () => {
|
|
it("emits a new config when the environment changes", async () => {
|
|
const globalStored = {
|
|
[apiUrl(0)]: serverConfigFactory(apiUrl(0)),
|
|
[apiUrl(1)]: serverConfigFactory(apiUrl(1)),
|
|
};
|
|
globalState.stateSubject.next(globalStored);
|
|
|
|
const spy = subscribeTo(sut.serverConfig$);
|
|
|
|
environmentSubject.next(environmentFactory(apiUrl(0)));
|
|
environmentSubject.next(environmentFactory(apiUrl(1)));
|
|
|
|
const expected = [globalStored[apiUrl(0)], globalStored[apiUrl(1)]];
|
|
|
|
const actual = await spy.pauseUntilReceived(2);
|
|
expect(actual.length).toBe(2);
|
|
|
|
// validate dates this is done separately because the dates are created when ServerConfig is initialized
|
|
expect(actual[0].utcDate).toAlmostEqual(expected[0].utcDate, 1000);
|
|
expect(actual[1].utcDate).toAlmostEqual(expected[1].utcDate, 1000);
|
|
delete actual[0].utcDate;
|
|
delete actual[1].utcDate;
|
|
delete expected[0].utcDate;
|
|
delete expected[1].utcDate;
|
|
|
|
expect(actual).toEqual(expected);
|
|
spy.unsubscribe();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
function apiUrl(count: number) {
|
|
return `https://api${count}.test.com`;
|
|
}
|
|
|
|
function serverConfigFactory(hash: string, date: Date = new Date()) {
|
|
const config = new ServerConfig(serverConfigDataFactory(hash));
|
|
config.utcDate = date;
|
|
return config;
|
|
}
|
|
|
|
function serverConfigDataFactory(hash?: string) {
|
|
return new ServerConfigData(serverConfigResponseFactory(hash));
|
|
}
|
|
|
|
function serverConfigResponseFactory(hash?: string) {
|
|
return new ServerConfigResponse({
|
|
version: "myConfigVersion",
|
|
gitHash: hash ?? Utils.newGuid(), // Use optional git hash to store uniqueness value
|
|
server: new ThirdPartyServerConfigResponse({
|
|
name: "myThirdPartyServer",
|
|
url: "www.example.com",
|
|
}),
|
|
environment: new EnvironmentServerConfigResponse({
|
|
vault: "vault.example.com",
|
|
}),
|
|
featureStates: {
|
|
feat1: "off",
|
|
feat2: "on",
|
|
feat3: "off",
|
|
},
|
|
});
|
|
}
|
|
|
|
function environmentFactory(apiUrl: string) {
|
|
return {
|
|
getApiUrl: () => apiUrl,
|
|
} as Environment;
|
|
}
|