diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index fef85ab87..0510a7c23 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -275,8 +275,7 @@ export interface DataMaskingPolicy { startPosition: number; length: number; }>; - excludedPaths: string[]; - isPolicyEnabled: boolean; + excludedPaths?: string[]; } export interface MaterializedView { diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index 7dfd49b11..ff7485b5e 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -30,7 +30,6 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({ dataMaskingPolicy: { includedPaths: [], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }, indexes: [], }), @@ -307,12 +306,10 @@ describe("SettingsComponent", () => { dataMaskingContent: { includedPaths: [], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }, dataMaskingContentBaseline: { includedPaths: [], excludedPaths: [], - isPolicyEnabled: false, }, isDataMaskingDirty: true, }); @@ -326,7 +323,6 @@ describe("SettingsComponent", () => { expect(wrapper.state("dataMaskingContentBaseline")).toEqual({ includedPaths: [], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }); }); @@ -340,7 +336,6 @@ describe("SettingsComponent", () => { const invalidPolicy: InvalidPolicy = { includedPaths: "invalid", excludedPaths: [], - isPolicyEnabled: false, }; // Use type assertion since we're deliberately testing with invalid data settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy); @@ -349,7 +344,6 @@ describe("SettingsComponent", () => { expect(wrapper.state("dataMaskingContent")).toEqual({ includedPaths: "invalid", excludedPaths: [], - isPolicyEnabled: false, }); expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]); @@ -364,7 +358,6 @@ describe("SettingsComponent", () => { }, ], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }; settingsComponentInstance["onDataMaskingContentChange"](validPolicy); @@ -388,7 +381,6 @@ describe("SettingsComponent", () => { }, ], excludedPaths: ["/excludedPath1"], - isPolicyEnabled: false, }; const modifiedPolicy = { @@ -401,7 +393,6 @@ describe("SettingsComponent", () => { }, ], excludedPaths: ["/excludedPath2"], - isPolicyEnabled: true, }; // Set initial state diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 95f7159cc..9f50b53c2 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -16,7 +16,7 @@ import { import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView"; import { useDatabases } from "Explorer/useDatabases"; import { isFabricNative } from "Platform/Fabric/FabricUtil"; -import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils"; +import { isVectorSearchEnabled } from "Utils/CapabilityUtils"; import { isRunningOnPublicCloud } from "Utils/CloudUtils"; import * as React from "react"; import DiscardIcon from "../../../../images/discard.svg"; @@ -70,6 +70,7 @@ import { getMongoNotification, getTabTitle, hasDatabaseSharedThroughput, + isDataMaskingEnabled, isDirty, parseConflictResolutionMode, parseConflictResolutionProcedure, @@ -686,22 +687,14 @@ export class SettingsComponent extends React.Component { - if (!newDataMasking.excludedPaths) { - newDataMasking.excludedPaths = []; - } - if (!newDataMasking.includedPaths) { - newDataMasking.includedPaths = []; - } - const validationErrors = []; - if (!Array.isArray(newDataMasking.includedPaths)) { + if (newDataMasking.includedPaths === undefined || newDataMasking.includedPaths === null) { + validationErrors.push("includedPaths is required"); + } else 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.isPolicyEnabled !== "boolean") { - validationErrors.push("isPolicyEnabled must be a boolean"); + if (newDataMasking.excludedPaths !== undefined && !Array.isArray(newDataMasking.excludedPaths)) { + validationErrors.push("excludedPaths must be an array if provided"); } this.setState({ @@ -842,7 +835,6 @@ export class SettingsComponent extends React.Component { - const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking); - const isSqlAccount = userContext.apiType === "SQL"; - - return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability - }; - - if (shouldEnableDDM()) { + if (isDataMaskingEnabled(this.collection.dataMaskingPolicy?.())) { const dataMaskingComponentProps: DataMaskingComponentProps = { shouldDiscardDataMasking: this.state.shouldDiscardDataMasking, resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking, diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx index a51d55a32..4e25c1980 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx @@ -53,7 +53,6 @@ describe("DataMaskingComponent", () => { }, ], excludedPaths: [], - isPolicyEnabled: false, }; let changeContentCallback: () => void; @@ -78,7 +77,7 @@ describe("DataMaskingComponent", () => { , ); @@ -123,7 +122,7 @@ describe("DataMaskingComponent", () => { }); it("resets content when shouldDiscardDataMasking is true", async () => { - const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true }; + const baselinePolicy = { ...samplePolicy, excludedPaths: ["/excluded"] }; const wrapper = mount( { wrapper.update(); // Update baseline to trigger componentDidUpdate - const newBaseline = { ...samplePolicy, isPolicyEnabled: true }; + const newBaseline = { ...samplePolicy, excludedPaths: ["/excluded"] }; wrapper.setProps({ dataMaskingContentBaseline: newBaseline }); expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true); @@ -174,7 +173,6 @@ describe("DataMaskingComponent", () => { const invalidPolicy: Record = { includedPaths: "not an array", excludedPaths: [] as string[], - isPolicyEnabled: "not a boolean", }; mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy)); @@ -197,7 +195,7 @@ describe("DataMaskingComponent", () => { wrapper.update(); // First change - const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true }; + const modifiedPolicy1 = { ...samplePolicy, excludedPaths: ["/path1"] }; mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1)); changeContentCallback(); expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx index 80314fe7c..61ac40931 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx @@ -1,12 +1,10 @@ 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"; +import { isDirty as isContentDirty, isDataMaskingEnabled } from "../SettingsUtils"; export interface DataMaskingComponentProps { shouldDiscardDataMasking: boolean; @@ -24,16 +22,8 @@ interface DataMaskingComponentState { } const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = { - includedPaths: [ - { - path: "/", - strategy: "Default", - startPosition: 0, - length: -1, - }, - ], + includedPaths: [], excludedPaths: [], - isPolicyEnabled: true, }; export class DataMaskingComponent extends React.Component { @@ -140,7 +130,7 @@ export class DataMaskingComponent extends React.Component { + const isSqlAccount = userContext.apiType === "SQL"; + if (!isSqlAccount) { + return false; + } + + const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking); + const hasDataMaskingPolicyFromCollection = + dataMaskingPolicy?.includedPaths?.length > 0 || dataMaskingPolicy?.excludedPaths?.length > 0; + + return hasDataMaskingCapability || hasDataMaskingPolicyFromCollection; +}; + export const parseConflictResolutionMode = (modeFromBackend: string): DataModels.ConflictResolutionMode => { // Backend can contain different casing as it does case-insensitive comparisson if (!modeFromBackend) { diff --git a/src/Explorer/Controls/Settings/TestUtils.tsx b/src/Explorer/Controls/Settings/TestUtils.tsx index f30e84709..c3b3f8b84 100644 --- a/src/Explorer/Controls/Settings/TestUtils.tsx +++ b/src/Explorer/Controls/Settings/TestUtils.tsx @@ -68,7 +68,6 @@ export const collection = { dataMaskingPolicy: ko.observable({ includedPaths: [], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }), readSettings: () => { return; diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 569bfd035..7f8452ddf 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -604,6 +604,58 @@ exports[`SettingsComponent renders 1`] = ` /> + + + + + (), excludedPaths: Array(), - isPolicyEnabled: true, }; const observablePolicy = ko.observable(data.dataMaskingPolicy || defaultDataMaskingPolicy); observablePolicy.subscribe(() => {}); diff --git a/test/fx.ts b/test/fx.ts index f9313b6ca..9c8c382a3 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -58,7 +58,9 @@ export const defaultAccounts: Record = { export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests"; export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c"; export const TEST_AUTOSCALE_THROUGHPUT_RU = 1000; +export const TEST_MANUAL_THROUGHPUT_RU = 800; export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K = 2000; +export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K = 4000; export const TEST_MANUAL_THROUGHPUT_RU_2K = 2000; export const ONE_MINUTE_MS: number = 60 * 1000; diff --git a/test/sql/scaleAndSettings/dataMasking.spec.ts b/test/sql/scaleAndSettings/dataMasking.spec.ts new file mode 100644 index 000000000..0c076554f --- /dev/null +++ b/test/sql/scaleAndSettings/dataMasking.spec.ts @@ -0,0 +1,127 @@ +import { expect, test, type Page } from "@playwright/test"; +import { DataExplorer, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +/** + * Tests for Dynamic Data Masking (DDM) feature. + * + * Prerequisites: + * - Test account must have the EnableDynamicDataMasking capability enabled + * - If the capability is not enabled, the DataMaskingTab will not be visible and tests will be skipped + * + * Important Notes: + * - Tests focus on enabling DDM and modifying the masking policy configuration + */ + +let testContainer: TestContainerContext; +let DATABASE_ID: string; +let CONTAINER_ID: string; + +test.beforeAll(async () => { + testContainer = await createTestSQLContainer(); + DATABASE_ID = testContainer.database.id; + CONTAINER_ID = testContainer.container.id; +}); + +// Clean up test database after all tests +test.afterAll(async () => { + if (testContainer) { + await testContainer.dispose(); + } +}); + +// Helper function to navigate to Data Masking tab +async function navigateToDataMaskingTab(page: Page, explorer: DataExplorer): Promise { + // Refresh the tree to see the newly created database + const refreshButton = explorer.frame.getByTestId("Sidebar/RefreshButton"); + await refreshButton.click(); + await page.waitForTimeout(3000); + + // Expand database and container nodes + const databaseNode = await explorer.waitForNode(DATABASE_ID); + await databaseNode.expand(); + await page.waitForTimeout(2000); + + const containerNode = await explorer.waitForNode(`${DATABASE_ID}/${CONTAINER_ID}`); + await containerNode.expand(); + await page.waitForTimeout(1000); + + // Click Scale & Settings or Settings (depending on container type) + let settingsNode = explorer.frame.getByTestId(`TreeNode:${DATABASE_ID}/${CONTAINER_ID}/Scale & Settings`); + const isScaleAndSettings = await settingsNode.isVisible().catch(() => false); + + if (!isScaleAndSettings) { + settingsNode = explorer.frame.getByTestId(`TreeNode:${DATABASE_ID}/${CONTAINER_ID}/Settings`); + } + + await settingsNode.click(); + await page.waitForTimeout(2000); + + // Check if Data Masking tab is available + const dataMaskingTab = explorer.frame.getByTestId("settings-tab-header/DataMaskingTab"); + const isTabVisible = await dataMaskingTab.isVisible().catch(() => false); + + if (!isTabVisible) { + return false; + } + + await dataMaskingTab.click(); + await page.waitForTimeout(1000); + return true; +} + +test.describe("Data Masking under Scale & Settings", () => { + test("Data Masking tab should be visible and show JSON editor", async ({ page }) => { + const explorer = await DataExplorer.open(page, TestAccount.SQL); + const isTabAvailable = await navigateToDataMaskingTab(page, explorer); + + if (!isTabAvailable) { + test.skip( + true, + "Data Masking tab is not available. Test account may not have EnableDynamicDataMasking capability.", + ); + } + + // Verify the Data Masking editor is visible + const dataMaskingEditor = explorer.frame.locator(".settingsV2Editor"); + await expect(dataMaskingEditor).toBeVisible(); + }); + + test("Data Masking editor should contain default policy structure", async ({ page }) => { + const explorer = await DataExplorer.open(page, TestAccount.SQL); + const isTabAvailable = await navigateToDataMaskingTab(page, explorer); + + if (!isTabAvailable) { + test.skip( + true, + "Data Masking tab is not available. Test account may not have EnableDynamicDataMasking capability.", + ); + } + + // Verify the editor contains the expected JSON structure fields + const editorContent = explorer.frame.locator(".settingsV2Editor"); + await expect(editorContent).toBeVisible(); + + // Check that the editor contains key policy fields (default policy has empty arrays) + await expect(editorContent).toContainText("includedPaths"); + await expect(editorContent).toContainText("excludedPaths"); + }); + + test("Data Masking editor should have correct default policy values", async ({ page }) => { + const explorer = await DataExplorer.open(page, TestAccount.SQL); + const isTabAvailable = await navigateToDataMaskingTab(page, explorer); + + if (!isTabAvailable) { + test.skip( + true, + "Data Masking tab is not available. Test account may not have EnableDynamicDataMasking capability.", + ); + } + + const editorContent = explorer.frame.locator(".settingsV2Editor"); + await expect(editorContent).toBeVisible(); + + // Default policy should have empty includedPaths and excludedPaths arrays + await expect(editorContent).toContainText("[]"); + }); +}); diff --git a/test/sql/scaleAndSettings/sharedThroughput.spec.ts b/test/sql/scaleAndSettings/sharedThroughput.spec.ts new file mode 100644 index 000000000..d1c7d4c90 --- /dev/null +++ b/test/sql/scaleAndSettings/sharedThroughput.spec.ts @@ -0,0 +1,229 @@ +import { Locator, expect, test } from "@playwright/test"; +import { + CommandBarButton, + DataExplorer, + ONE_MINUTE_MS, + TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K, + TEST_MANUAL_THROUGHPUT_RU, + TestAccount, +} from "../../fx"; +import { TestDatabaseContext, createTestDB } from "../../testData"; + +test.describe("Database with Shared Throughput", () => { + let dbContext: TestDatabaseContext = null!; + let explorer: DataExplorer = null!; + const containerId = "sharedcontainer"; + + // Helper methods + const getThroughputInput = (type: "manual" | "autopilot"): Locator => { + return explorer.frame.getByTestId(`${type}-throughput-input`); + }; + + test.afterEach("Delete Test Database", async () => { + await dbContext?.dispose(); + }); + + test.describe("Manual Throughput Tests", () => { + test.beforeEach(async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + test("Create database with shared manual throughput and verify Scale node in UI", async () => { + test.setTimeout(120000); // 2 minutes timeout + // Create database with shared manual throughput (400 RU/s) + dbContext = await createTestDB({ throughput: 400 }); + + // Verify database node appears in the tree + const databaseNode = await explorer.waitForNode(dbContext.database.id); + expect(databaseNode).toBeDefined(); + + // Expand the database node to see child nodes + await databaseNode.expand(); + + // Verify that "Scale" node appears under the database + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + expect(scaleNode).toBeDefined(); + await expect(scaleNode.element).toBeVisible(); + }); + + test("Add container to shared database without dedicated throughput", async () => { + // Create database with shared manual throughput + dbContext = await createTestDB({ throughput: 400 }); + + // Wait for the database to appear in the tree + await explorer.waitForNode(dbContext.database.id); + + // Add a container to the shared database via UI + const newContainerButton = await explorer.globalCommandButton("New Container"); + await newContainerButton.click(); + + await explorer.whilePanelOpen( + "New Container", + async (panel, okButton) => { + // Select "Use existing" database + const useExistingRadio = panel.getByRole("radio", { name: /Use existing/i }); + await useExistingRadio.click(); + + // Select the database from dropdown using the new data-testid + const databaseDropdown = panel.getByRole("combobox", { name: "Choose an existing database" }); + await databaseDropdown.click(); + + await explorer.frame.getByRole("option", { name: dbContext.database.id }).click(); + // Now you can target the specific database option by its data-testid + //await panel.getByTestId(`database-option-${dbContext.database.id}`).click(); + // Fill container id + await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId); + + // Fill partition key + await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk"); + + // Ensure "Provision dedicated throughput" is NOT checked + const dedicatedThroughputCheckbox = panel.getByRole("checkbox", { + name: /Provision dedicated throughput for this container/i, + }); + + if (await dedicatedThroughputCheckbox.isVisible()) { + const isChecked = await dedicatedThroughputCheckbox.isChecked(); + if (isChecked) { + await dedicatedThroughputCheckbox.uncheck(); + } + } + + await okButton.click(); + }, + { closeTimeout: 5 * ONE_MINUTE_MS }, + ); + + // Verify container was created under the database + const containerNode = await explorer.waitForContainerNode(dbContext.database.id, containerId); + expect(containerNode).toBeDefined(); + }); + + test("Scale shared database manual throughput", async () => { + // Create database with shared manual throughput (400 RU/s) + dbContext = await createTestDB({ throughput: 400 }); + + // Navigate to the scale settings by clicking the "Scale" node in the tree + const databaseNode = await explorer.waitForNode(dbContext.database.id); + await databaseNode.expand(); + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + await scaleNode.element.click(); + + // Update manual throughput from 400 to 800 + await getThroughputInput("manual").fill(TEST_MANUAL_THROUGHPUT_RU.toString()); + + // Save changes + await explorer.commandBarButton(CommandBarButton.Save).click(); + + // Verify success message + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for database ${dbContext.database.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + + test("Scale shared database from manual to autoscale", async () => { + // Create database with shared manual throughput (400 RU/s) + dbContext = await createTestDB({ throughput: 400 }); + + // Open database settings by clicking the "Scale" node + const databaseNode = await explorer.waitForNode(dbContext.database.id); + await databaseNode.expand(); + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + await scaleNode.element.click(); + + // Switch to Autoscale + const autoscaleRadio = explorer.frame.getByText("Autoscale", { exact: true }); + await autoscaleRadio.click(); + + // Set autoscale max throughput to 1000 + //await getThroughputInput("autopilot").fill(TEST_AUTOSCALE_THROUGHPUT_RU.toString()); + + // Save changes + await explorer.commandBarButton(CommandBarButton.Save).click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for database ${dbContext.database.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + }); + + test.describe("Autoscale Throughput Tests", () => { + test.beforeEach(async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + test("Create database with shared autoscale throughput and verify Scale node in UI", async () => { + test.setTimeout(120000); // 2 minutes timeout + + // Create database with shared autoscale throughput (max 1000 RU/s) + dbContext = await createTestDB({ maxThroughput: 1000 }); + + // Verify database node appears + const databaseNode = await explorer.waitForNode(dbContext.database.id); + expect(databaseNode).toBeDefined(); + + // Expand the database node to see child nodes + await databaseNode.expand(); + + // Verify that "Scale" node appears under the database + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + expect(scaleNode).toBeDefined(); + await expect(scaleNode.element).toBeVisible(); + }); + + test("Scale shared database autoscale throughput", async () => { + // Create database with shared autoscale throughput (max 1000 RU/s) + dbContext = await createTestDB({ maxThroughput: 1000 }); + + // Open database settings + const databaseNode = await explorer.waitForNode(dbContext.database.id); + await databaseNode.expand(); + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + await scaleNode.element.click(); + + // Update autoscale max throughput from 1000 to 4000 + await getThroughputInput("autopilot").fill(TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K.toString()); + + // Save changes + await explorer.commandBarButton(CommandBarButton.Save).click(); + + // Verify success message + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for database ${dbContext.database.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + + test("Scale shared database from autoscale to manual", async () => { + // Create database with shared autoscale throughput (max 1000 RU/s) + dbContext = await createTestDB({ maxThroughput: 1000 }); + + // Open database settings + const databaseNode = await explorer.waitForNode(dbContext.database.id); + await databaseNode.expand(); + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + await scaleNode.element.click(); + + // Switch to Manual + const manualRadio = explorer.frame.getByText("Manual", { exact: true }); + await manualRadio.click(); + + // Save changes + await explorer.commandBarButton(CommandBarButton.Save).click(); + + // Verify success message + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for database ${dbContext.database.id}`, + { timeout: 2 * ONE_MINUTE_MS }, + ); + }); + }); +}); diff --git a/test/testData.ts b/test/testData.ts index 7e5a1f26c..6d892cc60 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -82,6 +82,75 @@ export class TestContainerContext { } } +export class TestDatabaseContext { + constructor( + public armClient: CosmosDBManagementClient, + public client: CosmosClient, + public database: Database, + ) {} + + async dispose() { + await this.database.delete(); + } +} + +export interface CreateTestDBOptions { + throughput?: number; + maxThroughput?: number; // For autoscale +} + +// Helper function to create ARM client and Cosmos client for SQL account +async function createCosmosClientForSQLAccount( + accountType: TestAccount.SQL | TestAccount.SQLContainerCopyOnly = TestAccount.SQL, +): Promise<{ armClient: CosmosDBManagementClient; client: CosmosClient }> { + const credentials = getAzureCLICredentials(); + const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); + const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); + const accountName = getAccountName(accountType); + const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + + const clientOptions: CosmosClientOptions = { + endpoint: account.documentEndpoint!, + }; + + const rbacToken = + accountType === TestAccount.SQL + ? process.env.NOSQL_TESTACCOUNT_TOKEN + : accountType === TestAccount.SQLContainerCopyOnly + ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN + : ""; + + if (rbacToken) { + clientOptions.tokenProvider = async (): Promise => { + const AUTH_PREFIX = `type=aad&ver=1.0&sig=`; + const authorizationToken = `${AUTH_PREFIX}${rbacToken}`; + return authorizationToken; + }; + } else { + const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); + clientOptions.key = keys.primaryMasterKey; + } + + const client = new CosmosClient(clientOptions); + + return { armClient, client }; +} + +export async function createTestDB(options?: CreateTestDBOptions): Promise { + const databaseId = generateUniqueName("db"); + const { armClient, client } = await createCosmosClientForSQLAccount(); + + // Create database with provisioned throughput (shared throughput) + // This checks the "Provision database throughput" option + const { database } = await client.databases.create({ + id: databaseId, + throughput: options?.throughput, // Manual throughput (e.g., 400) + maxThroughput: options?.maxThroughput, // Autoscale max throughput (e.g., 1000) + }); + + return new TestDatabaseContext(armClient, client, database); +} + type createTestSqlContainerConfig = { includeTestData?: boolean; partitionKey?: string; @@ -104,34 +173,7 @@ export async function createMultipleTestContainers({ const creationPromises: Promise[] = []; const databaseId = databaseName ? databaseName : generateUniqueName("db"); - const credentials = getAzureCLICredentials(); - const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); - const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); - const accountName = getAccountName(accountType); - const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); - - const clientOptions: CosmosClientOptions = { - endpoint: account.documentEndpoint!, - }; - - const rbacToken = - accountType === TestAccount.SQL - ? process.env.NOSQL_TESTACCOUNT_TOKEN - : accountType === TestAccount.SQLContainerCopyOnly - ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN - : ""; - if (rbacToken) { - clientOptions.tokenProvider = async (): Promise => { - const AUTH_PREFIX = `type=aad&ver=1.0&sig=`; - const authorizationToken = `${AUTH_PREFIX}${rbacToken}`; - return authorizationToken; - }; - } else { - const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); - clientOptions.key = keys.primaryMasterKey; - } - - const client = new CosmosClient(clientOptions); + const { armClient, client } = await createCosmosClientForSQLAccount(accountType); const { database } = await client.databases.createIfNotExists({ id: databaseId }); try { @@ -158,29 +200,8 @@ export async function createTestSQLContainer({ }: createTestSqlContainerConfig = {}) { const databaseId = databaseName ? databaseName : generateUniqueName("db"); const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique - const credentials = getAzureCLICredentials(); - const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); - const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); - const accountName = getAccountName(TestAccount.SQL); - const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + const { armClient, client } = await createCosmosClientForSQLAccount(); - const clientOptions: CosmosClientOptions = { - endpoint: account.documentEndpoint!, - }; - - const nosqlAccountRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN; - if (nosqlAccountRbacToken) { - clientOptions.tokenProvider = async (): Promise => { - const AUTH_PREFIX = `type=aad&ver=1.0&sig=`; - const authorizationToken = `${AUTH_PREFIX}${nosqlAccountRbacToken}`; - return authorizationToken; - }; - } else { - const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); - clientOptions.key = keys.primaryMasterKey; - } - - const client = new CosmosClient(clientOptions); const { database } = await client.databases.createIfNotExists({ id: databaseId }); try { const { container } = await database.containers.createIfNotExists({