mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-19 08:51:24 +00:00
DDM in DE for NOSQL (#2224)
* ddm for DE for noSQL * ddm for DE for noSQL * ddm for DE for noSQL * ddm fix for the default case and test fix * formatting issue * updated the text change * added validation errors --------- Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
This commit is contained in:
@@ -27,6 +27,12 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
|
||||
changeFeedPolicy: undefined,
|
||||
analyticalStorageTtl: undefined,
|
||||
geospatialConfig: undefined,
|
||||
dataMaskingPolicy: {
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: true,
|
||||
},
|
||||
indexes: [],
|
||||
}),
|
||||
}));
|
||||
@@ -95,7 +101,6 @@ describe("SettingsComponent", () => {
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
|
||||
wrapper.setState({
|
||||
userCanChangeProvisioningTypes: true,
|
||||
isAutoPilotSelected: true,
|
||||
wasAutopilotOriginallySet: false,
|
||||
autoPilotThroughput: 1000,
|
||||
@@ -289,6 +294,167 @@ describe("SettingsComponent", () => {
|
||||
|
||||
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle data masking policy updates correctly", async () => {
|
||||
updateUserContext({
|
||||
apiType: "SQL",
|
||||
authType: AuthType.AAD,
|
||||
});
|
||||
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
|
||||
wrapper.setState({
|
||||
dataMaskingContent: {
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: true,
|
||||
},
|
||||
dataMaskingContentBaseline: {
|
||||
includedPaths: [],
|
||||
excludedPaths: [],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: false,
|
||||
},
|
||||
isDataMaskingDirty: true,
|
||||
});
|
||||
|
||||
await settingsComponentInstance.onSaveClick();
|
||||
|
||||
// The test needs to match what onDataMaskingContentChange returns
|
||||
expect(updateCollection).toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
|
||||
expect(wrapper.state("dataMaskingContentBaseline")).toEqual({
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should validate data masking policy content", () => {
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
|
||||
// Test with invalid data structure
|
||||
// Use invalid data type for testing validation
|
||||
type InvalidPolicy = Omit<DataModels.DataMaskingPolicy, "includedPaths"> & { includedPaths: string };
|
||||
const invalidPolicy: InvalidPolicy = {
|
||||
includedPaths: "invalid",
|
||||
excludedPaths: [],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: false,
|
||||
};
|
||||
// Use type assertion since we're deliberately testing with invalid data
|
||||
settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy);
|
||||
|
||||
// State should update with the content but also set validation errors
|
||||
expect(wrapper.state("dataMaskingContent")).toEqual({
|
||||
includedPaths: "invalid",
|
||||
excludedPaths: [],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: false,
|
||||
});
|
||||
expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]);
|
||||
|
||||
// Test with valid data
|
||||
const validPolicy = {
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/path1",
|
||||
strategy: "mask",
|
||||
startPosition: 0,
|
||||
length: 4,
|
||||
},
|
||||
],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: true,
|
||||
};
|
||||
|
||||
settingsComponentInstance["onDataMaskingContentChange"](validPolicy);
|
||||
|
||||
// State should update with valid data and no validation errors
|
||||
expect(wrapper.state("dataMaskingContent")).toEqual(validPolicy);
|
||||
expect(wrapper.state("dataMaskingValidationErrors")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle data masking discard correctly", () => {
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
|
||||
const baselinePolicy = {
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/basePath",
|
||||
strategy: "mask",
|
||||
startPosition: 0,
|
||||
length: 4,
|
||||
},
|
||||
],
|
||||
excludedPaths: ["/excludedPath1"],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: false,
|
||||
};
|
||||
|
||||
const modifiedPolicy = {
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/newPath",
|
||||
strategy: "mask",
|
||||
startPosition: 1,
|
||||
length: 5,
|
||||
},
|
||||
],
|
||||
excludedPaths: ["/excludedPath2"],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: true,
|
||||
};
|
||||
|
||||
// Set initial state
|
||||
wrapper.setState({
|
||||
dataMaskingContent: modifiedPolicy,
|
||||
dataMaskingContentBaseline: baselinePolicy,
|
||||
isDataMaskingDirty: true,
|
||||
});
|
||||
|
||||
// Call revert
|
||||
settingsComponentInstance.onRevertClick();
|
||||
|
||||
// Verify state is reset
|
||||
expect(wrapper.state("dataMaskingContent")).toEqual(baselinePolicy);
|
||||
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
|
||||
expect(wrapper.state("shouldDiscardDataMasking")).toBe(true);
|
||||
});
|
||||
|
||||
it("should disable save button when data masking has validation errors", () => {
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
|
||||
// Initially, save button should be disabled
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
|
||||
|
||||
// Make data masking dirty with valid data
|
||||
wrapper.setState({
|
||||
isDataMaskingDirty: true,
|
||||
dataMaskingValidationErrors: [],
|
||||
});
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
||||
|
||||
// Add validation errors - save should be disabled
|
||||
wrapper.setState({
|
||||
dataMaskingValidationErrors: ["includedPaths must be an array"],
|
||||
});
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
|
||||
|
||||
// Clear validation errors - save should be enabled again
|
||||
wrapper.setState({
|
||||
dataMaskingValidationErrors: [],
|
||||
});
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsComponent - indexing policy subscription", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
|
||||
import * as React from "react";
|
||||
import DiscardIcon from "../../../../images/discard.svg";
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
ConflictResolutionComponent,
|
||||
ConflictResolutionComponentProps,
|
||||
} from "./SettingsSubComponents/ConflictResolutionComponent";
|
||||
import { DataMaskingComponent, DataMaskingComponentProps } from "./SettingsSubComponents/DataMaskingComponent";
|
||||
import {
|
||||
GlobalSecondaryIndexComponent,
|
||||
GlobalSecondaryIndexComponentProps,
|
||||
@@ -151,6 +152,12 @@ export interface SettingsComponentState {
|
||||
conflictResolutionPolicyProcedureBaseline: string;
|
||||
isConflictResolutionDirty: boolean;
|
||||
|
||||
dataMaskingContent: DataModels.DataMaskingPolicy;
|
||||
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
|
||||
shouldDiscardDataMasking: boolean;
|
||||
isDataMaskingDirty: boolean;
|
||||
dataMaskingValidationErrors: string[];
|
||||
|
||||
selectedTab: SettingsV2TabTypes;
|
||||
}
|
||||
|
||||
@@ -258,6 +265,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
shouldDiscardComputedProperties: false,
|
||||
isComputedPropertiesDirty: false,
|
||||
|
||||
dataMaskingContent: undefined,
|
||||
dataMaskingContentBaseline: undefined,
|
||||
shouldDiscardDataMasking: false,
|
||||
isDataMaskingDirty: false,
|
||||
dataMaskingValidationErrors: [],
|
||||
|
||||
conflictResolutionPolicyMode: undefined,
|
||||
conflictResolutionPolicyModeBaseline: undefined,
|
||||
conflictResolutionPolicyPath: undefined,
|
||||
@@ -345,7 +358,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
};
|
||||
|
||||
public isSaveSettingsButtonEnabled = (): boolean => {
|
||||
if (this.isOfferReplacePending()) {
|
||||
if (this.isOfferReplacePending() || this.props.settingsTab.isExecuting()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -353,6 +366,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.state.dataMaskingValidationErrors.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.state.isScaleSaveable ||
|
||||
this.state.isSubSettingsSaveable ||
|
||||
@@ -360,12 +377,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
this.state.isIndexingPolicyDirty ||
|
||||
this.state.isConflictResolutionDirty ||
|
||||
this.state.isComputedPropertiesDirty ||
|
||||
this.state.isDataMaskingDirty ||
|
||||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
|
||||
this.state.isThroughputBucketsSaveable
|
||||
);
|
||||
};
|
||||
|
||||
public isDiscardSettingsButtonEnabled = (): boolean => {
|
||||
if (this.props.settingsTab.isExecuting()) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.state.isScaleDiscardable ||
|
||||
this.state.isSubSettingsDiscardable ||
|
||||
@@ -373,6 +394,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
this.state.isIndexingPolicyDirty ||
|
||||
this.state.isConflictResolutionDirty ||
|
||||
this.state.isComputedPropertiesDirty ||
|
||||
this.state.isDataMaskingDirty ||
|
||||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
|
||||
this.state.isThroughputBucketsSaveable
|
||||
);
|
||||
@@ -428,7 +450,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
: this.saveDatabaseSettings(startKey));
|
||||
} catch (error) {
|
||||
this.props.settingsTab.isExecutionError(true);
|
||||
console.error(error);
|
||||
traceFailure(
|
||||
Action.SettingsV2Updated,
|
||||
{
|
||||
@@ -445,8 +466,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
} finally {
|
||||
this.props.settingsTab.isExecuting(false);
|
||||
|
||||
// Send message to Fabric no matter success or failure.
|
||||
// In case of failure, saveCollectionSettings might have partially succeeded and Fabric needs to refresh
|
||||
if (isFabricNative() && this.isCollectionSettingsTab) {
|
||||
sendMessage({
|
||||
type: FabricMessageTypes.ContainerUpdated,
|
||||
@@ -457,6 +476,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
};
|
||||
|
||||
public onRevertClick = (): void => {
|
||||
if (this.props.settingsTab.isExecuting()) {
|
||||
return;
|
||||
}
|
||||
trace(Action.SettingsV2Discarded, ActionModifiers.Mark, {
|
||||
message: "Settings Discarded",
|
||||
});
|
||||
@@ -497,6 +519,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
|
||||
shouldDiscardComputedProperties: true,
|
||||
isComputedPropertiesDirty: false,
|
||||
dataMaskingContent: this.state.dataMaskingContentBaseline,
|
||||
shouldDiscardDataMasking: true,
|
||||
isDataMaskingDirty: false,
|
||||
dataMaskingValidationErrors: [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -659,6 +685,39 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
|
||||
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
|
||||
|
||||
private onDataMaskingContentChange = (newDataMasking: DataModels.DataMaskingPolicy): void => {
|
||||
if (!newDataMasking.excludedPaths) {
|
||||
newDataMasking.excludedPaths = [];
|
||||
}
|
||||
if (!newDataMasking.includedPaths) {
|
||||
newDataMasking.includedPaths = [];
|
||||
}
|
||||
|
||||
const validationErrors = [];
|
||||
if (!Array.isArray(newDataMasking.includedPaths)) {
|
||||
validationErrors.push("includedPaths must be an array");
|
||||
}
|
||||
if (!Array.isArray(newDataMasking.excludedPaths)) {
|
||||
validationErrors.push("excludedPaths must be an array");
|
||||
}
|
||||
if (typeof newDataMasking.policyFormatVersion !== "number") {
|
||||
validationErrors.push("policyFormatVersion must be a number");
|
||||
}
|
||||
if (typeof newDataMasking.isPolicyEnabled !== "boolean") {
|
||||
validationErrors.push("isPolicyEnabled must be a boolean");
|
||||
}
|
||||
|
||||
this.setState({
|
||||
dataMaskingContent: newDataMasking,
|
||||
dataMaskingValidationErrors: validationErrors,
|
||||
});
|
||||
};
|
||||
|
||||
private resetShouldDiscardDataMasking = (): void => this.setState({ shouldDiscardDataMasking: false });
|
||||
|
||||
private onDataMaskingDirtyChange = (isDataMaskingDirty: boolean): void =>
|
||||
this.setState({ isDataMaskingDirty: isDataMaskingDirty });
|
||||
|
||||
private calculateTotalThroughputUsed = (): void => {
|
||||
this.totalThroughputUsed = 0;
|
||||
(useDatabases.getState().databases || []).forEach(async (database) => {
|
||||
@@ -783,6 +842,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
const fullTextPolicy: DataModels.FullTextPolicy =
|
||||
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
|
||||
const indexingPolicyContent = this.collection.indexingPolicy();
|
||||
const dataMaskingContent: DataModels.DataMaskingPolicy = {
|
||||
includedPaths: this.collection.dataMaskingPolicy?.()?.includedPaths || [],
|
||||
excludedPaths: this.collection.dataMaskingPolicy?.()?.excludedPaths || [],
|
||||
policyFormatVersion: this.collection.dataMaskingPolicy?.()?.policyFormatVersion || 2,
|
||||
isPolicyEnabled: this.collection.dataMaskingPolicy?.()?.isPolicyEnabled || false,
|
||||
};
|
||||
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
|
||||
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
|
||||
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
|
||||
@@ -834,11 +899,14 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
geospatialConfigTypeBaseline: geoSpatialConfigType,
|
||||
computedPropertiesContent: computedPropertiesContent,
|
||||
computedPropertiesContentBaseline: computedPropertiesContent,
|
||||
dataMaskingContent: dataMaskingContent,
|
||||
dataMaskingContentBaseline: dataMaskingContent,
|
||||
};
|
||||
};
|
||||
|
||||
private getTabsButtons = (): CommandButtonComponentProps[] => {
|
||||
const buttons: CommandButtonComponentProps[] = [];
|
||||
const isExecuting = this.props.settingsTab.isExecuting();
|
||||
if (this.saveSettingsButton.isVisible()) {
|
||||
const label = "Save";
|
||||
buttons.push({
|
||||
@@ -848,7 +916,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveSettingsButton.isEnabled(),
|
||||
disabled: isExecuting || !this.saveSettingsButton.isEnabled(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -857,11 +925,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onRevertClick,
|
||||
onCommandClick: () => {
|
||||
if (isExecuting) {
|
||||
return;
|
||||
}
|
||||
this.onRevertClick();
|
||||
},
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.discardSettingsChangesButton.isEnabled(),
|
||||
disabled: isExecuting || !this.discardSettingsChangesButton.isEnabled(),
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
@@ -980,7 +1053,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
this.state.isContainerPolicyDirty ||
|
||||
this.state.isIndexingPolicyDirty ||
|
||||
this.state.isConflictResolutionDirty ||
|
||||
this.state.isComputedPropertiesDirty
|
||||
this.state.isComputedPropertiesDirty ||
|
||||
this.state.isDataMaskingDirty
|
||||
) {
|
||||
let defaultTtl: number;
|
||||
switch (this.state.timeToLive) {
|
||||
@@ -1002,6 +1076,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
newCollection.vectorEmbeddingPolicy = this.state.vectorEmbeddingPolicy;
|
||||
|
||||
newCollection.fullTextPolicy = this.state.fullTextPolicy;
|
||||
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
|
||||
|
||||
newCollection.indexingPolicy = this.state.indexingPolicyContent;
|
||||
|
||||
@@ -1048,13 +1123,18 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
await this.refreshIndexTransformationProgress();
|
||||
}
|
||||
|
||||
// Update collection object with new data
|
||||
this.collection.dataMaskingPolicy(updatedCollection.dataMaskingPolicy);
|
||||
|
||||
this.setState({
|
||||
dataMaskingContentBaseline: this.state.dataMaskingContent,
|
||||
isSubSettingsSaveable: false,
|
||||
isSubSettingsDiscardable: false,
|
||||
isContainerPolicyDirty: false,
|
||||
isIndexingPolicyDirty: false,
|
||||
isConflictResolutionDirty: false,
|
||||
isComputedPropertiesDirty: false,
|
||||
isDataMaskingDirty: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1383,6 +1463,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
});
|
||||
}
|
||||
|
||||
// Check if DDM should be enabled
|
||||
const shouldEnableDDM = (): boolean => {
|
||||
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
|
||||
const isSqlAccount = userContext.apiType === "SQL";
|
||||
|
||||
return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability
|
||||
};
|
||||
|
||||
if (shouldEnableDDM()) {
|
||||
const dataMaskingComponentProps: DataMaskingComponentProps = {
|
||||
shouldDiscardDataMasking: this.state.shouldDiscardDataMasking,
|
||||
resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking,
|
||||
dataMaskingContent: this.state.dataMaskingContent,
|
||||
dataMaskingContentBaseline: this.state.dataMaskingContentBaseline,
|
||||
onDataMaskingContentChange: this.onDataMaskingContentChange,
|
||||
onDataMaskingDirtyChange: this.onDataMaskingDirtyChange,
|
||||
validationErrors: this.state.dataMaskingValidationErrors,
|
||||
};
|
||||
|
||||
tabs.push({
|
||||
tab: SettingsV2TabTypes.DataMaskingTab,
|
||||
content: <DataMaskingComponent {...dataMaskingComponentProps} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) {
|
||||
tabs.push({
|
||||
tab: SettingsV2TabTypes.ThroughputBucketsTab,
|
||||
|
||||
@@ -69,13 +69,111 @@ describe("SettingsUtils functions", () => {
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
|
||||
const prices = getRuPriceBreakdown(500, "", 1, false, false);
|
||||
expect(prices.hourlyPrice).toBe(0.04);
|
||||
expect(prices.dailyPrice).toBe(0.96);
|
||||
expect(prices.monthlyPrice).toBe(29.2);
|
||||
expect(prices.pricePerRu).toBe(0.00008);
|
||||
expect(prices.currency).toBe("USD");
|
||||
expect(prices.currencySign).toBe("$");
|
||||
describe("getRuPriceBreakdown", () => {
|
||||
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
|
||||
const prices = getRuPriceBreakdown(500, "", 1, false, false);
|
||||
expect(prices.hourlyPrice).toBe(0.04);
|
||||
expect(prices.dailyPrice).toBe(0.96);
|
||||
expect(prices.monthlyPrice).toBe(29.2);
|
||||
expect(prices.pricePerRu).toBe(0.00008);
|
||||
expect(prices.currency).toBe("USD");
|
||||
expect(prices.currencySign).toBe("$");
|
||||
});
|
||||
|
||||
it("should return correct price breakdown for autoscale", () => {
|
||||
const prices = getRuPriceBreakdown(1000, "", 1, false, true);
|
||||
// For autoscale, the baseline RU is 10% of max RU
|
||||
expect(prices.hourlyPrice).toBe(0.12); // Higher because autoscale pricing is different
|
||||
expect(prices.dailyPrice).toBe(2.88); // hourlyPrice * 24
|
||||
expect(prices.monthlyPrice).toBe(87.6); // hourlyPrice * 730
|
||||
expect(prices.pricePerRu).toBe(0.00012); // Autoscale price per RU
|
||||
expect(prices.currency).toBe("USD");
|
||||
expect(prices.currencySign).toBe("$");
|
||||
});
|
||||
|
||||
it("should return correct price breakdown for multimaster", () => {
|
||||
const prices = getRuPriceBreakdown(500, "", 2, true, false);
|
||||
// For multimaster with 2 regions, price is multiplied by 4
|
||||
expect(prices.hourlyPrice).toBe(0.16); // Base price * 4
|
||||
expect(prices.dailyPrice).toBe(3.84); // hourlyPrice * 24
|
||||
expect(prices.monthlyPrice).toBe(116.8); // hourlyPrice * 730
|
||||
expect(prices.pricePerRu).toBe(0.00016); // Base price per RU * 2 (regions) * 2 (multimaster)
|
||||
expect(prices.currency).toBe("USD");
|
||||
expect(prices.currencySign).toBe("$");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message formatting", () => {
|
||||
it("should format throughput apply delayed message correctly", () => {
|
||||
const message = getThroughputApplyDelayedMessage(false, 1000, "RU/s", "testDb", "testColl", 2000);
|
||||
const wrapper = shallow(message);
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain("testDb");
|
||||
expect(text).toContain("testColl");
|
||||
expect(text).toContain("Current manual throughput: 1000 RU/s");
|
||||
expect(text).toContain("Target manual throughput: 2000");
|
||||
});
|
||||
|
||||
it("should format autoscale throughput message correctly", () => {
|
||||
const message = getThroughputApplyDelayedMessage(true, 1000, "RU/s", "testDb", "testColl", 2000);
|
||||
const wrapper = shallow(message);
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain("Current autoscale throughput: 100 - 1000 RU/s");
|
||||
expect(text).toContain("Target autoscale throughput: 200 - 2000 RU/s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimated spending element", () => {
|
||||
// Mock Stack component since we're using shallow rendering
|
||||
const mockStack = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mock("@fluentui/react", () => ({
|
||||
...jest.requireActual("@fluentui/react"),
|
||||
Stack: mockStack,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("should render correct spending info for manual throughput", () => {
|
||||
const costElement = <div>Cost</div>;
|
||||
const priceBreakdown: PriceBreakdown = {
|
||||
hourlyPrice: 1.0,
|
||||
dailyPrice: 24.0,
|
||||
monthlyPrice: 730.0,
|
||||
pricePerRu: 0.0001,
|
||||
currency: "USD",
|
||||
currencySign: "$",
|
||||
};
|
||||
|
||||
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, false);
|
||||
const wrapper = shallow(element);
|
||||
const spendElement = wrapper.find("#throughputSpendElement");
|
||||
|
||||
expect(spendElement.find("span").at(0).text()).toBe("1 region");
|
||||
expect(spendElement.find("span").at(1).text()).toBe("1000 RU/s");
|
||||
expect(spendElement.find("span").at(2).text()).toBe("$0.0001/RU");
|
||||
});
|
||||
|
||||
it("should render correct spending info for autoscale throughput", () => {
|
||||
const costElement = <div>Cost</div>;
|
||||
const priceBreakdown: PriceBreakdown = {
|
||||
hourlyPrice: 1.0,
|
||||
dailyPrice: 24.0,
|
||||
monthlyPrice: 730.0,
|
||||
pricePerRu: 0.0001,
|
||||
currency: "USD",
|
||||
currencySign: "$",
|
||||
};
|
||||
|
||||
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, true);
|
||||
const wrapper = shallow(element);
|
||||
const spendElement = wrapper.find("#throughputSpendElement");
|
||||
|
||||
expect(spendElement.find("span").at(1).text()).toBe("100 RU/s - 1000 RU/s");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface PriceBreakdown {
|
||||
currencySign: string;
|
||||
}
|
||||
|
||||
export type editorType = "indexPolicy" | "computedProperties";
|
||||
export type editorType = "indexPolicy" | "computedProperties" | "dataMasking";
|
||||
|
||||
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
|
||||
|
||||
@@ -170,6 +170,14 @@ export const messageBarStyles: Partial<IMessageBarStyles> = {
|
||||
text: { fontSize: 14 },
|
||||
};
|
||||
|
||||
export const unsavedEditorMessageBarStyles: Partial<IMessageBarStyles> = {
|
||||
root: {
|
||||
marginTop: "5px",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
text: { fontSize: 14 },
|
||||
};
|
||||
|
||||
export const throughputUnit = "RU/s";
|
||||
|
||||
export function onRenderRow(props: IDetailsRowProps): JSX.Element {
|
||||
@@ -259,7 +267,12 @@ export const ttlWarning: JSX.Element = (
|
||||
export const unsavedEditorWarningMessage = (editor: editorType): JSX.Element => (
|
||||
<Text styles={infoAndToolTipTextStyle}>
|
||||
You have not saved the latest changes made to your{" "}
|
||||
{editor === "indexPolicy" ? "indexing policy" : "computed properties"}. Please click save to confirm the changes.
|
||||
{editor === "indexPolicy"
|
||||
? "indexing policy"
|
||||
: editor === "dataMasking"
|
||||
? "data masking policy"
|
||||
: "computed properties"}
|
||||
. Please click save to confirm the changes.
|
||||
</Text>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { MessageBar, MessageBarType } from "@fluentui/react";
|
||||
import { mount } from "enzyme";
|
||||
import React from "react";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import { DataMaskingComponent } from "./DataMaskingComponent";
|
||||
|
||||
const mockGetValue = jest.fn();
|
||||
const mockSetValue = jest.fn();
|
||||
const mockOnDidChangeContent = jest.fn();
|
||||
const mockGetModel = jest.fn(() => ({
|
||||
getValue: mockGetValue,
|
||||
setValue: mockSetValue,
|
||||
onDidChangeContent: mockOnDidChangeContent,
|
||||
}));
|
||||
|
||||
const mockEditor = {
|
||||
getModel: mockGetModel,
|
||||
dispose: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock("../../../LazyMonaco", () => ({
|
||||
loadMonaco: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
editor: {
|
||||
create: jest.fn(() => mockEditor),
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../Utils/CapabilityUtils", () => ({
|
||||
isCapabilityEnabled: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
describe("DataMaskingComponent", () => {
|
||||
const mockProps = {
|
||||
shouldDiscardDataMasking: false,
|
||||
resetShouldDiscardDataMasking: jest.fn(),
|
||||
dataMaskingContent: undefined as DataModels.DataMaskingPolicy,
|
||||
dataMaskingContentBaseline: undefined as DataModels.DataMaskingPolicy,
|
||||
onDataMaskingContentChange: jest.fn(),
|
||||
onDataMaskingDirtyChange: jest.fn(),
|
||||
validationErrors: [] as string[],
|
||||
};
|
||||
|
||||
const samplePolicy: DataModels.DataMaskingPolicy = {
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/test",
|
||||
strategy: "Default",
|
||||
startPosition: 0,
|
||||
length: -1,
|
||||
},
|
||||
],
|
||||
excludedPaths: [],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: false,
|
||||
};
|
||||
|
||||
let changeContentCallback: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetValue.mockReturnValue(JSON.stringify(samplePolicy));
|
||||
mockOnDidChangeContent.mockImplementation((callback) => {
|
||||
changeContentCallback = callback;
|
||||
});
|
||||
});
|
||||
|
||||
it("renders without crashing", async () => {
|
||||
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
expect(wrapper.exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays warning message when content is dirty", async () => {
|
||||
const wrapper = mount(
|
||||
<DataMaskingComponent
|
||||
{...mockProps}
|
||||
dataMaskingContent={samplePolicy}
|
||||
dataMaskingContentBaseline={{ ...samplePolicy, isPolicyEnabled: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
|
||||
// Verify editor div is rendered
|
||||
const editorDiv = wrapper.find(".settingsV2Editor");
|
||||
expect(editorDiv.exists()).toBeTruthy();
|
||||
|
||||
// Warning message should be visible when content is dirty
|
||||
const messageBar = wrapper.find(MessageBar);
|
||||
expect(messageBar.exists()).toBeTruthy();
|
||||
expect(messageBar.prop("messageBarType")).toBe(MessageBarType.warning);
|
||||
});
|
||||
|
||||
it("updates content and dirty state on valid JSON input", async () => {
|
||||
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
|
||||
// Simulate valid JSON input by setting mock return value and triggering callback
|
||||
const validJson = JSON.stringify(samplePolicy);
|
||||
mockGetValue.mockReturnValue(validJson);
|
||||
changeContentCallback();
|
||||
|
||||
expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(samplePolicy);
|
||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("doesn't update content on invalid JSON input", async () => {
|
||||
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
|
||||
// Simulate invalid JSON input
|
||||
const invalidJson = "{invalid:json}";
|
||||
mockGetValue.mockReturnValue(invalidJson);
|
||||
changeContentCallback();
|
||||
|
||||
expect(mockProps.onDataMaskingContentChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets content when shouldDiscardDataMasking is true", async () => {
|
||||
const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true };
|
||||
|
||||
const wrapper = mount(
|
||||
<DataMaskingComponent
|
||||
{...mockProps}
|
||||
dataMaskingContent={samplePolicy}
|
||||
dataMaskingContentBaseline={baselinePolicy}
|
||||
/>,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
|
||||
// Now update props to trigger shouldDiscardDataMasking
|
||||
wrapper.setProps({ shouldDiscardDataMasking: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
|
||||
// Check that reset was triggered
|
||||
expect(mockProps.resetShouldDiscardDataMasking).toHaveBeenCalled();
|
||||
expect(mockSetValue).toHaveBeenCalledWith(JSON.stringify(samplePolicy, undefined, 4));
|
||||
});
|
||||
|
||||
it("recalculates dirty state when baseline changes", async () => {
|
||||
const wrapper = mount(
|
||||
<DataMaskingComponent
|
||||
{...mockProps}
|
||||
dataMaskingContent={samplePolicy}
|
||||
dataMaskingContentBaseline={samplePolicy}
|
||||
/>,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
|
||||
// Update baseline to trigger componentDidUpdate
|
||||
const newBaseline = { ...samplePolicy, isPolicyEnabled: true };
|
||||
wrapper.setProps({ dataMaskingContentBaseline: newBaseline });
|
||||
|
||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("validates required fields in policy", async () => {
|
||||
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
|
||||
// Test with missing required fields
|
||||
const invalidPolicy: Record<string, unknown> = {
|
||||
includedPaths: "not an array",
|
||||
excludedPaths: [] as string[],
|
||||
policyFormatVersion: "not a number",
|
||||
isPolicyEnabled: "not a boolean",
|
||||
};
|
||||
|
||||
mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy));
|
||||
changeContentCallback();
|
||||
|
||||
// Parent callback should be called even with invalid data (parent will validate)
|
||||
expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(invalidPolicy);
|
||||
});
|
||||
|
||||
it("maintains dirty state after multiple content changes", async () => {
|
||||
const wrapper = mount(
|
||||
<DataMaskingComponent
|
||||
{...mockProps}
|
||||
dataMaskingContent={samplePolicy}
|
||||
dataMaskingContentBaseline={samplePolicy}
|
||||
/>,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
|
||||
// First change
|
||||
const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true };
|
||||
mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1));
|
||||
changeContentCallback();
|
||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
||||
|
||||
// Second change back to baseline
|
||||
mockGetValue.mockReturnValue(JSON.stringify(samplePolicy));
|
||||
changeContentCallback();
|
||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
||||
import * as monaco from "monaco-editor";
|
||||
import * as React from "react";
|
||||
import * as Constants from "../../../../Common/Constants";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import { isCapabilityEnabled } from "../../../../Utils/CapabilityUtils";
|
||||
import { loadMonaco } from "../../../LazyMonaco";
|
||||
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils";
|
||||
import { isDirty as isContentDirty } from "../SettingsUtils";
|
||||
|
||||
export interface DataMaskingComponentProps {
|
||||
shouldDiscardDataMasking: boolean;
|
||||
resetShouldDiscardDataMasking: () => void;
|
||||
dataMaskingContent: DataModels.DataMaskingPolicy;
|
||||
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
|
||||
onDataMaskingContentChange: (dataMasking: DataModels.DataMaskingPolicy) => void;
|
||||
onDataMaskingDirtyChange: (isDirty: boolean) => void;
|
||||
validationErrors: string[];
|
||||
}
|
||||
|
||||
interface DataMaskingComponentState {
|
||||
isDirty: boolean;
|
||||
dataMaskingContentIsValid: boolean;
|
||||
}
|
||||
|
||||
const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = {
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/",
|
||||
strategy: "Default",
|
||||
startPosition: 0,
|
||||
length: -1,
|
||||
},
|
||||
],
|
||||
excludedPaths: [],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: false,
|
||||
};
|
||||
|
||||
export class DataMaskingComponent extends React.Component<DataMaskingComponentProps, DataMaskingComponentState> {
|
||||
private dataMaskingDiv = React.createRef<HTMLDivElement>();
|
||||
private dataMaskingEditor: monaco.editor.IStandaloneCodeEditor;
|
||||
private shouldCheckComponentIsDirty = true;
|
||||
|
||||
constructor(props: DataMaskingComponentProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isDirty: false,
|
||||
dataMaskingContentIsValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
if (this.props.shouldDiscardDataMasking) {
|
||||
this.resetDataMaskingEditor();
|
||||
this.props.resetShouldDiscardDataMasking();
|
||||
}
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.resetDataMaskingEditor();
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
private onComponentUpdate = (): void => {
|
||||
if (!this.shouldCheckComponentIsDirty) {
|
||||
this.shouldCheckComponentIsDirty = true;
|
||||
return;
|
||||
}
|
||||
this.props.onDataMaskingDirtyChange(this.IsComponentDirty());
|
||||
this.shouldCheckComponentIsDirty = false;
|
||||
};
|
||||
|
||||
public IsComponentDirty = (): boolean => {
|
||||
if (
|
||||
isContentDirty(this.props.dataMaskingContent, this.props.dataMaskingContentBaseline) &&
|
||||
this.state.dataMaskingContentIsValid
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
private resetDataMaskingEditor = (): void => {
|
||||
if (!this.dataMaskingEditor) {
|
||||
this.createDataMaskingEditor();
|
||||
} else {
|
||||
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
||||
const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4);
|
||||
dataMaskingEditorModel.setValue(value);
|
||||
}
|
||||
this.onComponentUpdate();
|
||||
};
|
||||
|
||||
private async createDataMaskingEditor(): Promise<void> {
|
||||
const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4);
|
||||
const monaco = await loadMonaco();
|
||||
this.dataMaskingEditor = monaco.editor.create(this.dataMaskingDiv.current, {
|
||||
value: value,
|
||||
language: "json",
|
||||
automaticLayout: true,
|
||||
ariaLabel: "Data Masking Policy",
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: "on",
|
||||
});
|
||||
if (this.dataMaskingEditor) {
|
||||
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
||||
dataMaskingEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
private onEditorContentChange = (): void => {
|
||||
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
||||
try {
|
||||
const newContent = JSON.parse(dataMaskingEditorModel.getValue()) as DataModels.DataMaskingPolicy;
|
||||
|
||||
// Always call parent's validation - it will handle validation and store errors
|
||||
this.props.onDataMaskingContentChange(newContent);
|
||||
|
||||
const isDirty = isContentDirty(newContent, this.props.dataMaskingContentBaseline);
|
||||
this.setState(
|
||||
{
|
||||
dataMaskingContentIsValid: this.props.validationErrors.length === 0,
|
||||
isDirty,
|
||||
},
|
||||
() => {
|
||||
this.props.onDataMaskingDirtyChange(isDirty);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Invalid JSON - mark as invalid without propagating
|
||||
this.setState({
|
||||
dataMaskingContentIsValid: false,
|
||||
isDirty: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
if (!isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDirty = this.IsComponentDirty();
|
||||
return (
|
||||
<Stack {...titleAndInputStackProps}>
|
||||
{isDirty && (
|
||||
<MessageBar messageBarType={MessageBarType.warning}>{unsavedEditorWarningMessage("dataMasking")}</MessageBar>
|
||||
)}
|
||||
{this.props.validationErrors.length > 0 && (
|
||||
<MessageBar messageBarType={MessageBarType.error}>
|
||||
Validation failed: {this.props.validationErrors.join(", ")}
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className="settingsV2Editor" tabIndex={0} ref={this.dataMaskingDiv}></div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
getMongoIndexType,
|
||||
getMongoIndexTypeText,
|
||||
getMongoNotification,
|
||||
getPartitionKeyName,
|
||||
getPartitionKeyPlaceHolder,
|
||||
getPartitionKeySubtext,
|
||||
getPartitionKeyTooltipText,
|
||||
getSanitizedInputValue,
|
||||
getTabTitle,
|
||||
hasDatabaseSharedThroughput,
|
||||
isDirty,
|
||||
isIndexTransforming,
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
MongoWildcardPlaceHolder,
|
||||
parseConflictResolutionMode,
|
||||
parseConflictResolutionProcedure,
|
||||
SettingsV2TabTypes,
|
||||
SingleFieldText,
|
||||
WildcardText,
|
||||
} from "./SettingsUtils";
|
||||
@@ -50,14 +56,46 @@ describe("SettingsUtils", () => {
|
||||
expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true);
|
||||
});
|
||||
|
||||
it("parseConflictResolutionMode", () => {
|
||||
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||
describe("parseConflictResolutionMode", () => {
|
||||
it("parses valid modes correctly", () => {
|
||||
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||
expect(parseConflictResolutionMode("Custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(parseConflictResolutionMode("CUSTOM")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(parseConflictResolutionMode("LastWriterWins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||
});
|
||||
|
||||
it("handles empty/undefined input", () => {
|
||||
expect(parseConflictResolutionMode(undefined)).toBeUndefined();
|
||||
expect(parseConflictResolutionMode(null)).toBeUndefined();
|
||||
expect(parseConflictResolutionMode("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults to LastWriterWins for invalid inputs", () => {
|
||||
expect(parseConflictResolutionMode("invalid")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||
expect(parseConflictResolutionMode("123")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||
});
|
||||
});
|
||||
|
||||
it("parseConflictResolutionProcedure", () => {
|
||||
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual("conflictResSproc");
|
||||
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
|
||||
describe("parseConflictResolutionProcedure", () => {
|
||||
it("extracts procedure name from valid paths", () => {
|
||||
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual(
|
||||
"conflictResSproc",
|
||||
);
|
||||
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
|
||||
expect(parseConflictResolutionProcedure("/dbs/mydb/colls/mycoll/sprocs/myProc")).toEqual("myProc");
|
||||
});
|
||||
|
||||
it("handles empty/undefined input", () => {
|
||||
expect(parseConflictResolutionProcedure(undefined)).toBeUndefined();
|
||||
expect(parseConflictResolutionProcedure(null)).toBeUndefined();
|
||||
expect(parseConflictResolutionProcedure("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles invalid path formats", () => {
|
||||
expect(parseConflictResolutionProcedure("/invalid/path")).toBeUndefined();
|
||||
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/wrongtype/name")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDirty", () => {
|
||||
@@ -68,68 +106,235 @@ describe("SettingsUtils", () => {
|
||||
excludedPaths: [],
|
||||
} as DataModels.IndexingPolicy;
|
||||
|
||||
it("works on all types", () => {
|
||||
expect(isDirty("baseline", "baseline")).toEqual(false);
|
||||
expect(isDirty(0, 0)).toEqual(false);
|
||||
expect(isDirty(true, true)).toEqual(false);
|
||||
expect(isDirty(undefined, undefined)).toEqual(false);
|
||||
expect(isDirty(indexingPolicy, indexingPolicy)).toEqual(false);
|
||||
describe("primitive types", () => {
|
||||
it("handles strings", () => {
|
||||
expect(isDirty("baseline", "baseline")).toBeFalsy();
|
||||
expect(isDirty("baseline", "current")).toBeTruthy();
|
||||
expect(isDirty("", "")).toBeFalsy();
|
||||
expect(isDirty("test", "")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(isDirty("baseline", "current")).toEqual(true);
|
||||
expect(isDirty(0, 1)).toEqual(true);
|
||||
expect(isDirty(true, false)).toEqual(true);
|
||||
expect(isDirty(undefined, indexingPolicy)).toEqual(true);
|
||||
expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toEqual(true);
|
||||
it("handles numbers", () => {
|
||||
expect(isDirty(0, 0)).toBeFalsy();
|
||||
expect(isDirty(1, 1)).toBeFalsy();
|
||||
expect(isDirty(0, 1)).toBeTruthy();
|
||||
expect(isDirty(-1, 1)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles booleans", () => {
|
||||
expect(isDirty(true, true)).toBeFalsy();
|
||||
expect(isDirty(false, false)).toBeFalsy();
|
||||
expect(isDirty(true, false)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles undefined and null", () => {
|
||||
expect(isDirty(undefined, undefined)).toBeFalsy();
|
||||
expect(isDirty(null, null)).toBeFalsy();
|
||||
expect(isDirty(undefined, null)).toBeTruthy();
|
||||
expect(isDirty(undefined, "value")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex types", () => {
|
||||
it("handles indexing policy", () => {
|
||||
expect(isDirty(indexingPolicy, indexingPolicy)).toBeFalsy();
|
||||
expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toBeTruthy();
|
||||
expect(isDirty(indexingPolicy, { ...indexingPolicy, includedPaths: ["/path"] })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles array type policies", () => {
|
||||
const computedProperties: DataModels.ComputedProperties = [
|
||||
{ name: "prop1", query: "SELECT * FROM c" },
|
||||
{ name: "prop2", query: "SELECT * FROM c" },
|
||||
];
|
||||
const otherProperties: DataModels.ComputedProperties = [{ name: "prop1", query: "SELECT * FROM c" }];
|
||||
expect(isDirty(computedProperties, computedProperties)).toBeFalsy();
|
||||
expect(isDirty(computedProperties, otherProperties)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("type mismatch handling", () => {
|
||||
it("throws error for mismatched types", () => {
|
||||
expect(() => isDirty("string", 123)).toThrow("current and baseline values are not of the same type");
|
||||
expect(() => isDirty(true, "true")).toThrow("current and baseline values are not of the same type");
|
||||
expect(() => isDirty(0, false)).toThrow("current and baseline values are not of the same type");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("getSanitizedInputValue", () => {
|
||||
describe("getSanitizedInputValue", () => {
|
||||
const max = 100;
|
||||
expect(getSanitizedInputValue("", max)).toEqual(0);
|
||||
expect(getSanitizedInputValue("999", max)).toEqual(100);
|
||||
expect(getSanitizedInputValue("10", max)).toEqual(10);
|
||||
|
||||
it("handles empty or invalid inputs", () => {
|
||||
expect(getSanitizedInputValue("", max)).toEqual(0);
|
||||
expect(getSanitizedInputValue("abc", max)).toEqual(0);
|
||||
expect(getSanitizedInputValue("!@#", max)).toEqual(0);
|
||||
expect(getSanitizedInputValue(null, max)).toEqual(0);
|
||||
expect(getSanitizedInputValue(undefined, max)).toEqual(0);
|
||||
});
|
||||
|
||||
it("handles valid inputs within max", () => {
|
||||
expect(getSanitizedInputValue("10", max)).toEqual(10);
|
||||
expect(getSanitizedInputValue("50", max)).toEqual(50);
|
||||
expect(getSanitizedInputValue("100", max)).toEqual(100);
|
||||
});
|
||||
|
||||
it("handles inputs exceeding max", () => {
|
||||
expect(getSanitizedInputValue("101", max)).toEqual(100);
|
||||
expect(getSanitizedInputValue("999", max)).toEqual(100);
|
||||
expect(getSanitizedInputValue("1000000", max)).toEqual(100);
|
||||
});
|
||||
|
||||
it("handles inputs without max constraint", () => {
|
||||
expect(getSanitizedInputValue("10")).toEqual(10);
|
||||
expect(getSanitizedInputValue("1000")).toEqual(1000);
|
||||
expect(getSanitizedInputValue("999999")).toEqual(999999);
|
||||
});
|
||||
|
||||
it("handles negative numbers", () => {
|
||||
expect(getSanitizedInputValue("-10", max)).toEqual(-10);
|
||||
expect(getSanitizedInputValue("-999", max)).toEqual(-999);
|
||||
});
|
||||
});
|
||||
|
||||
it("getMongoIndexType", () => {
|
||||
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
|
||||
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||
expect(getMongoIndexType(["Key1", "Key2"])).toEqual(undefined);
|
||||
describe("getMongoIndexType", () => {
|
||||
it("correctly identifies single field indexes", () => {
|
||||
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
|
||||
expect(getMongoIndexType(["field1"])).toEqual(MongoIndexTypes.Single);
|
||||
expect(getMongoIndexType(["name"])).toEqual(MongoIndexTypes.Single);
|
||||
});
|
||||
|
||||
it("correctly identifies wildcard indexes", () => {
|
||||
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||
expect(getMongoIndexType(["field.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||
expect(getMongoIndexType(["nested.path.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||
});
|
||||
|
||||
it("returns undefined for invalid or compound indexes", () => {
|
||||
expect(getMongoIndexType(["Key1", "Key2"])).toBeUndefined();
|
||||
expect(getMongoIndexType([])).toBeUndefined();
|
||||
expect(getMongoIndexType(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("getMongoIndexTypeText", () => {
|
||||
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
|
||||
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
|
||||
describe("getMongoIndexTypeText", () => {
|
||||
it("returns correct text for single field indexes", () => {
|
||||
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
|
||||
});
|
||||
|
||||
it("returns correct text for wildcard indexes", () => {
|
||||
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
|
||||
});
|
||||
});
|
||||
|
||||
it("getMongoNotification", () => {
|
||||
describe("getMongoNotification", () => {
|
||||
const singleIndexDescription = "sampleKey";
|
||||
const wildcardIndexDescription = "sampleKey.$**";
|
||||
|
||||
let notification = getMongoNotification(singleIndexDescription, undefined);
|
||||
expect(notification.message).toEqual("Please select a type for each index.");
|
||||
expect(notification.type).toEqual(MongoNotificationType.Warning);
|
||||
describe("type validation", () => {
|
||||
it("returns warning when type is missing", () => {
|
||||
const notification = getMongoNotification(singleIndexDescription, undefined);
|
||||
expect(notification.message).toEqual("Please select a type for each index.");
|
||||
expect(notification.type).toEqual(MongoNotificationType.Warning);
|
||||
});
|
||||
|
||||
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Single);
|
||||
expect(notification).toEqual(undefined);
|
||||
it("returns undefined for valid type and description combinations", () => {
|
||||
expect(getMongoNotification(singleIndexDescription, MongoIndexTypes.Single)).toBeUndefined();
|
||||
expect(getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
notification = getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard);
|
||||
expect(notification).toEqual(undefined);
|
||||
describe("field name validation", () => {
|
||||
it("returns error when field name is empty", () => {
|
||||
const notification = getMongoNotification("", MongoIndexTypes.Single);
|
||||
expect(notification.message).toEqual("Please enter a field name.");
|
||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||
|
||||
notification = getMongoNotification("", MongoIndexTypes.Single);
|
||||
expect(notification.message).toEqual("Please enter a field name.");
|
||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||
const whitespaceNotification = getMongoNotification(" ", MongoIndexTypes.Single);
|
||||
expect(whitespaceNotification.message).toEqual("Please enter a field name.");
|
||||
expect(whitespaceNotification.type).toEqual(MongoNotificationType.Error);
|
||||
});
|
||||
|
||||
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard);
|
||||
expect(notification.message).toEqual(
|
||||
"Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder,
|
||||
);
|
||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||
it("returns error when wildcard index is missing $** pattern", () => {
|
||||
const notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard);
|
||||
expect(notification.message).toEqual(
|
||||
"Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder,
|
||||
);
|
||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles undefined field name", () => {
|
||||
const notification = getMongoNotification(undefined, MongoIndexTypes.Single);
|
||||
expect(notification.message).toEqual("Please enter a field name.");
|
||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||
});
|
||||
});
|
||||
it("isIndexingTransforming", () => {
|
||||
expect(isIndexTransforming(undefined)).toBeFalsy();
|
||||
expect(isIndexTransforming(0)).toBeTruthy();
|
||||
expect(isIndexTransforming(90)).toBeTruthy();
|
||||
expect(isIndexTransforming(100)).toBeFalsy();
|
||||
});
|
||||
|
||||
describe("getTabTitle", () => {
|
||||
it("returns correct titles for each tab type", () => {
|
||||
expect(getTabTitle(SettingsV2TabTypes.ScaleTab)).toBe("Scale");
|
||||
expect(getTabTitle(SettingsV2TabTypes.ConflictResolutionTab)).toBe("Conflict Resolution");
|
||||
expect(getTabTitle(SettingsV2TabTypes.SubSettingsTab)).toBe("Settings");
|
||||
expect(getTabTitle(SettingsV2TabTypes.IndexingPolicyTab)).toBe("Indexing Policy");
|
||||
expect(getTabTitle(SettingsV2TabTypes.ComputedPropertiesTab)).toBe("Computed Properties");
|
||||
expect(getTabTitle(SettingsV2TabTypes.ContainerVectorPolicyTab)).toBe("Container Policies");
|
||||
expect(getTabTitle(SettingsV2TabTypes.ThroughputBucketsTab)).toBe("Throughput Buckets");
|
||||
expect(getTabTitle(SettingsV2TabTypes.DataMaskingTab)).toBe("Masking Policy (preview)");
|
||||
});
|
||||
|
||||
it("handles partition key tab title based on fabric native", () => {
|
||||
// Assuming initially not fabric native
|
||||
expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys (preview)");
|
||||
});
|
||||
|
||||
it("throws error for unknown tab type", () => {
|
||||
expect(() => getTabTitle(999 as SettingsV2TabTypes)).toThrow("Unknown tab 999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("partition key utils", () => {
|
||||
it("getPartitionKeyName returns correct name based on API type", () => {
|
||||
expect(getPartitionKeyName("Mongo")).toBe("Shard key");
|
||||
expect(getPartitionKeyName("SQL")).toBe("Partition key");
|
||||
expect(getPartitionKeyName("Mongo", true)).toBe("shard key");
|
||||
expect(getPartitionKeyName("SQL", true)).toBe("partition key");
|
||||
});
|
||||
|
||||
it("getPartitionKeyTooltipText returns correct tooltip based on API type", () => {
|
||||
const mongoTooltip = getPartitionKeyTooltipText("Mongo");
|
||||
expect(mongoTooltip).toContain("shard key");
|
||||
expect(mongoTooltip).toContain("replica sets");
|
||||
|
||||
const sqlTooltip = getPartitionKeyTooltipText("SQL");
|
||||
expect(sqlTooltip).toContain("partition key");
|
||||
expect(sqlTooltip).toContain("id is often a good choice");
|
||||
});
|
||||
|
||||
it("getPartitionKeySubtext returns correct subtext", () => {
|
||||
expect(getPartitionKeySubtext(true, "SQL")).toBe(
|
||||
"For small workloads, the item ID is a suitable choice for the partition key.",
|
||||
);
|
||||
expect(getPartitionKeySubtext(true, "Mongo")).toBe(
|
||||
"For small workloads, the item ID is a suitable choice for the partition key.",
|
||||
);
|
||||
expect(getPartitionKeySubtext(false, "SQL")).toBe("");
|
||||
expect(getPartitionKeySubtext(true, "Other")).toBe("");
|
||||
});
|
||||
|
||||
it("getPartitionKeyPlaceHolder returns correct placeholder based on API type", () => {
|
||||
expect(getPartitionKeyPlaceHolder("Mongo")).toBe("e.g., categoryId");
|
||||
expect(getPartitionKeyPlaceHolder("Gremlin")).toBe("e.g., /address");
|
||||
expect(getPartitionKeyPlaceHolder("SQL")).toBe("Required - first partition key e.g., /TenantId");
|
||||
expect(getPartitionKeyPlaceHolder("SQL", 0)).toBe("second partition key e.g., /UserId");
|
||||
expect(getPartitionKeyPlaceHolder("SQL", 1)).toBe("third partition key e.g., /SessionId");
|
||||
expect(getPartitionKeyPlaceHolder("Other")).toBe("e.g., /address/zipCode");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("isIndexingTransforming", () => {
|
||||
expect(isIndexTransforming(undefined)).toBeFalsy();
|
||||
expect(isIndexTransforming(0)).toBeTruthy();
|
||||
expect(isIndexTransforming(90)).toBeTruthy();
|
||||
expect(isIndexTransforming(100)).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ export type isDirtyTypes =
|
||||
| DataModels.ComputedProperties
|
||||
| DataModels.VectorEmbedding[]
|
||||
| DataModels.FullTextPolicy
|
||||
| DataModels.ThroughputBucket[];
|
||||
| DataModels.ThroughputBucket[]
|
||||
| DataModels.DataMaskingPolicy;
|
||||
export const TtlOff = "off";
|
||||
export const TtlOn = "on";
|
||||
export const TtlOnNoDefault = "on-nodefault";
|
||||
@@ -59,6 +60,7 @@ export enum SettingsV2TabTypes {
|
||||
ContainerVectorPolicyTab,
|
||||
ThroughputBucketsTab,
|
||||
GlobalSecondaryIndexTab,
|
||||
DataMaskingTab,
|
||||
}
|
||||
|
||||
export enum ContainerPolicyTabTypes {
|
||||
@@ -175,6 +177,8 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
|
||||
return "Throughput Buckets";
|
||||
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
|
||||
return "Global Secondary Index (Preview)";
|
||||
case SettingsV2TabTypes.DataMaskingTab:
|
||||
return "Masking Policy (preview)";
|
||||
default:
|
||||
throw new Error(`Unknown tab ${tab}`);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ export const collection = {
|
||||
sourceCollectionId: "source1",
|
||||
sourceCollectionRid: "rid123",
|
||||
}),
|
||||
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: true,
|
||||
}),
|
||||
readSettings: () => {
|
||||
return;
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"dataMaskingPolicy": [Function],
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
@@ -155,6 +156,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"dataMaskingPolicy": [Function],
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
@@ -330,6 +332,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"dataMaskingPolicy": [Function],
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
@@ -480,6 +483,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"dataMaskingPolicy": [Function],
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
|
||||
@@ -14,17 +14,30 @@ describe("Collection", () => {
|
||||
defaultTtl: 1,
|
||||
indexingPolicy: {} as DataModels.IndexingPolicy,
|
||||
partitionKey,
|
||||
_rid: "",
|
||||
_self: "",
|
||||
_etag: "",
|
||||
_rid: "testRid",
|
||||
_self: "testSelf",
|
||||
_etag: "testEtag",
|
||||
_ts: 1,
|
||||
id: "",
|
||||
id: "testCollection",
|
||||
};
|
||||
};
|
||||
|
||||
const generateMockCollectionWithDataModel = (data: DataModels.Collection): Collection => {
|
||||
const mockContainer = {} as Explorer;
|
||||
return generateCollection(mockContainer, "abc", data);
|
||||
const mockContainer = {
|
||||
isReadOnly: () => false,
|
||||
isFabricCapable: () => true,
|
||||
databaseAccount: () => ({
|
||||
name: () => "testAccount",
|
||||
id: () => "testAccount",
|
||||
properties: {
|
||||
enablePartitionKey: true,
|
||||
partitionKeyDefinitionVersion: 2,
|
||||
capabilities: [] as string[],
|
||||
databaseAccountEndpoint: "test.documents.azure.com",
|
||||
},
|
||||
}),
|
||||
} as unknown as Explorer;
|
||||
return generateCollection(mockContainer, "testDb", data);
|
||||
};
|
||||
|
||||
describe("Partition key path parsing", () => {
|
||||
@@ -78,7 +91,7 @@ describe("Collection", () => {
|
||||
expect(collection.partitionKeyPropertyHeaders[0]).toBe("/somePartitionKey");
|
||||
});
|
||||
|
||||
it("should be null if there is no partition key", () => {
|
||||
it("should be empty if there is no partition key", () => {
|
||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||
version: 2,
|
||||
paths: [],
|
||||
@@ -88,4 +101,103 @@ describe("Collection", () => {
|
||||
expect(collection.partitionKeyPropertyHeaders.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Collection properties", () => {
|
||||
let collection: Collection;
|
||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||
paths: ["/id"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||
});
|
||||
|
||||
it("should return correct collection id", () => {
|
||||
expect(collection.id()).toBe("testCollection");
|
||||
});
|
||||
|
||||
it("should return correct rid", () => {
|
||||
expect(collection.rid).toBe("testRid");
|
||||
});
|
||||
|
||||
it("should return correct self", () => {
|
||||
expect(collection.self).toBe("testSelf");
|
||||
});
|
||||
|
||||
it("should return correct collection type", () => {
|
||||
expect(collection.partitionKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Collection type", () => {
|
||||
it("should identify large partitioned collection for v2 partition key", () => {
|
||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||
paths: ["/id"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
});
|
||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||
expect(collection.partitionKey.version).toBe(2);
|
||||
expect(collection.partitionKey.paths).toEqual(["/id"]);
|
||||
});
|
||||
|
||||
it("should identify standard partitioned collection for v1 partition key", () => {
|
||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||
paths: ["/id"],
|
||||
kind: "Hash",
|
||||
version: 1,
|
||||
});
|
||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||
expect(collection.partitionKey.version).toBe(1);
|
||||
expect(collection.partitionKey.paths).toEqual(["/id"]);
|
||||
});
|
||||
|
||||
it("should identify collection without partition key", () => {
|
||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||
paths: [],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
});
|
||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||
expect(collection.partitionKey.paths).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Partition key handling", () => {
|
||||
it("should return correct partition key paths for multiple paths", () => {
|
||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||
paths: ["/id", "/pk"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
});
|
||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||
expect(collection.partitionKey.paths).toEqual(["/id", "/pk"]);
|
||||
expect(collection.partitionKeyProperties).toEqual(["id", "pk"]);
|
||||
});
|
||||
|
||||
it("should handle empty partition key paths", () => {
|
||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||
paths: [],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
});
|
||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||
expect(collection.partitionKey.paths).toEqual([]);
|
||||
expect(collection.partitionKeyProperties).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle undefined partition key", () => {
|
||||
const collectionData = generateMockCollectionsDataModelWithPartitionKey({
|
||||
paths: ["/id"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
});
|
||||
delete collectionData.partitionKey;
|
||||
const collection = generateMockCollectionWithDataModel(collectionData);
|
||||
expect(collection.partitionKey).toBeUndefined();
|
||||
expect(collection.partitionKeyProperties).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ export default class Collection implements ViewModels.Collection {
|
||||
public computedProperties: ko.Observable<DataModels.ComputedProperties>;
|
||||
public materializedViews: ko.Observable<DataModels.MaterializedView[]>;
|
||||
public materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
|
||||
public dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
|
||||
|
||||
public offer: ko.Observable<DataModels.Offer>;
|
||||
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
|
||||
@@ -136,25 +137,36 @@ export default class Collection implements ViewModels.Collection {
|
||||
this.materializedViews = ko.observable(data.materializedViews);
|
||||
this.materializedViewDefinition = ko.observable(data.materializedViewDefinition);
|
||||
|
||||
this.partitionKeyPropertyHeaders = this.partitionKey?.paths;
|
||||
this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
|
||||
// TODO fix this to only replace non-excaped single quotes
|
||||
let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "");
|
||||
// Initialize dataMaskingPolicy with default values if not present
|
||||
const defaultDataMaskingPolicy: DataModels.DataMaskingPolicy = {
|
||||
includedPaths: Array<{ path: string; strategy: string; startPosition: number; length: number }>(),
|
||||
excludedPaths: Array<string>(),
|
||||
policyFormatVersion: 2,
|
||||
isPolicyEnabled: false,
|
||||
};
|
||||
const observablePolicy = ko.observable(data.dataMaskingPolicy || defaultDataMaskingPolicy);
|
||||
observablePolicy.subscribe(() => {});
|
||||
this.dataMaskingPolicy = observablePolicy;
|
||||
this.partitionKeyPropertyHeaders = this.partitionKey?.paths || [];
|
||||
this.partitionKeyProperties =
|
||||
this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
|
||||
// TODO fix this to only replace non-excaped single quotes
|
||||
let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "");
|
||||
|
||||
if (userContext.apiType === "Mongo" && partitionKeyProperty) {
|
||||
if (~partitionKeyProperty.indexOf(`"`)) {
|
||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||
if (userContext.apiType === "Mongo" && partitionKeyProperty) {
|
||||
if (~partitionKeyProperty.indexOf(`"`)) {
|
||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||
}
|
||||
// TODO #10738269 : Add this logic in a derived class for Mongo
|
||||
if (partitionKeyProperty.indexOf("$v") > -1) {
|
||||
// From $v.shard.$v.key.$v > shard.key
|
||||
partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, "");
|
||||
this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty;
|
||||
}
|
||||
}
|
||||
// TODO #10738269 : Add this logic in a derived class for Mongo
|
||||
if (partitionKeyProperty.indexOf("$v") > -1) {
|
||||
// From $v.shard.$v.key.$v > shard.key
|
||||
partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, "");
|
||||
this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty;
|
||||
}
|
||||
}
|
||||
|
||||
return partitionKeyProperty;
|
||||
});
|
||||
return partitionKeyProperty;
|
||||
}) || [];
|
||||
|
||||
this.documentIds = ko.observableArray<DocumentId>([]);
|
||||
this.isCollectionExpanded = ko.observable<boolean>(false);
|
||||
@@ -163,7 +175,6 @@ export default class Collection implements ViewModels.Collection {
|
||||
|
||||
this.documentsFocused = ko.observable<boolean>();
|
||||
this.documentsFocused.subscribe((focus) => {
|
||||
console.log("Focus set on Documents: " + focus);
|
||||
this.focusedSubnodeKind(ViewModels.CollectionTabKind.Documents);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user