mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-21 01:41:31 +00:00
Initial Move from Azure DevOps to GitHub
This commit is contained in:
112
src/Utils/AuthorizationUtils.test.ts
Normal file
112
src/Utils/AuthorizationUtils.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Constants from "../Common/Constants";
|
||||
import * as AuthorizationUtils from "./AuthorizationUtils";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { PlatformType } from "../PlatformType";
|
||||
import { CosmosClient } from "../Common/CosmosClient";
|
||||
import Explorer from "../Explorer/Explorer";
|
||||
jest.mock("../Explorer/Explorer");
|
||||
|
||||
describe("AuthorizationUtils", () => {
|
||||
let originalAuthorizationToken: string;
|
||||
let originalAccessToken: string;
|
||||
|
||||
beforeAll(() => {
|
||||
originalAuthorizationToken = CosmosClient.authorizationToken();
|
||||
originalAccessToken = CosmosClient.accessToken();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
CosmosClient.authorizationToken && CosmosClient.authorizationToken(originalAuthorizationToken);
|
||||
CosmosClient.accessToken(originalAccessToken);
|
||||
});
|
||||
|
||||
describe("getAuthorizationHeader()", () => {
|
||||
it("should return authorization header if authentication type is AAD", () => {
|
||||
window.authType = AuthType.AAD;
|
||||
CosmosClient.authorizationToken = ko.observable("some-token");
|
||||
|
||||
expect(AuthorizationUtils.getAuthorizationHeader().header).toBe(Constants.HttpHeaders.authorization);
|
||||
expect(AuthorizationUtils.getAuthorizationHeader().token).toBe("some-token");
|
||||
});
|
||||
|
||||
it("should return guest access header if authentication type is EncryptedToken", () => {
|
||||
window.authType = AuthType.EncryptedToken;
|
||||
CosmosClient.accessToken = ko.observable("some-token");
|
||||
|
||||
expect(AuthorizationUtils.getAuthorizationHeader().header).toBe(Constants.HttpHeaders.guestAccessToken);
|
||||
expect(AuthorizationUtils.getAuthorizationHeader().token).toBe("some-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptJWTToken()", () => {
|
||||
it("should throw an error if token is undefined", () => {
|
||||
expect(() => AuthorizationUtils.decryptJWTToken(undefined)).toThrowError();
|
||||
});
|
||||
|
||||
it("should throw an error if token is null", () => {
|
||||
expect(() => AuthorizationUtils.decryptJWTToken(null)).toThrowError();
|
||||
});
|
||||
|
||||
it("should throw an error if token is empty", () => {
|
||||
expect(() => AuthorizationUtils.decryptJWTToken("")).toThrowError();
|
||||
});
|
||||
|
||||
it("should throw an error if token is malformed", () => {
|
||||
expect(() =>
|
||||
AuthorizationUtils.decryptJWTToken(
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9."
|
||||
)
|
||||
).toThrowError();
|
||||
});
|
||||
|
||||
it("should return decrypted token payload", () => {
|
||||
expect(
|
||||
AuthorizationUtils.decryptJWTToken(
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.eyJhdWQiOiJodHRwczovL3dvcmtzcGFjZWFydGlmYWN0cy5wcm9qZWN0YXJjYWRpYS5uZXQiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTcxOTUwMjIwLCJuYmYiOjE1NzE5NTAyMjAsImV4cCI6MTU3MTk1NDEyMCwiYWNyIjoiMSIsImFpbyI6IkFWUUFxLzhOQUFBQVJ5c1pWWW1qV3lqeG1zU3VpdUdGbUZLSEwxKytFM2JBK0xhck5mMUVYUnZ1MFB6bDlERWFaMVNMdi8vSXlscG5hanFwZG1aSjFaSXNZUEN0UzJrY1lJbWdTVjFvUitsM2VlNWZlT1JZRjZvPSIsImFtciI6WyJyc2EiLCJtZmEiXSwiYXBwaWQiOiIyMDNmMTE0NS04NTZhLTQyMzItODNkNC1hNDM1NjhmYmEyM2QiLCJhcHBpZGFjciI6IjAiLCJmYW1pbHlfbmFtZSI6IlJhbmdhaXNoZW52aSIsImdpdmVuX25hbWUiOiJWaWduZXNoIiwiaGFzZ3JvdXBzIjoidHJ1ZSIsImlwYWRkciI6IjEzMS4xMDcuMTQ3LjE0NiIsIm5hbWUiOiJWaWduZXNoIFJhbmdhaXNoZW52aSIsIm9pZCI6ImJiN2Q0YjliLTZlOGYtNDg4NS05OTI4LTBhOWM5OWQwN2Q1NSIsIm9ucHJlbV9zaWQiOiJTLTEtNS0yMS0yMTI3NTIxMTg0LTE2MDQwMTI5MjAtMTg4NzkyNzUyNy0yNzEyNTYzNiIsInB1aWQiOiIxMDAzMDAwMEEyNjJGNDE4Iiwic2NwIjoid29ya3NwYWNlYXJ0aWZhY3RzLm1hbmFnZW1lbnQiLCJzdWIiOiI0X3hzSVdTdWZncHEtN2ZBV1dxaURYT3U5bGtKbDRpWEtBV0JVeUZ0Mm5vIiwidGlkIjoiNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3IiwidW5pcXVlX25hbWUiOiJ2aXJhbmdhaUBtaWNyb3NvZnQuY29tIiwidXBuIjoidmlyYW5nYWlAbWljcm9zb2Z0LmNvbSIsInV0aSI6InoxRldzZzlWU2tPR1BTcEdremdWQUEiLCJ2ZXIiOiIxLjAifQ.nd-CZ6jpTQ8_2wkxQzuaoJCyEeR_woFK4MGMpHEVttwTd5WBDbVOUgk6gz36Jm2fdFemrQFJ03n1MXtCJYNnMoJX37SrGD3lAzZlXs5aBQig6ZrexWkiUDaaNcbx5qVy8O5JEQPds8OGMArsfUra0DG7iW0v7rgvhInX0umeC8ugnU5C-xEMPSZ9xYj0Q7m62AQrrCIIc94nUicEpxm_cusfsbT-CJHf2yLdmLYQkSx-ewzyBca0jiIl98sm0xA9btXDcwnWcmTY9scyGZ9mlSMtz4zmVY0NUdwssysKm7Js4aWtbA_ON8tsNEElViuwy_w3havM_3RQaNv26J87eQ"
|
||||
)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayTokenRenewalPromptForStatus()", () => {
|
||||
let explorer = new Explorer({} as any) as jest.Mocked<Explorer>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.dataExplorer = explorer;
|
||||
window.dataExplorerPlatform = PlatformType.Hosted;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.dataExplorer = undefined;
|
||||
window.dataExplorerPlatform = undefined;
|
||||
});
|
||||
|
||||
it("should not open token renewal prompt if status code is undefined", () => {
|
||||
AuthorizationUtils.displayTokenRenewalPromptForStatus(undefined);
|
||||
expect(explorer.displayGuestAccessTokenRenewalPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open token renewal prompt if status code is null", () => {
|
||||
AuthorizationUtils.displayTokenRenewalPromptForStatus(null);
|
||||
expect(explorer.displayGuestAccessTokenRenewalPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open token renewal prompt if status code is not 401", () => {
|
||||
AuthorizationUtils.displayTokenRenewalPromptForStatus(Constants.HttpStatusCodes.Forbidden);
|
||||
expect(explorer.displayGuestAccessTokenRenewalPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open token renewal prompt if running on a different platform", () => {
|
||||
window.dataExplorerPlatform = PlatformType.Portal;
|
||||
AuthorizationUtils.displayTokenRenewalPromptForStatus(Constants.HttpStatusCodes.Unauthorized);
|
||||
expect(explorer.displayGuestAccessTokenRenewalPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should open token renewal prompt if running on hosted platform and status code is 401", () => {
|
||||
AuthorizationUtils.displayTokenRenewalPromptForStatus(Constants.HttpStatusCodes.Unauthorized);
|
||||
expect(explorer.displayGuestAccessTokenRenewalPrompt).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
72
src/Utils/AuthorizationUtils.ts
Normal file
72
src/Utils/AuthorizationUtils.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as Constants from "../Common/Constants";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import AuthHeadersUtil from "../Platform/Hosted/Authorization";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { Logger } from "../Common/Logger";
|
||||
import { PlatformType } from "../PlatformType";
|
||||
import { CosmosClient } from "../Common/CosmosClient";
|
||||
import { config } from "../Config";
|
||||
|
||||
export function getAuthorizationHeader(): ViewModels.AuthorizationTokenHeaderMetadata {
|
||||
if (window.authType === AuthType.EncryptedToken) {
|
||||
return {
|
||||
header: Constants.HttpHeaders.guestAccessToken,
|
||||
token: CosmosClient.accessToken()
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
header: Constants.HttpHeaders.authorization,
|
||||
token: CosmosClient.authorizationToken() || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getArcadiaAuthToken(
|
||||
arcadiaEndpoint: string = config.ARCADIA_ENDPOINT,
|
||||
tenantId?: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const token = await AuthHeadersUtil.getAccessToken(arcadiaEndpoint, tenantId);
|
||||
return token;
|
||||
} catch (error) {
|
||||
Logger.logError(error, "AuthorizationUtils/getArcadiaAuthToken");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptJWTToken(token: string) {
|
||||
if (!token) {
|
||||
Logger.logError("Cannot decrypt token: No JWT token found", "AuthorizationUtils/decryptJWTToken");
|
||||
throw new Error("No JWT token found");
|
||||
}
|
||||
const tokenParts = token.split(".");
|
||||
if (tokenParts.length < 2) {
|
||||
Logger.logError(`Invalid JWT token: ${token}`, "AuthorizationUtils/decryptJWTToken");
|
||||
throw new Error(`Invalid JWT token: ${token}`);
|
||||
}
|
||||
let tokenPayloadBase64: string = tokenParts[1];
|
||||
tokenPayloadBase64 = tokenPayloadBase64.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const tokenPayload = decodeURIComponent(
|
||||
atob(tokenPayloadBase64)
|
||||
.split("")
|
||||
.map(p => "%" + ("00" + p.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join("")
|
||||
);
|
||||
|
||||
return JSON.parse(tokenPayload);
|
||||
}
|
||||
|
||||
export function displayTokenRenewalPromptForStatus(httpStatusCode: number): void {
|
||||
const platformType: PlatformType = window.dataExplorerPlatform;
|
||||
const explorer: ViewModels.Explorer = window.dataExplorer;
|
||||
|
||||
if (
|
||||
httpStatusCode == null ||
|
||||
httpStatusCode != Constants.HttpStatusCodes.Unauthorized ||
|
||||
platformType !== PlatformType.Hosted
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
explorer.displayGuestAccessTokenRenewalPrompt();
|
||||
}
|
||||
119
src/Utils/AutoPilotUtils.test.ts
Normal file
119
src/Utils/AutoPilotUtils.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as AutoPilotUtils from "./AutoPilotUtils";
|
||||
import * as Constants from "../Common/Constants";
|
||||
import { AutopilotTier, Offer } from "../Contracts/DataModels";
|
||||
|
||||
describe("AutoPilotUtils", () => {
|
||||
describe("isAutoPilotOfferUpgradedToV3", () => {
|
||||
const legacyAutopilotOffer = {
|
||||
tier: 1,
|
||||
maximumTierThroughput: 20000,
|
||||
maxThroughput: 20000
|
||||
};
|
||||
|
||||
const v3AutopilotOffer = {
|
||||
maximumTierThroughput: 20000,
|
||||
maxThroughput: 20000
|
||||
};
|
||||
|
||||
const v3AutopilotOfferDuringTransitionPhase = {
|
||||
tier: 0,
|
||||
maximumTierThroughput: 20000,
|
||||
maxThroughput: 20000
|
||||
};
|
||||
|
||||
it("should return false if the offer has a tier level and the tier level >= 1", () => {
|
||||
expect(AutoPilotUtils.isAutoPilotOfferUpgradedToV3(legacyAutopilotOffer)).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return true if the autopilot offer does not have a tier level", () => {
|
||||
expect(AutoPilotUtils.isAutoPilotOfferUpgradedToV3(v3AutopilotOffer)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true if the autopilot offer has a tier level and the tier level is === 0", () => {
|
||||
expect(AutoPilotUtils.isAutoPilotOfferUpgradedToV3(v3AutopilotOfferDuringTransitionPhase)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidAutoPilotOffer", () => {
|
||||
function getOffer(): Offer {
|
||||
const commonOffer: Offer = {
|
||||
_etag: "_etag",
|
||||
_rid: "_rid",
|
||||
_self: "_self",
|
||||
_ts: "_ts",
|
||||
id: "id",
|
||||
content: {
|
||||
offerThroughput: 0,
|
||||
offerIsRUPerMinuteThroughputEnabled: false,
|
||||
offerAutopilotSettings: undefined
|
||||
}
|
||||
};
|
||||
return commonOffer;
|
||||
}
|
||||
|
||||
it("offer with autopilot", () => {
|
||||
let offer = getOffer();
|
||||
offer.content.offerAutopilotSettings = {
|
||||
tier: 1
|
||||
};
|
||||
const isValid = AutoPilotUtils.isValidV2AutoPilotOffer(offer);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("offer without autopilot", () => {
|
||||
let offer = getOffer();
|
||||
const isValid = AutoPilotUtils.isValidV2AutoPilotOffer(offer);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidAutoPilotTier", () => {
|
||||
it("invalid input, should return false", () => {
|
||||
const isValid1 = AutoPilotUtils.isValidAutoPilotTier(0);
|
||||
expect(isValid1).toBe(false);
|
||||
const isValid2 = AutoPilotUtils.isValidAutoPilotTier(5);
|
||||
expect(isValid2).toBe(false);
|
||||
const isValid3 = AutoPilotUtils.isValidAutoPilotTier(undefined);
|
||||
expect(isValid3).toBe(false);
|
||||
});
|
||||
|
||||
it("valid input, should return true", () => {
|
||||
const isValid1 = AutoPilotUtils.isValidAutoPilotTier(1);
|
||||
expect(isValid1).toBe(true);
|
||||
const isValid3 = AutoPilotUtils.isValidAutoPilotTier(AutopilotTier.Tier3);
|
||||
expect(isValid3).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutoPilotTextWithTier", () => {
|
||||
it("invalid input, should return undefined", () => {
|
||||
const text1 = AutoPilotUtils.getAutoPilotTextWithTier(0);
|
||||
expect(text1).toBe(undefined);
|
||||
const text2 = AutoPilotUtils.getAutoPilotTextWithTier(undefined);
|
||||
expect(text2).toBe(undefined);
|
||||
});
|
||||
|
||||
it("valid input, should return coreponding text", () => {
|
||||
const text1 = AutoPilotUtils.getAutoPilotTextWithTier(1);
|
||||
expect(text1).toBe(Constants.AutoPilot.tier1Text);
|
||||
const text4 = AutoPilotUtils.getAutoPilotTextWithTier(AutopilotTier.Tier4);
|
||||
expect(text4).toBe(Constants.AutoPilot.tier4Text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableAutoPilotTiersOptions", () => {
|
||||
it("invalid input should return all options", () => {
|
||||
const option1 = AutoPilotUtils.getAvailableAutoPilotTiersOptions(undefined);
|
||||
expect(option1.length).toBe(4);
|
||||
const option2 = AutoPilotUtils.getAvailableAutoPilotTiersOptions(5);
|
||||
expect(option2.length).toBe(4);
|
||||
});
|
||||
|
||||
it("valid input should return all available options", () => {
|
||||
const option1 = AutoPilotUtils.getAvailableAutoPilotTiersOptions();
|
||||
expect(option1.length).toBe(4);
|
||||
const option2 = AutoPilotUtils.getAvailableAutoPilotTiersOptions(AutopilotTier.Tier3);
|
||||
expect(option2.length).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
src/Utils/AutoPilotUtils.ts
Normal file
94
src/Utils/AutoPilotUtils.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as Constants from "../Common/Constants";
|
||||
import { AutoPilotOfferSettings, AutopilotTier, Offer } from "../Contracts/DataModels";
|
||||
import { DropdownOption } from "../Contracts/ViewModels";
|
||||
|
||||
export const manualToAutoscaleDisclaimer = `The starting autoscale max RU/s will be determined by the system, based on the current manual throughput settings and storage of your resource. After autoscale has been enabled, you can change the max RU/s. <a href="${Constants.Urls.autoscaleMigration}">Learn more</a>.`;
|
||||
|
||||
export const minAutoPilotThroughput = 4000;
|
||||
|
||||
export const autoPilotIncrementStep = 1000;
|
||||
|
||||
const autoPilotTiers: Array<AutopilotTier> = [
|
||||
AutopilotTier.Tier1,
|
||||
AutopilotTier.Tier2,
|
||||
AutopilotTier.Tier3,
|
||||
AutopilotTier.Tier4
|
||||
];
|
||||
|
||||
const autoPilotTierTextMap = {
|
||||
[AutopilotTier.Tier1]: Constants.AutoPilot.tier1Text,
|
||||
[AutopilotTier.Tier2]: Constants.AutoPilot.tier2Text,
|
||||
[AutopilotTier.Tier3]: Constants.AutoPilot.tier3Text,
|
||||
[AutopilotTier.Tier4]: Constants.AutoPilot.tier4Text
|
||||
};
|
||||
|
||||
export function isAutoPilotOfferUpgradedToV3(offer: AutoPilotOfferSettings): boolean {
|
||||
return offer && !offer.tier;
|
||||
}
|
||||
|
||||
export function isValidV3AutoPilotOffer(offer: Offer): boolean {
|
||||
const maxThroughput =
|
||||
offer &&
|
||||
offer.content &&
|
||||
offer.content.offerAutopilotSettings &&
|
||||
offer.content.offerAutopilotSettings.maxThroughput;
|
||||
return isValidAutoPilotThroughput(maxThroughput);
|
||||
}
|
||||
|
||||
export function isValidV2AutoPilotOffer(offer: Offer): boolean {
|
||||
const tier =
|
||||
offer && offer.content && offer.content.offerAutopilotSettings && offer.content.offerAutopilotSettings.tier;
|
||||
if (!tier) {
|
||||
return false;
|
||||
}
|
||||
return isValidAutoPilotTier(tier);
|
||||
}
|
||||
|
||||
export function isValidAutoPilotTier(tier: number | AutopilotTier): boolean {
|
||||
if (autoPilotTiers.indexOf(tier) >= 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getAutoPilotTextWithTier(tier: AutopilotTier): string {
|
||||
return !!autoPilotTierTextMap[tier] ? autoPilotTierTextMap[tier] : undefined;
|
||||
}
|
||||
|
||||
export function getAvailableAutoPilotTiersOptions(
|
||||
tier: AutopilotTier = AutopilotTier.Tier1
|
||||
): DropdownOption<AutopilotTier>[] {
|
||||
if (!isValidAutoPilotTier(tier)) {
|
||||
tier = AutopilotTier.Tier1;
|
||||
}
|
||||
|
||||
return autoPilotTiers.map((t: AutopilotTier) => ({ value: t, text: getAutoPilotTextWithTier(t) }));
|
||||
}
|
||||
|
||||
export function isValidAutoPilotThroughput(maxThroughput: number): boolean {
|
||||
if (!maxThroughput) {
|
||||
return false;
|
||||
}
|
||||
if (maxThroughput < minAutoPilotThroughput) {
|
||||
return false;
|
||||
}
|
||||
if (maxThroughput % 1000) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getMinRUsBasedOnUserInput(throughput: number): number {
|
||||
return Math.round(throughput && throughput * 0.1);
|
||||
}
|
||||
|
||||
export function getStorageBasedOnUserInput(throughput: number): number {
|
||||
return Math.round(throughput && throughput * 0.01);
|
||||
}
|
||||
|
||||
export function getAutoPilotHeaderText(isV2Model: boolean): string {
|
||||
if (isV2Model) {
|
||||
return "Throughput (Autopilot)";
|
||||
}
|
||||
return "Throughput (autoscale)";
|
||||
}
|
||||
154
src/Utils/DatabaseAccountUtils.test.ts
Normal file
154
src/Utils/DatabaseAccountUtils.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import { DatabaseAccountUtils } from "./DatabaseAccountUtils";
|
||||
|
||||
describe("DatabaseAccountUtils Tests", () => {
|
||||
describe("mergeDatabaseAccountWithGateway", () => {
|
||||
const databaseAccountWithProperties: ViewModels.DatabaseAccount = {
|
||||
id: "test",
|
||||
kind: "GlobalDocumentDB",
|
||||
name: "test",
|
||||
location: "somewhere",
|
||||
type: "DocumentDB",
|
||||
tags: [],
|
||||
properties: {
|
||||
documentEndpoint: "",
|
||||
cassandraEndpoint: "",
|
||||
gremlinEndpoint: "",
|
||||
tableEndpoint: ""
|
||||
}
|
||||
};
|
||||
|
||||
const databaseAccountWithLocations: ViewModels.DatabaseAccount = {
|
||||
id: "test",
|
||||
kind: "GlobalDocumentDB",
|
||||
name: "test",
|
||||
location: "somewhere",
|
||||
type: "DocumentDB",
|
||||
tags: [],
|
||||
properties: {
|
||||
documentEndpoint: "",
|
||||
cassandraEndpoint: "",
|
||||
gremlinEndpoint: "",
|
||||
tableEndpoint: "",
|
||||
enableMultipleWriteLocations: false,
|
||||
readLocations: [
|
||||
{
|
||||
documentEndpoint: "https://centralus",
|
||||
failoverPriority: 0,
|
||||
id: "",
|
||||
locationId: "",
|
||||
locationName: "Central US",
|
||||
provisioningState: "Succeeded"
|
||||
}
|
||||
],
|
||||
writeLocations: [
|
||||
{
|
||||
documentEndpoint: "https://centralus",
|
||||
failoverPriority: 0,
|
||||
id: "",
|
||||
locationId: "",
|
||||
locationName: "Central US",
|
||||
provisioningState: "Succeeded"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const databaseAccountWithoutProperties: ViewModels.DatabaseAccount = {
|
||||
id: "test",
|
||||
kind: "GlobalDocumentDB",
|
||||
name: "test",
|
||||
location: "somewhere",
|
||||
type: "DocumentDB",
|
||||
tags: [],
|
||||
properties: null
|
||||
};
|
||||
|
||||
const gatewayDatabaseAccount: DataModels.GatewayDatabaseAccount = {
|
||||
EnableMultipleWriteLocations: true,
|
||||
CurrentMediaStorageUsageInMB: 0,
|
||||
DatabasesLink: "",
|
||||
MaxMediaStorageUsageInMB: 0,
|
||||
MediaLink: "",
|
||||
ReadableLocations: [
|
||||
{
|
||||
name: "Central US",
|
||||
documentAccountEndpoint: "https://centralus"
|
||||
},
|
||||
{
|
||||
name: "North Central US",
|
||||
documentAccountEndpoint: "https://northcentralus"
|
||||
}
|
||||
],
|
||||
WritableLocations: [
|
||||
{
|
||||
name: "Central US",
|
||||
documentAccountEndpoint: "https://centralus"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
it("should return databaseAccount when gatewayDatabaseAccount is not defined", () => {
|
||||
expect(DatabaseAccountUtils.mergeDatabaseAccountWithGateway(databaseAccountWithoutProperties, null)).toBe(
|
||||
databaseAccountWithoutProperties
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null when databaseAccount is not defined", () => {
|
||||
expect(DatabaseAccountUtils.mergeDatabaseAccountWithGateway(null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return merged with no properties when databaseAccount has no properties", () => {
|
||||
const merged = DatabaseAccountUtils.mergeDatabaseAccountWithGateway(
|
||||
databaseAccountWithoutProperties,
|
||||
gatewayDatabaseAccount
|
||||
);
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged.properties).toBeNull();
|
||||
});
|
||||
|
||||
it("should return merged writableLocations", () => {
|
||||
const merged = DatabaseAccountUtils.mergeDatabaseAccountWithGateway(
|
||||
databaseAccountWithProperties,
|
||||
gatewayDatabaseAccount
|
||||
);
|
||||
expect(merged.properties).toBeDefined();
|
||||
expect(merged.properties.writeLocations).toBeDefined();
|
||||
expect(merged.properties.writeLocations.length).toBe(gatewayDatabaseAccount.WritableLocations.length);
|
||||
});
|
||||
|
||||
it("should return merged readableLocations", () => {
|
||||
const merged = DatabaseAccountUtils.mergeDatabaseAccountWithGateway(
|
||||
databaseAccountWithProperties,
|
||||
gatewayDatabaseAccount
|
||||
);
|
||||
expect(merged.properties).toBeDefined();
|
||||
expect(merged.properties.readLocations).toBeDefined();
|
||||
expect(merged.properties.readLocations.length).toBe(gatewayDatabaseAccount.ReadableLocations.length);
|
||||
});
|
||||
|
||||
it("should return merged enableMultipleWriteLocations", () => {
|
||||
const merged = DatabaseAccountUtils.mergeDatabaseAccountWithGateway(
|
||||
databaseAccountWithProperties,
|
||||
gatewayDatabaseAccount
|
||||
);
|
||||
expect(merged.properties).toBeDefined();
|
||||
expect(merged.properties.enableMultipleWriteLocations).toBe(gatewayDatabaseAccount.EnableMultipleWriteLocations);
|
||||
});
|
||||
|
||||
it("should not overwrite existing values", () => {
|
||||
const merged = DatabaseAccountUtils.mergeDatabaseAccountWithGateway(
|
||||
databaseAccountWithLocations,
|
||||
gatewayDatabaseAccount
|
||||
);
|
||||
expect(merged.properties.enableMultipleWriteLocations).toBe(
|
||||
databaseAccountWithLocations.properties.enableMultipleWriteLocations
|
||||
);
|
||||
expect(merged.properties.readLocations.length).toBe(databaseAccountWithLocations.properties.readLocations.length);
|
||||
expect(merged.properties.writeLocations.length).toBe(
|
||||
databaseAccountWithLocations.properties.writeLocations.length
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
src/Utils/DatabaseAccountUtils.ts
Normal file
53
src/Utils/DatabaseAccountUtils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import { StringUtils } from "./StringUtils";
|
||||
|
||||
export class DatabaseAccountUtils {
|
||||
public static mergeDatabaseAccountWithGateway(
|
||||
databaseAccount: ViewModels.DatabaseAccount,
|
||||
gatewayDatabaseAccount: DataModels.GatewayDatabaseAccount
|
||||
): ViewModels.DatabaseAccount {
|
||||
if (!databaseAccount || !gatewayDatabaseAccount) {
|
||||
return databaseAccount;
|
||||
}
|
||||
|
||||
if (databaseAccount.properties && gatewayDatabaseAccount.EnableMultipleWriteLocations) {
|
||||
databaseAccount.properties.enableMultipleWriteLocations = gatewayDatabaseAccount.EnableMultipleWriteLocations;
|
||||
}
|
||||
|
||||
if (databaseAccount.properties && !databaseAccount.properties.readLocations) {
|
||||
databaseAccount.properties.readLocations = DatabaseAccountUtils._convertToDatabaseAccountResponseLocation(
|
||||
gatewayDatabaseAccount.ReadableLocations
|
||||
);
|
||||
}
|
||||
|
||||
if (databaseAccount.properties && !databaseAccount.properties.writeLocations) {
|
||||
databaseAccount.properties.writeLocations = DatabaseAccountUtils._convertToDatabaseAccountResponseLocation(
|
||||
gatewayDatabaseAccount.WritableLocations
|
||||
);
|
||||
}
|
||||
|
||||
return databaseAccount;
|
||||
}
|
||||
|
||||
private static _convertToDatabaseAccountResponseLocation(
|
||||
gatewayLocations: DataModels.RegionEndpoint[]
|
||||
): DataModels.DatabaseAccountResponseLocation[] {
|
||||
if (!gatewayLocations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return gatewayLocations.map((gatewayLocation: DataModels.RegionEndpoint, index: number) => {
|
||||
const responseLocation: DataModels.DatabaseAccountResponseLocation = {
|
||||
documentEndpoint: gatewayLocation.documentAccountEndpoint,
|
||||
locationName: gatewayLocation.name,
|
||||
failoverPriority: index,
|
||||
locationId: StringUtils.stripSpacesFromString(gatewayLocation.name).toLowerCase(),
|
||||
provisioningState: "Succeeded",
|
||||
id: gatewayLocation.name
|
||||
};
|
||||
|
||||
return responseLocation;
|
||||
});
|
||||
}
|
||||
}
|
||||
50
src/Utils/GitHubUtils.test.ts
Normal file
50
src/Utils/GitHubUtils.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { GitHubUtils } from "./GitHubUtils";
|
||||
|
||||
describe("GitHubUtils", () => {
|
||||
describe("fromGitHubUri", () => {
|
||||
it("parses github repo url for a branch", () => {
|
||||
const gitHubInfo = GitHubUtils.fromGitHubUri("https://github.com/owner/repo/tree/branch");
|
||||
expect(gitHubInfo).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
branch: "branch",
|
||||
path: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("parses github file url", () => {
|
||||
const gitHubInfo = GitHubUtils.fromGitHubUri("https://github.com/owner/repo/blob/branch/dir/file.ext");
|
||||
expect(gitHubInfo).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
branch: "branch",
|
||||
path: "dir/file.ext"
|
||||
});
|
||||
});
|
||||
|
||||
it("parses github file url with spaces", () => {
|
||||
const gitHubInfo = GitHubUtils.fromGitHubUri("https://github.com/owner/repo/blob/branch/dir/file name.ext");
|
||||
expect(gitHubInfo).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
branch: "branch",
|
||||
path: "dir/file name.ext"
|
||||
});
|
||||
});
|
||||
|
||||
it("parses github file url with encoded chars", () => {
|
||||
const gitHubInfo = GitHubUtils.fromGitHubUri("https://github.com/owner/repo/blob/branch/dir/file%20name.ext");
|
||||
expect(gitHubInfo).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
branch: "branch",
|
||||
path: "dir/file%20name.ext"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("toRepoFullName returns full name in expected format", () => {
|
||||
const fullName = GitHubUtils.toRepoFullName("owner", "repo");
|
||||
expect(fullName).toBe("owner/repo");
|
||||
});
|
||||
});
|
||||
56
src/Utils/GitHubUtils.ts
Normal file
56
src/Utils/GitHubUtils.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import UrlUtility from "../Common/UrlUtility";
|
||||
import { RepoListItem } from "../Explorer/Controls/GitHub/GitHubReposComponent";
|
||||
import { IGitHubFile, IGitHubRepo } from "../GitHub/GitHubClient";
|
||||
import { IPinnedRepo } from "../Juno/JunoClient";
|
||||
|
||||
export class GitHubUtils {
|
||||
// The pattern is https://github.com/<owner>/<repo>/(blob|tree)/<branch>/<path>
|
||||
private static readonly UriPattern = "https://github.com/([^/]*)/([^/]*)/(blob|tree)/([^/]*)/?(.*)";
|
||||
|
||||
public static toRepoFullName(owner: string, repo: string): string {
|
||||
return `${owner}/${repo}`;
|
||||
}
|
||||
|
||||
public static toGitHubUriForRepoAndBranch(owner: string, repo: string, branch: string, path?: string): string {
|
||||
return UrlUtility.createUri(`https://github.com/${owner}/${repo}/tree/${branch}`, path);
|
||||
}
|
||||
|
||||
public static toGitHubUriForFile(gitHubFile: IGitHubFile): string {
|
||||
return decodeURIComponent(gitHubFile.html_url);
|
||||
}
|
||||
|
||||
public static fromGitHubUri(
|
||||
gitHubUri: string
|
||||
): undefined | { owner: string; repo: string; branch: string; path: string } {
|
||||
try {
|
||||
const matches = gitHubUri.match(GitHubUtils.UriPattern);
|
||||
return {
|
||||
owner: matches[1],
|
||||
repo: matches[2],
|
||||
branch: matches[4],
|
||||
path: matches[5]
|
||||
};
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public static toPinnedRepo(item: RepoListItem): IPinnedRepo {
|
||||
return {
|
||||
owner: item.repo.owner.login,
|
||||
name: item.repo.name,
|
||||
private: item.repo.private,
|
||||
branches: item.branches.map(element => ({ name: element.name }))
|
||||
};
|
||||
}
|
||||
|
||||
public static toGitHubRepo(pinnedRepo: IPinnedRepo): IGitHubRepo {
|
||||
return {
|
||||
owner: {
|
||||
login: pinnedRepo.owner
|
||||
},
|
||||
name: pinnedRepo.name,
|
||||
private: pinnedRepo.private
|
||||
};
|
||||
}
|
||||
}
|
||||
44
src/Utils/JunoUtils.ts
Normal file
44
src/Utils/JunoUtils.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import { config } from "../Config";
|
||||
|
||||
export class JunoUtils {
|
||||
public static async getLikedNotebooks(authorizationToken: string): Promise<DataModels.LikedNotebooksJunoResponse> {
|
||||
//TODO: Add Get method once juno has it implemented
|
||||
return {
|
||||
likedNotebooksContent: await JunoUtils.getOfficialSampleNotebooks(),
|
||||
userMetadata: {
|
||||
likedNotebooks: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static async getOfficialSampleNotebooks(): Promise<DataModels.GitHubInfoJunoResponse[]> {
|
||||
try {
|
||||
const response = await window.fetch(config.JUNO_ENDPOINT + "/api/galleries/notebooks", {
|
||||
method: "GET"
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Status code:" + response.status);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
throw new Error("Official samples fetch failed.");
|
||||
}
|
||||
}
|
||||
|
||||
public static async updateUserMetadata(
|
||||
authorizationToken: string,
|
||||
userMetadata: DataModels.UserMetadata
|
||||
): Promise<DataModels.UserMetadata> {
|
||||
return undefined;
|
||||
//TODO: add userMetadata updation code
|
||||
}
|
||||
|
||||
public static async updateNotebookMetadata(
|
||||
authorizationToken: string,
|
||||
userMetadata: DataModels.NotebookMetadata
|
||||
): Promise<DataModels.NotebookMetadata> {
|
||||
return undefined;
|
||||
//TODO: add notebookMetadata updation code
|
||||
}
|
||||
}
|
||||
16
src/Utils/MessageValidation.ts
Normal file
16
src/Utils/MessageValidation.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { config } from "../Config";
|
||||
|
||||
export function isInvalidParentFrameOrigin(event: MessageEvent): boolean {
|
||||
return !isValidOrigin(config.allowedParentFrameOrigins, event);
|
||||
}
|
||||
|
||||
function isValidOrigin(allowedOrigins: RegExp, event: MessageEvent): boolean {
|
||||
const eventOrigin = (event && event.origin) || "";
|
||||
const windowOrigin = (window && window.origin) || "";
|
||||
if (eventOrigin === windowOrigin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = allowedOrigins && allowedOrigins.test(eventOrigin);
|
||||
return result;
|
||||
}
|
||||
86
src/Utils/NotebookConfigurationUtils.ts
Normal file
86
src/Utils/NotebookConfigurationUtils.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import { Explorer, KernelConnectionMetadata } from "../Contracts/ViewModels";
|
||||
import { Logger } from "../Common/Logger";
|
||||
|
||||
export class NotebookConfigurationUtils {
|
||||
private constructor() {}
|
||||
|
||||
public static async configureServiceEndpoints(
|
||||
notebookPath: string,
|
||||
notebookConnectionInfo: DataModels.NotebookWorkspaceConnectionInfo,
|
||||
kernelName: string,
|
||||
clusterConnectionInfo: DataModels.SparkClusterConnectionInfo
|
||||
): Promise<void> {
|
||||
if (!notebookPath || !notebookConnectionInfo || !kernelName) {
|
||||
Logger.logError(
|
||||
"Invalid or missing notebook connection info/path",
|
||||
"NotebookConfigurationUtils/configureServiceEndpoints"
|
||||
);
|
||||
return Promise.reject("Invalid or missing notebook connection info");
|
||||
}
|
||||
|
||||
if (!clusterConnectionInfo || !clusterConnectionInfo.endpoints || clusterConnectionInfo.endpoints.length === 0) {
|
||||
Logger.logError(
|
||||
"Invalid or missing cluster connection info/endpoints",
|
||||
"NotebookConfigurationUtils/configureServiceEndpoints"
|
||||
);
|
||||
return Promise.reject("Invalid or missing cluster connection info");
|
||||
}
|
||||
|
||||
const dataExplorer = window.dataExplorer as Explorer;
|
||||
const notebookEndpointInfo: DataModels.NotebookConfigurationEndpointInfo[] = clusterConnectionInfo.endpoints.map(
|
||||
clusterEndpoint => ({
|
||||
type: clusterEndpoint.kind.toLowerCase(),
|
||||
endpoint: clusterEndpoint && clusterEndpoint.endpoint,
|
||||
username: clusterConnectionInfo.userName,
|
||||
password: clusterConnectionInfo.password,
|
||||
token: dataExplorer && dataExplorer.arcadiaToken()
|
||||
})
|
||||
);
|
||||
const configurationEndpoints: DataModels.NotebookConfigurationEndpoints = {
|
||||
path: notebookPath,
|
||||
endpoints: notebookEndpointInfo
|
||||
};
|
||||
const kernelMetadata: KernelConnectionMetadata = {
|
||||
configurationEndpoints,
|
||||
notebookConnectionInfo,
|
||||
name: kernelName
|
||||
};
|
||||
|
||||
return await NotebookConfigurationUtils._configureServiceEndpoints(kernelMetadata);
|
||||
}
|
||||
|
||||
private static async _configureServiceEndpoints(kernelMetadata: KernelConnectionMetadata): Promise<void> {
|
||||
if (!kernelMetadata) {
|
||||
// should never get into this state
|
||||
Logger.logWarning("kernel metadata is null or undefined", "NotebookConfigurationUtils/configureServiceEndpoints");
|
||||
return;
|
||||
}
|
||||
|
||||
const notebookConnectionInfo = kernelMetadata.notebookConnectionInfo;
|
||||
const configurationEndpoints = kernelMetadata.configurationEndpoints;
|
||||
if (notebookConnectionInfo && configurationEndpoints) {
|
||||
try {
|
||||
const headers: any = { "Content-Type": "application/json" };
|
||||
if (notebookConnectionInfo.authToken) {
|
||||
headers["Authorization"] = `token ${notebookConnectionInfo.authToken}`;
|
||||
}
|
||||
const response = await fetch(`${notebookConnectionInfo.notebookServerEndpoint}/api/configureEndpoints`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(configurationEndpoints)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const responseMessage = await response.json();
|
||||
Logger.logError(
|
||||
JSON.stringify(responseMessage),
|
||||
"NotebookConfigurationUtils/configureServiceEndpoints",
|
||||
response.status
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.logError(error, "NotebookConfigurationUtils/configureServiceEndpoints");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/Utils/NotificationConsoleUtils.ts
Normal file
28
src/Utils/NotificationConsoleUtils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as _ from "underscore";
|
||||
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
|
||||
const _global = typeof self === "undefined" ? window : self;
|
||||
|
||||
export class NotificationConsoleUtils {
|
||||
public static logConsoleMessage(type: ConsoleDataType, message: string, id?: string): string {
|
||||
const dataExplorer = _global.dataExplorer;
|
||||
if (dataExplorer != null) {
|
||||
const date = new Date();
|
||||
const formattedDate: string = new Intl.DateTimeFormat("en-EN", {
|
||||
hour12: true,
|
||||
hour: "numeric",
|
||||
minute: "numeric"
|
||||
}).format(date);
|
||||
if (!id) {
|
||||
id = _.uniqueId();
|
||||
}
|
||||
dataExplorer.logConsoleData({ type: type, date: formattedDate, message: message, id: id });
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public static clearInProgressMessageWithId(id: string) {
|
||||
const dataExplorer = _global.dataExplorer;
|
||||
dataExplorer && (dataExplorer as any).deleteInProgressConsoleDataWithId(id);
|
||||
}
|
||||
}
|
||||
51
src/Utils/OfferUtils.test.ts
Normal file
51
src/Utils/OfferUtils.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as Constants from "../../src/Common/Constants";
|
||||
import * as DataModels from "../../src/Contracts/DataModels";
|
||||
import { OfferUtils } from "../../src/Utils/OfferUtils";
|
||||
|
||||
describe("OfferUtils tests", () => {
|
||||
const offerV1: DataModels.Offer = {
|
||||
_rid: "",
|
||||
_self: "",
|
||||
_ts: 0,
|
||||
_etag: "",
|
||||
id: "v1",
|
||||
offerVersion: Constants.OfferVersions.V1,
|
||||
offerType: "Standard",
|
||||
offerResourceId: "",
|
||||
content: null,
|
||||
resource: ""
|
||||
};
|
||||
|
||||
const offerV2: DataModels.Offer = {
|
||||
_rid: "",
|
||||
_self: "",
|
||||
_ts: 0,
|
||||
_etag: "",
|
||||
id: "v1",
|
||||
offerVersion: Constants.OfferVersions.V2,
|
||||
offerType: "Standard",
|
||||
offerResourceId: "",
|
||||
content: null,
|
||||
resource: ""
|
||||
};
|
||||
|
||||
describe("isOfferV1()", () => {
|
||||
it("should return true for V1", () => {
|
||||
expect(OfferUtils.isOfferV1(offerV1)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false for V2", () => {
|
||||
expect(OfferUtils.isOfferV1(offerV2)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNotOfferV1()", () => {
|
||||
it("should return true for V2", () => {
|
||||
expect(OfferUtils.isNotOfferV1(offerV2)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false for V1", () => {
|
||||
expect(OfferUtils.isNotOfferV1(offerV1)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
12
src/Utils/OfferUtils.ts
Normal file
12
src/Utils/OfferUtils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as Constants from "../Common/Constants";
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
|
||||
export class OfferUtils {
|
||||
public static isOfferV1(offer: DataModels.Offer): boolean {
|
||||
return !offer || offer.offerVersion !== Constants.OfferVersions.V2;
|
||||
}
|
||||
|
||||
public static isNotOfferV1(offer: DataModels.Offer): boolean {
|
||||
return !OfferUtils.isOfferV1(offer);
|
||||
}
|
||||
}
|
||||
386
src/Utils/PricingUtils.test.ts
Normal file
386
src/Utils/PricingUtils.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import * as PricingUtils from "./PricingUtils";
|
||||
|
||||
describe("PricingUtils Tests", () => {
|
||||
describe("isLargerThanDefaultMinRU()", () => {
|
||||
it("should return true if passed number is larger than default min RU", () => {
|
||||
const value = PricingUtils.isLargerThanDefaultMinRU(2000);
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if passed number is smaller than default min RU", () => {
|
||||
const value = PricingUtils.isLargerThanDefaultMinRU(200);
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if passed number is negative", () => {
|
||||
const value = PricingUtils.isLargerThanDefaultMinRU(-1);
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if passed number is not number", () => {
|
||||
const value = PricingUtils.isLargerThanDefaultMinRU(null);
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRUUsagePriceHourly()", () => {
|
||||
it("should return 0 for NaN regions default cloud", () => {
|
||||
const value = PricingUtils.computeRUUsagePriceHourly("default", false, 1, null, false);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for -1 regions", () => {
|
||||
const value = PricingUtils.computeRUUsagePriceHourly("default", false, 1, -1, false);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0.00008 for default cloud, rupm disabled, 1RU, 1 region, multimaster disabled", () => {
|
||||
const value = PricingUtils.computeRUUsagePriceHourly("default", false, 1, 1, false);
|
||||
expect(value).toBe(0.00008);
|
||||
});
|
||||
|
||||
it("should return 0.00051 for Mooncake cloud, rupm disabled, 1RU, 1 region, multimaster disabled", () => {
|
||||
const value = PricingUtils.computeRUUsagePriceHourly("mooncake", false, 1, 1, false);
|
||||
expect(value).toBe(0.00051);
|
||||
});
|
||||
|
||||
it("should return 0.00016 for default cloud, rupm disabled, 1RU, 2 regions, multimaster disabled", () => {
|
||||
const value = PricingUtils.computeRUUsagePriceHourly("default", false, 1, 2, false);
|
||||
expect(value).toBe(0.00016);
|
||||
});
|
||||
|
||||
it("should return 0.00008 for default cloud, rupm disabled, 1RU, 1 region, multimaster enabled", () => {
|
||||
const value = PricingUtils.computeRUUsagePriceHourly("default", false, 1, 1, true);
|
||||
expect(value).toBe(0.00008);
|
||||
});
|
||||
|
||||
it("should return 0.00048 for default cloud, rupm disabled, 1RU, 2 region, multimaster enabled", () => {
|
||||
const value = PricingUtils.computeRUUsagePriceHourly("default", false, 1, 2, true);
|
||||
expect(value).toBe(0.00048);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPriceCurrency()", () => {
|
||||
it("should return USD by default", () => {
|
||||
const value = PricingUtils.getPriceCurrency("default");
|
||||
expect(value).toBe("USD");
|
||||
});
|
||||
|
||||
it("should return RMB for Mooncake", () => {
|
||||
const value = PricingUtils.getPriceCurrency("mooncake");
|
||||
expect(value).toBe("RMB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeStorageUsagePrice()", () => {
|
||||
it("should return 0.0 USD for default, 0Gib", () => {
|
||||
const value = PricingUtils.computeStorageUsagePrice("default", 0);
|
||||
expect(value).toBe("0.0 USD");
|
||||
});
|
||||
|
||||
it("should return 0.0 USD for default cloud, 1Gib", () => {
|
||||
const value = PricingUtils.computeStorageUsagePrice("default", 1);
|
||||
expect(value).toBe("0.00034 USD");
|
||||
});
|
||||
|
||||
it("should return 0.0 RMB for Mooncake, 0", () => {
|
||||
const value = PricingUtils.computeStorageUsagePrice("mooncake", 0);
|
||||
expect(value).toBe("0.0 RMB");
|
||||
});
|
||||
|
||||
it("should return 0.035 RMB for Mooncake, 1", () => {
|
||||
const value = PricingUtils.computeStorageUsagePrice("mooncake", 1);
|
||||
expect(value).toBe("0.0035 RMB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEstimateNumber()", () => {
|
||||
it("should return '0.0060' for 0.006", () => {
|
||||
const value = PricingUtils.calculateEstimateNumber(0.006);
|
||||
expect(value).toBe("0.0060");
|
||||
});
|
||||
|
||||
it("should return '0.010' for 0.01", () => {
|
||||
const value = PricingUtils.calculateEstimateNumber(0.01);
|
||||
expect(value).toBe("0.010");
|
||||
});
|
||||
|
||||
it("should return '0.10' for 0.1", () => {
|
||||
const value = PricingUtils.calculateEstimateNumber(0.1);
|
||||
expect(value).toBe("0.10");
|
||||
});
|
||||
|
||||
it("should return '1.00' for 1", () => {
|
||||
const value = PricingUtils.calculateEstimateNumber(1);
|
||||
expect(value).toBe("1.00");
|
||||
});
|
||||
|
||||
it("should return '11.00' for 11", () => {
|
||||
const value = PricingUtils.calculateEstimateNumber(11);
|
||||
expect(value).toBe("11.00");
|
||||
});
|
||||
|
||||
it("should return '1.10' for 1.1", () => {
|
||||
const value = PricingUtils.calculateEstimateNumber(1.1);
|
||||
expect(value).toBe("1.10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrencySign()", () => {
|
||||
it("should return '$' for default clouds", () => {
|
||||
const value = PricingUtils.getCurrencySign("default");
|
||||
expect(value).toBe("$");
|
||||
});
|
||||
|
||||
it("should return '¥' for mooncake", () => {
|
||||
const value = PricingUtils.getCurrencySign("mooncake");
|
||||
expect(value).toBe("¥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPricePerRu()", () => {
|
||||
it("should return 0.00008 for default clouds", () => {
|
||||
const value = PricingUtils.getPricePerRu("default");
|
||||
expect(value).toBe(0.00008);
|
||||
});
|
||||
|
||||
it("should return 0.00051 for mooncake", () => {
|
||||
const value = PricingUtils.getPricePerRu("mooncake");
|
||||
expect(value).toBe(0.00051);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPricePerRuPm()", () => {
|
||||
it("should return 0.000027397260273972603 for default clouds", () => {
|
||||
const value = PricingUtils.getPricePerRuPm("default");
|
||||
expect(value).toBe(0.000027397260273972603);
|
||||
});
|
||||
|
||||
it("should return 0.00027397260273972606 for mooncake", () => {
|
||||
const value = PricingUtils.getPricePerRuPm("mooncake");
|
||||
expect(value).toBe(0.00027397260273972606);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRegionMultiplier()", () => {
|
||||
describe("without multimaster", () => {
|
||||
it("should return 0 for null", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(null, false);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for undefined", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(undefined, false);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for -1", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(-1, false);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for 0", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(0, false);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 1 for 1", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(1, false);
|
||||
expect(value).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 2 for 2", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(2, false);
|
||||
expect(value).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with multimaster", () => {
|
||||
it("should return 0 for null", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(null, true);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for undefined", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(undefined, true);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for -1", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(-1, true);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for 0", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(0, true);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 1 for 1", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(1, true);
|
||||
expect(value).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 3 for 2", () => {
|
||||
const value = PricingUtils.getRegionMultiplier(2, true);
|
||||
expect(value).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMultimasterMultiplier()", () => {
|
||||
it("should return 1 for multimaster disabled", () => {
|
||||
const value = PricingUtils.getMultimasterMultiplier(1, false);
|
||||
expect(value).toBe(1);
|
||||
|
||||
const value2 = PricingUtils.getMultimasterMultiplier(2, false);
|
||||
expect(value2).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 1 for multimaster enabled with 1 region", () => {
|
||||
const value = PricingUtils.getMultimasterMultiplier(1, true);
|
||||
expect(value).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 2 for multimaster enabled with 2 regions", () => {
|
||||
const value = PricingUtils.getMultimasterMultiplier(2, true);
|
||||
expect(value).toBe(2);
|
||||
});
|
||||
|
||||
it("should return 2 for multimaster enabled with 3 regions", () => {
|
||||
const value = PricingUtils.getMultimasterMultiplier(3, true);
|
||||
expect(value).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEstimatedSpendHtml()", () => {
|
||||
it("should return 'Estimated cost (USD): <b>$0.000080 hourly / $0.0019 daily / $0.058 monthly </b> (1 region, 1RU/s, $0.00008/RU)' for 1RU/s on default cloud, 1 region, with multimaster, and no rupm", () => {
|
||||
const value = PricingUtils.getEstimatedSpendHtml(
|
||||
1 /*RU/s*/,
|
||||
"default" /* cloud */,
|
||||
1 /* region */,
|
||||
true /* multimaster */,
|
||||
false /* rupm */
|
||||
);
|
||||
expect(value).toBe(
|
||||
"Estimated cost (USD): <b>$0.000080 hourly / $0.0019 daily / $0.058 monthly </b> (1 region, 1RU/s, $0.00008/RU)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 'Estimated cost (RMB): <b>¥0.00051 hourly / ¥0.012 daily / ¥0.37 monthly </b> (1 region, 1RU/s, ¥0.00051/RU)' for 1RU/s on mooncake, 1 region, with multimaster, and no rupm", () => {
|
||||
const value = PricingUtils.getEstimatedSpendHtml(
|
||||
1 /*RU/s*/,
|
||||
"mooncake" /* cloud */,
|
||||
1 /* region */,
|
||||
true /* multimaster */,
|
||||
false /* rupm */
|
||||
);
|
||||
expect(value).toBe(
|
||||
"Estimated cost (RMB): <b>¥0.00051 hourly / ¥0.012 daily / ¥0.37 monthly </b> (1 region, 1RU/s, ¥0.00051/RU)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 'Estimated cost (USD): <b>$0.13 hourly / $3.07 daily / $140.16 monthly </b> (2 regions, 400RU/s, $0.00016/RU)' for 400RU/s on default cloud, 2 region, with multimaster, and no rupm", () => {
|
||||
const value = PricingUtils.getEstimatedSpendHtml(
|
||||
400 /*RU/s*/,
|
||||
"default" /* cloud */,
|
||||
2 /* region */,
|
||||
true /* multimaster */,
|
||||
false /* rupm */
|
||||
);
|
||||
expect(value).toBe(
|
||||
"Estimated cost (USD): <b>$0.19 hourly / $4.61 daily / $140.16 monthly </b> (2 regions, 400RU/s, $0.00016/RU)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 'Estimated cost (USD): <b>$0.064 hourly / $1.54 daily / $46.72 monthly </b> (2 regions, 400RU/s, $0.00008/RU)' for 400RU/s on default cloud, 2 region, without multimaster, and no rupm", () => {
|
||||
const value = PricingUtils.getEstimatedSpendHtml(
|
||||
400 /*RU/s*/,
|
||||
"default" /* cloud */,
|
||||
2 /* region */,
|
||||
false /* multimaster */,
|
||||
false /* rupm */
|
||||
);
|
||||
expect(value).toBe(
|
||||
"Estimated cost (USD): <b>$0.064 hourly / $1.54 daily / $46.72 monthly </b> (2 regions, 400RU/s, $0.00008/RU)"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEstimatedSpendAcknowledgeString()", () => {
|
||||
it("should return 'I acknowledge the estimated $0.0019 daily cost for the throughput above.' for 1RU/s on default cloud, 1 region, with multimaster, and no rupm", () => {
|
||||
const value = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
1 /*RU/s*/,
|
||||
"default" /* cloud */,
|
||||
1 /* region */,
|
||||
true /* multimaster */,
|
||||
false /* rupm */,
|
||||
false
|
||||
);
|
||||
expect(value).toBe("I acknowledge the estimated $0.0019 daily cost for the throughput above.");
|
||||
});
|
||||
|
||||
it("should return 'I acknowledge the estimated ¥0.012 daily cost for the throughput above.' for 1RU/s on mooncake, 1 region, with multimaster, and no rupm", () => {
|
||||
const value = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
1 /*RU/s*/,
|
||||
"mooncake" /* cloud */,
|
||||
1 /* region */,
|
||||
true /* multimaster */,
|
||||
false /* rupm */,
|
||||
false
|
||||
);
|
||||
expect(value).toBe("I acknowledge the estimated ¥0.012 daily cost for the throughput above.");
|
||||
});
|
||||
|
||||
it("should return 'I acknowledge the estimated $3.07 daily cost for the throughput above.' for 400RU/s on default cloud, 2 region, with multimaster, and no rupm", () => {
|
||||
const value = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
400 /*RU/s*/,
|
||||
"default" /* cloud */,
|
||||
2 /* region */,
|
||||
true /* multimaster */,
|
||||
false /* rupm */,
|
||||
false
|
||||
);
|
||||
expect(value).toBe("I acknowledge the estimated $4.61 daily cost for the throughput above.");
|
||||
});
|
||||
|
||||
it("should return 'I acknowledge the estimated $1.54 daily cost for the throughput above.' for 400RU/s on default cloud, 2 region, without multimaster, and no rupm", () => {
|
||||
const value = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
400 /*RU/s*/,
|
||||
"default" /* cloud */,
|
||||
2 /* region */,
|
||||
false /* multimaster */,
|
||||
false /* rupm */,
|
||||
false
|
||||
);
|
||||
expect(value).toBe("I acknowledge the estimated $1.54 daily cost for the throughput above.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeNumberOfRegions()", () => {
|
||||
it("should return 0 for null", () => {
|
||||
const value = PricingUtils.normalizeNumber(null);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for undefined", () => {
|
||||
const value = PricingUtils.normalizeNumber(undefined);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 1 for '1'", () => {
|
||||
const value = PricingUtils.normalizeNumber("1");
|
||||
expect(value).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 1 for -1", () => {
|
||||
const value = PricingUtils.normalizeNumber(-1);
|
||||
expect(value).toBe(-1);
|
||||
});
|
||||
|
||||
it("should return 1 for 0.1", () => {
|
||||
const value = PricingUtils.normalizeNumber(0.1);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
290
src/Utils/PricingUtils.ts
Normal file
290
src/Utils/PricingUtils.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import * as AutoPilotUtils from "../Utils/AutoPilotUtils";
|
||||
import * as Constants from "../Shared/Constants";
|
||||
import { AutopilotTier } from "../Contracts/DataModels";
|
||||
|
||||
/**
|
||||
* Anything that is not a number should return 0
|
||||
* Otherwise, return numberOfRegions
|
||||
* @param number
|
||||
*/
|
||||
export function normalizeNumber(number: any): number {
|
||||
const normalizedNumber: number = number === null ? 0 : isNaN(number) ? 0 : parseInt(number);
|
||||
|
||||
return normalizedNumber;
|
||||
}
|
||||
|
||||
export function getRuToolTipText(isV2AutoPilot: boolean): string {
|
||||
if (isV2AutoPilot) {
|
||||
return "Provisioned throughput is measured in Request Units per second (RU/s). 1 RU corresponds to the throughput of a read of a 1 KB document.";
|
||||
}
|
||||
return `Set the throughput — Request Units per second (RU/s) — required for the workload. A read of a 1 KB document uses 1 RU. Select manual if you plan to scale RU/s yourself. Select autoscale to allow the system to scale RU/s based on usage.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For anything other than a numbers or numbers <= 0, should return 0
|
||||
* Otherwise, return numberOfRegions
|
||||
* @param numberOfRegions
|
||||
*/
|
||||
export function getRegionMultiplier(numberOfRegions: number, multimasterEnabled: boolean): number {
|
||||
const normalizedNumberOfRegions: number = normalizeNumber(numberOfRegions);
|
||||
|
||||
if (normalizedNumberOfRegions <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (numberOfRegions === 1) {
|
||||
return numberOfRegions;
|
||||
}
|
||||
|
||||
if (multimasterEnabled) {
|
||||
return numberOfRegions + 1;
|
||||
}
|
||||
|
||||
return numberOfRegions;
|
||||
}
|
||||
|
||||
export function getMultimasterMultiplier(numberOfRegions: number, multimasterEnabled: boolean): number {
|
||||
const regionMultiplier: number = getRegionMultiplier(numberOfRegions, multimasterEnabled);
|
||||
const multimasterMultiplier: number = !multimasterEnabled ? 1 : regionMultiplier > 1 ? 2 : 1;
|
||||
|
||||
return multimasterMultiplier;
|
||||
}
|
||||
|
||||
export function computeRUUsagePriceHourly(
|
||||
serverId: string,
|
||||
rupmEnabled: boolean,
|
||||
requestUnits: number,
|
||||
numberOfRegions: number,
|
||||
multimasterEnabled: boolean
|
||||
): number {
|
||||
const regionMultiplier: number = getRegionMultiplier(numberOfRegions, multimasterEnabled);
|
||||
const multimasterMultiplier: number = getMultimasterMultiplier(numberOfRegions, multimasterEnabled);
|
||||
|
||||
const pricePerRu = getPricePerRu(serverId);
|
||||
const pricePerRuPm = getPricePerRuPm(serverId);
|
||||
|
||||
const ruCharge = requestUnits * pricePerRu * multimasterMultiplier * regionMultiplier;
|
||||
const rupmCharge = rupmEnabled ? requestUnits * pricePerRuPm : 0;
|
||||
|
||||
return Number((ruCharge + rupmCharge).toFixed(5));
|
||||
}
|
||||
|
||||
export function getPriceCurrency(serverId: string): string {
|
||||
if (serverId === "mooncake") {
|
||||
return Constants.OfferPricing.HourlyPricing.mooncake.Currency;
|
||||
}
|
||||
|
||||
return Constants.OfferPricing.HourlyPricing.default.Currency;
|
||||
}
|
||||
|
||||
export function computeStorageUsagePrice(serverId: string, storageUsedRoundUpToGB: number): string {
|
||||
if (serverId === "mooncake") {
|
||||
let storageCharge = storageUsedRoundUpToGB * Constants.OfferPricing.HourlyPricing.mooncake.Standard.PricePerGB;
|
||||
return calculateEstimateNumber(storageCharge) + " " + Constants.OfferPricing.HourlyPricing.mooncake.Currency;
|
||||
}
|
||||
|
||||
let storageCharge = storageUsedRoundUpToGB * Constants.OfferPricing.HourlyPricing.default.Standard.PricePerGB;
|
||||
return calculateEstimateNumber(storageCharge) + " " + Constants.OfferPricing.HourlyPricing.default.Currency;
|
||||
}
|
||||
|
||||
export function computeDisplayUsageString(usageInKB: number): string {
|
||||
let usageInMB = usageInKB / 1024,
|
||||
usageInGB = usageInMB / 1024,
|
||||
displayUsageString =
|
||||
usageInGB > 0.1
|
||||
? usageInGB.toFixed(2) + " GB"
|
||||
: usageInMB > 0.1
|
||||
? usageInMB.toFixed(2) + " MB"
|
||||
: usageInKB.toFixed(2) + " KB";
|
||||
return displayUsageString;
|
||||
}
|
||||
|
||||
export function usageInGB(usageInKB: number): number {
|
||||
let usageInMB = usageInKB / 1024,
|
||||
usageInGB = usageInMB / 1024;
|
||||
return Math.ceil(usageInGB);
|
||||
}
|
||||
|
||||
export function calculateEstimateNumber(n: number): string {
|
||||
return n >= 1 ? n.toFixed(2) : n.toPrecision(2);
|
||||
}
|
||||
|
||||
export function numberWithCommasFormatter(n: number) {
|
||||
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
export function isLargerThanDefaultMinRU(ru: number): boolean {
|
||||
if (typeof ru === "number" && ru > Constants.CollectionCreation.DefaultCollectionRUs400) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getCurrencySign(serverId: string): string {
|
||||
if (serverId === "mooncake") {
|
||||
return Constants.OfferPricing.HourlyPricing.mooncake.CurrencySign;
|
||||
}
|
||||
|
||||
return Constants.OfferPricing.HourlyPricing.default.CurrencySign;
|
||||
}
|
||||
|
||||
export function getAutoscalePricePerRu(serverId: string, mmMultiplier: number): number {
|
||||
if (serverId === "mooncake") {
|
||||
if (mmMultiplier > 1) {
|
||||
return Constants.AutoscalePricing.HourlyPricing.mooncake.multiMaster.Standard.PricePerRU;
|
||||
} else {
|
||||
return Constants.AutoscalePricing.HourlyPricing.mooncake.singleMaster.Standard.PricePerRU;
|
||||
}
|
||||
}
|
||||
|
||||
if (mmMultiplier > 1) {
|
||||
return Constants.AutoscalePricing.HourlyPricing.default.multiMaster.Standard.PricePerRU;
|
||||
} else {
|
||||
return Constants.AutoscalePricing.HourlyPricing.default.singleMaster.Standard.PricePerRU;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPricePerRu(serverId: string): number {
|
||||
if (serverId === "mooncake") {
|
||||
return Constants.OfferPricing.HourlyPricing.mooncake.Standard.PricePerRU;
|
||||
}
|
||||
|
||||
return Constants.OfferPricing.HourlyPricing.default.Standard.PricePerRU;
|
||||
}
|
||||
|
||||
export function getPricePerRuPm(serverId: string): number {
|
||||
if (serverId === "mooncake") {
|
||||
return Constants.OfferPricing.HourlyPricing.mooncake.Standard.PricePerRUPM;
|
||||
}
|
||||
|
||||
return Constants.OfferPricing.HourlyPricing.default.Standard.PricePerRUPM;
|
||||
}
|
||||
|
||||
export function getAutoPilotV2SpendHtml(autoPilotTier: AutopilotTier, isDatabaseThroughput: boolean): string {
|
||||
if (!autoPilotTier) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const resource: string = isDatabaseThroughput ? "database" : "container";
|
||||
switch (autoPilotTier) {
|
||||
case AutopilotTier.Tier1:
|
||||
return `Your ${resource} throughput will automatically scale between 400 RU/s and 4,000 RU/s based on the workload needs, as long as your storage does not exceed 50GB. If your storage exceeds 50GB, we will upgrade the maximum (and minimum) throughput thresholds to the next available value. For more details, see <a href='${Constants.AutopilotDocumentation.Url}' target='_blank'>documentation</a>.`;
|
||||
case AutopilotTier.Tier2:
|
||||
return `Your ${resource} throughput will automatically scale between 2,000 RU/s and 20,000 RU/s based on the workload needs, as long as your storage does not exceed 200GB. If your storage exceeds 200GB, we will upgrade the maximum (and minimum) throughput thresholds to the next available value. For more details, see <a href='${Constants.AutopilotDocumentation.Url}' target='_blank'>documentation</a>.`;
|
||||
case AutopilotTier.Tier3:
|
||||
return `Your ${resource} throughput will automatically scale between 10,000 RU/s and 100,000 RU/s based on the workload needs, as long as your storage does not exceed 1TB. If your storage exceeds 1TB, we will upgrade the maximum (and minimum) throughput thresholds to the next available value. For more details, see <a href='${Constants.AutopilotDocumentation.Url}' target='_blank'>documentation</a>.`;
|
||||
case AutopilotTier.Tier4:
|
||||
return `Your ${resource} throughput will automatically scale between 50,000 RU/s and 500,000 RU/s based on the workload needs, as long as your storage does not exceed 5TB. If your storage exceeds 5TB, we will upgrade the maximum (and minimum) throughput thresholds to the next available value. For more details, see <a href='${Constants.AutopilotDocumentation.Url}' target='_blank'>documentation</a>.`;
|
||||
default:
|
||||
return `Your ${resource} throughput will automatically scale based on the workload needs.`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAutoPilotV3SpendHtml(maxAutoPilotThroughputSet: number, isDatabaseThroughput: boolean): string {
|
||||
if (!maxAutoPilotThroughputSet) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const resource: string = isDatabaseThroughput ? "database" : "container";
|
||||
return `Your ${resource} throughput will automatically scale from <b>${AutoPilotUtils.getMinRUsBasedOnUserInput(
|
||||
maxAutoPilotThroughputSet
|
||||
)} RU/s (10% of max RU/s) - ${maxAutoPilotThroughputSet} RU/s</b> based on usage. <br /><br />After the first ${AutoPilotUtils.getStorageBasedOnUserInput(
|
||||
maxAutoPilotThroughputSet
|
||||
)} GB of data stored, the max RU/s will be automatically upgraded based on the new storage value. <a href='${
|
||||
Constants.AutopilotDocumentation.Url
|
||||
}' target='_blank'>Learn more</a>.`;
|
||||
}
|
||||
|
||||
export function computeAutoscaleUsagePriceHourly(
|
||||
serverId: string,
|
||||
requestUnits: number,
|
||||
numberOfRegions: number,
|
||||
multimasterEnabled: boolean
|
||||
): number {
|
||||
const regionMultiplier: number = getRegionMultiplier(numberOfRegions, multimasterEnabled);
|
||||
const multimasterMultiplier: number = getMultimasterMultiplier(numberOfRegions, multimasterEnabled);
|
||||
|
||||
const pricePerRu = getAutoscalePricePerRu(serverId, multimasterMultiplier);
|
||||
const ruCharge = requestUnits * pricePerRu * multimasterMultiplier * regionMultiplier;
|
||||
|
||||
return Number(ruCharge.toFixed(5));
|
||||
}
|
||||
|
||||
export function getEstimatedAutoscaleSpendHtml(
|
||||
throughput: number,
|
||||
serverId: string,
|
||||
regions: number,
|
||||
multimaster: boolean
|
||||
): string {
|
||||
const hourlyPrice: number = computeAutoscaleUsagePriceHourly(serverId, throughput, regions, multimaster);
|
||||
const monthlyPrice: number = hourlyPrice * Constants.hoursInAMonth;
|
||||
const currency: string = getPriceCurrency(serverId);
|
||||
const currencySign: string = getCurrencySign(serverId);
|
||||
const pricePerRu =
|
||||
getAutoscalePricePerRu(serverId, getMultimasterMultiplier(regions, multimaster)) *
|
||||
getMultimasterMultiplier(regions, multimaster);
|
||||
|
||||
return (
|
||||
`Estimated monthly cost (${currency}): <b>` +
|
||||
`${currencySign}${calculateEstimateNumber(monthlyPrice / 10)} - ` +
|
||||
`${currencySign}${calculateEstimateNumber(monthlyPrice)} </b> ` +
|
||||
`(${regions} ${regions === 1 ? "region" : "regions"}, ${throughput /
|
||||
10} - ${throughput} RU/s, ${currencySign}${pricePerRu}/RU)`
|
||||
);
|
||||
}
|
||||
|
||||
export function getEstimatedSpendHtml(
|
||||
throughput: number,
|
||||
serverId: string,
|
||||
regions: number,
|
||||
multimaster: boolean,
|
||||
rupmEnabled: boolean
|
||||
): string {
|
||||
const hourlyPrice: number = computeRUUsagePriceHourly(serverId, rupmEnabled, throughput, regions, multimaster);
|
||||
const dailyPrice: number = hourlyPrice * 24;
|
||||
const monthlyPrice: number = hourlyPrice * Constants.hoursInAMonth;
|
||||
const currency: string = getPriceCurrency(serverId);
|
||||
const currencySign: string = getCurrencySign(serverId);
|
||||
const pricePerRu = getPricePerRu(serverId) * getMultimasterMultiplier(regions, multimaster);
|
||||
|
||||
return (
|
||||
`Estimated cost (${currency}): <b>` +
|
||||
`${currencySign}${calculateEstimateNumber(hourlyPrice)} hourly / ` +
|
||||
`${currencySign}${calculateEstimateNumber(dailyPrice)} daily / ` +
|
||||
`${currencySign}${calculateEstimateNumber(monthlyPrice)} monthly </b> ` +
|
||||
`(${regions} ${regions === 1 ? "region" : "regions"}, ${throughput}RU/s, ${currencySign}${pricePerRu}/RU)`
|
||||
);
|
||||
}
|
||||
|
||||
export function getEstimatedSpendAcknowledgeString(
|
||||
throughput: number,
|
||||
serverId: string,
|
||||
regions: number,
|
||||
multimaster: boolean,
|
||||
rupmEnabled: boolean,
|
||||
isAutoscale: boolean
|
||||
): string {
|
||||
const hourlyPrice: number = isAutoscale
|
||||
? computeAutoscaleUsagePriceHourly(serverId, throughput, regions, multimaster)
|
||||
: computeRUUsagePriceHourly(serverId, rupmEnabled, throughput, regions, multimaster);
|
||||
const dailyPrice: number = hourlyPrice * 24;
|
||||
const monthlyPrice: number = hourlyPrice * Constants.hoursInAMonth;
|
||||
const currencySign: string = getCurrencySign(serverId);
|
||||
return !isAutoscale
|
||||
? `I acknowledge the estimated ${currencySign}${calculateEstimateNumber(
|
||||
dailyPrice
|
||||
)} daily cost for the throughput above.`
|
||||
: `I acknowledge the estimated ${currencySign}${calculateEstimateNumber(
|
||||
monthlyPrice / 10
|
||||
)} - ${currencySign}${calculateEstimateNumber(monthlyPrice)} monthly cost for the throughput above.`;
|
||||
}
|
||||
|
||||
export function getUpsellMessage(serverId: string = "default"): string {
|
||||
let price: number = Constants.OfferPricing.MonthlyPricing.default.Standard.StartingPrice;
|
||||
|
||||
if (serverId === "mooncake") {
|
||||
price = Constants.OfferPricing.MonthlyPricing.mooncake.Standard.StartingPrice;
|
||||
}
|
||||
|
||||
return `Start at ${getCurrencySign(serverId)}${price}/mo per database, multiple containers included`;
|
||||
}
|
||||
187
src/Utils/QueryUtils.test.ts
Normal file
187
src/Utils/QueryUtils.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import * as Q from "q";
|
||||
import * as sinon from "sinon";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import { QueryUtils } from "./QueryUtils";
|
||||
|
||||
describe("Query Utils", () => {
|
||||
function generatePartitionKeyForPath(path: string): DataModels.PartitionKey {
|
||||
return {
|
||||
paths: [path],
|
||||
kind: "hash",
|
||||
version: 2
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDocumentsQueryPartitionProjections()", () => {
|
||||
it("should return empty string if partition key is undefined", () => {
|
||||
expect(QueryUtils.buildDocumentsQueryPartitionProjections("c", undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string if partition key is null", () => {
|
||||
expect(QueryUtils.buildDocumentsQueryPartitionProjections("c", null)).toBe("");
|
||||
});
|
||||
|
||||
it("should replace slashes and embed projection in square braces", () => {
|
||||
const partitionKey: DataModels.PartitionKey = generatePartitionKeyForPath("/a");
|
||||
const partitionProjection: string = QueryUtils.buildDocumentsQueryPartitionProjections("c", partitionKey);
|
||||
|
||||
expect(partitionProjection).toContain('c["a"]');
|
||||
});
|
||||
|
||||
it("should embed multiple projections in individual square braces", () => {
|
||||
const partitionKey: DataModels.PartitionKey = generatePartitionKeyForPath("/a/b");
|
||||
const partitionProjection: string = QueryUtils.buildDocumentsQueryPartitionProjections("c", partitionKey);
|
||||
|
||||
expect(partitionProjection).toContain('c["a"]["b"]');
|
||||
});
|
||||
|
||||
it("should not escape double quotes if partition key definition does not have single quote prefix", () => {
|
||||
const partitionKey: DataModels.PartitionKey = generatePartitionKeyForPath('/"a"');
|
||||
const partitionProjection: string = QueryUtils.buildDocumentsQueryPartitionProjections("c", partitionKey);
|
||||
|
||||
expect(partitionProjection).toContain('c["a"]');
|
||||
});
|
||||
|
||||
it("should escape single quotes", () => {
|
||||
const partitionKey: DataModels.PartitionKey = generatePartitionKeyForPath("/'\"a\"'");
|
||||
const partitionProjection: string = QueryUtils.buildDocumentsQueryPartitionProjections("c", partitionKey);
|
||||
|
||||
expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]');
|
||||
});
|
||||
|
||||
it("should escape double quotes if partition key definition has single quote prefix", () => {
|
||||
const partitionKey: DataModels.PartitionKey = generatePartitionKeyForPath("/'\\\"a\\\"'");
|
||||
const partitionProjection: string = QueryUtils.buildDocumentsQueryPartitionProjections("c", partitionKey);
|
||||
|
||||
expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]');
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryPagesUntilContentPresent()", () => {
|
||||
const queryResultWithItemsInPage: ViewModels.QueryResults = {
|
||||
documents: [{ a: "123" }],
|
||||
activityId: "123",
|
||||
requestCharge: 1,
|
||||
hasMoreResults: false,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 1,
|
||||
itemCount: 1
|
||||
};
|
||||
const queryResultWithNoItemsInPage: ViewModels.QueryResults = {
|
||||
documents: [],
|
||||
activityId: "123",
|
||||
requestCharge: 1,
|
||||
hasMoreResults: true,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 0,
|
||||
itemCount: 0
|
||||
};
|
||||
|
||||
it("should perform multiple queries until it finds a page that has items", async () => {
|
||||
const queryStub = sinon
|
||||
.stub()
|
||||
.onFirstCall()
|
||||
.returns(Q.resolve(queryResultWithNoItemsInPage))
|
||||
.returns(Q.resolve(queryResultWithItemsInPage));
|
||||
|
||||
await QueryUtils.queryPagesUntilContentPresent(0, queryStub);
|
||||
expect(queryStub.callCount).toBe(2);
|
||||
expect(queryStub.getCall(0).args[0]).toBe(0);
|
||||
expect(queryStub.getCall(1).args[0]).toBe(0);
|
||||
});
|
||||
|
||||
it("should not perform multiple queries if the first page of results has items", done => {
|
||||
const queryStub = sinon.stub().returns(Q.resolve(queryResultWithItemsInPage));
|
||||
QueryUtils.queryPagesUntilContentPresent(0, queryStub).finally(() => {
|
||||
expect(queryStub.callCount).toBe(1);
|
||||
expect(queryStub.getCall(0).args[0]).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not proceed with subsequent queries if the first one errors out", done => {
|
||||
const queryStub = sinon.stub().returns(Q.reject("Error injected for testing purposes"));
|
||||
QueryUtils.queryPagesUntilContentPresent(0, queryStub).finally(() => {
|
||||
expect(queryStub.callCount).toBe(1);
|
||||
expect(queryStub.getCall(0).args[0]).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryAllPages()", () => {
|
||||
const queryResultWithNoContinuation: ViewModels.QueryResults = {
|
||||
documents: [{ a: "123" }],
|
||||
activityId: "123",
|
||||
requestCharge: 1,
|
||||
hasMoreResults: false,
|
||||
firstItemIndex: 1,
|
||||
lastItemIndex: 1,
|
||||
itemCount: 1
|
||||
};
|
||||
const queryResultWithContinuation: ViewModels.QueryResults = {
|
||||
documents: [{ b: "123" }],
|
||||
activityId: "123",
|
||||
requestCharge: 1,
|
||||
hasMoreResults: true,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 0,
|
||||
itemCount: 1
|
||||
};
|
||||
|
||||
it("should follow continuation token to fetch all pages", done => {
|
||||
const queryStub = sinon
|
||||
.stub()
|
||||
.onFirstCall()
|
||||
.returns(Q.resolve(queryResultWithContinuation))
|
||||
.returns(Q.resolve(queryResultWithNoContinuation));
|
||||
QueryUtils.queryAllPages(queryStub).then(
|
||||
(results: ViewModels.QueryResults) => {
|
||||
expect(queryStub.callCount).toBe(2);
|
||||
expect(queryStub.getCall(0).args[0]).toBe(0);
|
||||
expect(queryStub.getCall(1).args[0]).toBe(1);
|
||||
expect(results.itemCount).toBe(
|
||||
queryResultWithContinuation.documents.length + queryResultWithNoContinuation.documents.length
|
||||
);
|
||||
expect(results.requestCharge).toBe(
|
||||
queryResultWithContinuation.requestCharge + queryResultWithNoContinuation.requestCharge
|
||||
);
|
||||
expect(results.documents).toEqual(
|
||||
queryResultWithContinuation.documents.concat(queryResultWithNoContinuation.documents)
|
||||
);
|
||||
done();
|
||||
},
|
||||
(error: any) => {
|
||||
fail(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should not perform subsequent fetches when result has no continuation", done => {
|
||||
const queryStub = sinon.stub().returns(Q.resolve(queryResultWithNoContinuation));
|
||||
QueryUtils.queryAllPages(queryStub).then(
|
||||
(results: ViewModels.QueryResults) => {
|
||||
expect(queryStub.callCount).toBe(1);
|
||||
expect(queryStub.getCall(0).args[0]).toBe(0);
|
||||
expect(results.itemCount).toBe(queryResultWithNoContinuation.documents.length);
|
||||
expect(results.requestCharge).toBe(queryResultWithNoContinuation.requestCharge);
|
||||
expect(results.documents).toEqual(queryResultWithNoContinuation.documents);
|
||||
done();
|
||||
},
|
||||
(error: any) => {
|
||||
fail(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should not proceed with subsequent fetches if the first one errors out", done => {
|
||||
const queryStub = sinon.stub().returns(Q.reject("Error injected for testing purposes"));
|
||||
QueryUtils.queryAllPages(queryStub).finally(() => {
|
||||
expect(queryStub.callCount).toBe(1);
|
||||
expect(queryStub.getCall(0).args[0]).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/Utils/QueryUtils.ts
Normal file
128
src/Utils/QueryUtils.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import Q from "q";
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
|
||||
export class QueryUtils {
|
||||
public static buildDocumentsQuery(
|
||||
filter: string,
|
||||
partitionKeyProperty: string,
|
||||
partitionKey: DataModels.PartitionKey
|
||||
): string {
|
||||
let query: string = partitionKeyProperty
|
||||
? `select c.id, c._self, c._rid, c._ts, ${QueryUtils.buildDocumentsQueryPartitionProjections(
|
||||
"c",
|
||||
partitionKey
|
||||
)} as _partitionKeyValue from c`
|
||||
: `select c.id, c._self, c._rid, c._ts from c`;
|
||||
|
||||
if (filter) {
|
||||
query += " " + filter;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
public static buildDocumentsQueryPartitionProjections(
|
||||
collectionAlias: string,
|
||||
partitionKey: DataModels.PartitionKey
|
||||
): string {
|
||||
if (!partitionKey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// e.g., path /order/id will be projected as c["order"]["id"],
|
||||
// to escape any property names that match a keyword
|
||||
let projections = [];
|
||||
for (let index in partitionKey.paths) {
|
||||
// TODO: Handle "/" in partition key definitions
|
||||
const projectedProperties: string[] = partitionKey.paths[index].split("/").slice(1);
|
||||
let projectedProperty: string = "";
|
||||
|
||||
projectedProperties.forEach((property: string) => {
|
||||
const projection = property.trim();
|
||||
if (projection.length > 0 && projection.charAt(0) != "'" && projection.charAt(0) != '"') {
|
||||
projectedProperty = projectedProperty + `["${projection}"]`;
|
||||
} else if (projection.length > 0 && projection.charAt(0) == "'") {
|
||||
// trim single quotes and escape double quotes
|
||||
const projectionSlice = projection.slice(1, projection.length - 1);
|
||||
projectedProperty =
|
||||
projectedProperty + `["${projectionSlice.replace(/\\"/g, '"').replace(/"/g, '\\\\\\"')}"]`;
|
||||
} else {
|
||||
projectedProperty = projectedProperty + `[${projection}]`;
|
||||
}
|
||||
});
|
||||
|
||||
projections.push(`${collectionAlias}${projectedProperty}`);
|
||||
}
|
||||
|
||||
return projections.join(",");
|
||||
}
|
||||
|
||||
public static queryPagesUntilContentPresent(
|
||||
firstItemIndex: number,
|
||||
queryItems: (itemIndex: number) => Q.Promise<ViewModels.QueryResults>
|
||||
): Q.Promise<ViewModels.QueryResults> {
|
||||
let roundTrips: number = 0;
|
||||
let netRequestCharge: number = 0;
|
||||
const doRequest = (itemIndex: number): Q.Promise<ViewModels.QueryResults> =>
|
||||
queryItems(itemIndex).then(
|
||||
(results: ViewModels.QueryResults) => {
|
||||
roundTrips = roundTrips + 1;
|
||||
results.roundTrips = roundTrips;
|
||||
results.requestCharge = Number(results.requestCharge) + netRequestCharge;
|
||||
netRequestCharge = Number(results.requestCharge);
|
||||
const resultsMetadata: ViewModels.QueryResultsMetadata = {
|
||||
hasMoreResults: results.hasMoreResults,
|
||||
itemCount: results.itemCount,
|
||||
firstItemIndex: results.firstItemIndex,
|
||||
lastItemIndex: results.lastItemIndex
|
||||
};
|
||||
if (resultsMetadata.itemCount === 0 && resultsMetadata.hasMoreResults) {
|
||||
return doRequest(resultsMetadata.lastItemIndex);
|
||||
}
|
||||
return Q.resolve(results);
|
||||
},
|
||||
(error: any) => {
|
||||
return Q.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return doRequest(firstItemIndex);
|
||||
}
|
||||
|
||||
public static queryAllPages(
|
||||
queryItems: (itemIndex: number) => Q.Promise<ViewModels.QueryResults>
|
||||
): Q.Promise<ViewModels.QueryResults> {
|
||||
const queryResults: ViewModels.QueryResults = {
|
||||
documents: [],
|
||||
activityId: undefined,
|
||||
hasMoreResults: false,
|
||||
itemCount: 0,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 0,
|
||||
requestCharge: 0,
|
||||
roundTrips: 0
|
||||
};
|
||||
const doRequest = (itemIndex: number): Q.Promise<ViewModels.QueryResults> =>
|
||||
queryItems(itemIndex).then(
|
||||
(results: ViewModels.QueryResults) => {
|
||||
const { requestCharge, hasMoreResults, itemCount, lastItemIndex, documents } = results;
|
||||
queryResults.roundTrips = queryResults.roundTrips + 1;
|
||||
queryResults.requestCharge = Number(queryResults.requestCharge) + Number(requestCharge);
|
||||
queryResults.hasMoreResults = hasMoreResults;
|
||||
queryResults.itemCount = queryResults.itemCount + itemCount;
|
||||
queryResults.lastItemIndex = lastItemIndex;
|
||||
queryResults.documents = queryResults.documents.concat(documents);
|
||||
if (queryResults.hasMoreResults) {
|
||||
return doRequest(queryResults.lastItemIndex + 1);
|
||||
}
|
||||
return Q.resolve(queryResults);
|
||||
},
|
||||
(error: any) => {
|
||||
return Q.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return doRequest(0);
|
||||
}
|
||||
}
|
||||
30
src/Utils/StringUtils.test.ts
Normal file
30
src/Utils/StringUtils.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { StringUtils } from "./StringUtils";
|
||||
|
||||
describe("StringUtils", () => {
|
||||
describe("stripSpacesFromString()", () => {
|
||||
it("should strip all spaces from input string", () => {
|
||||
const transformedString: string = StringUtils.stripSpacesFromString("a b c");
|
||||
expect(transformedString).toBe("abc");
|
||||
});
|
||||
|
||||
it("should return original string if input string has no spaces", () => {
|
||||
const transformedString: string = StringUtils.stripSpacesFromString("abc");
|
||||
expect(transformedString).toBe("abc");
|
||||
});
|
||||
|
||||
it("should return null if input is null", () => {
|
||||
const transformedString: string = StringUtils.stripSpacesFromString(null);
|
||||
expect(transformedString).toBeNull();
|
||||
});
|
||||
|
||||
it("should return undefined if input is undefiend", () => {
|
||||
const transformedString: string = StringUtils.stripSpacesFromString(undefined);
|
||||
expect(transformedString).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return empty string if input is an empty string", () => {
|
||||
const transformedString: string = StringUtils.stripSpacesFromString("");
|
||||
expect(transformedString).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
21
src/Utils/StringUtils.ts
Normal file
21
src/Utils/StringUtils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export class StringUtils {
|
||||
public static stripSpacesFromString(inputString: string): string {
|
||||
if (inputString == null || typeof inputString !== "string") {
|
||||
return inputString;
|
||||
}
|
||||
return inputString.replace(/ /g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of endsWith which works for IE
|
||||
* @param stringToTest
|
||||
* @param suffix
|
||||
*/
|
||||
public static endsWith(stringToTest: string, suffix: string): boolean {
|
||||
return stringToTest.indexOf(suffix, stringToTest.length - suffix.length) !== -1;
|
||||
}
|
||||
|
||||
public static startsWith(stringToTest: string, prefix: string): boolean {
|
||||
return stringToTest.indexOf(prefix) === 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user