diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx index c8650b988..8a2c8f19e 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx @@ -121,7 +121,12 @@ export class ComputedPropertiesComponent extends React.Component<   about how to define computed properties and how to use them. -
+
); } diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx index 8def1ae78..c2a0322c5 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx @@ -268,8 +268,8 @@ export class SubSettingsComponent extends React.Component ( diff --git a/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx b/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx index b2f8fe590..234fcf3ed 100644 --- a/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx +++ b/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx @@ -3,12 +3,12 @@ import { DetailsListLayoutMode, DirectionalHint, FontIcon, - IColumn, - SelectionMode, - TooltipHost, getTheme, + IColumn, mergeStyles, mergeStyleSets, + SelectionMode, + TooltipHost, } from "@fluentui/react"; import { Upload } from "Common/Upload/Upload"; import { UploadDetailsRecord } from "Contracts/ViewModels"; @@ -204,7 +204,7 @@ export const UploadItemsPane: FunctionComponent = ({ onUpl tooltip="Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB. You can perform multiple upload operations for larger data sets." /> {uploadFileData?.length > 0 && ( -
+
File upload status { + let context: TestContainerContext = null!; + const uploadDocumentFilePath: string = path.join(__dirname, "uploadDocument.json"); + + test.beforeEach("Create Test Database and Open documents tab", async ({ page }) => { + context = await createTestSQLContainer(); + explorer = await DataExplorer.open(page, TestAccount.SQL); + + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + + const containerMenuNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id); + await containerMenuNode.element.click(); + }); + + test.afterEach("Delete Test Database and Upload Document Temp Directory", async () => { + if (existsSync(uploadDocumentFilePath)) { + // Delete the temp directory after test + unlinkSync(uploadDocumentFilePath); + } + await context?.dispose(); + }); + + test("upload document", async ({}, testInfo) => { + // Create file to upload + const TestDataJsonString: string = JSON.stringify(TestData, null, 2); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload success message + const fileUploadStatusExpected: string = `${partitionCount * itemsPerPartition} created, 0 throttled, 0 errors`; + const fileUploadStatus = explorer.frame.getByTestId("file-upload-status"); + await expect(fileUploadStatus).toContainText(fileUploadStatusExpected, { + timeout: ONE_MINUTE_MS, + }); + }); + + test("upload same document twice", async ({}, testInfo) => { + // Create file to upload + const TestDataJsonString: string = JSON.stringify(TestData, null, 2); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload success message + const fileUploadStatusExpected: string = `${partitionCount * itemsPerPartition} created, 0 throttled, 0 errors`; + const fileUploadStatus = explorer.frame.getByTestId("file-upload-status"); + await expect(fileUploadStatus).toContainText(fileUploadStatusExpected, { + timeout: ONE_MINUTE_MS, + }); + + // Select file to upload again + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + await uploadButton.click(); + + // Verify upload failure message + const errorIcon = explorer.frame.getByRole("img", { name: "error" }); + await expect(errorIcon).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(fileUploadStatus).toContainText( + `0 created, 0 throttled, ${partitionCount * itemsPerPartition} errors`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("upload invalid json", async ({}, testInfo) => { + // Create file to upload + let TestDataJsonString: string = JSON.stringify(TestData, null, 2); + // Remove the first '[' so that it becomes invalid json + TestDataJsonString = TestDataJsonString.substring(1); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload failure message + const fileUploadStatusExpected: string = "Unexpected non-whitespace character after JSON"; + const fileUploadErrorList = explorer.frame.getByLabel("error list"); + await expect(fileUploadErrorList).toContainText(fileUploadStatusExpected, { + timeout: ONE_MINUTE_MS, + }); + }); +}); diff --git a/test/sql/scaleAndSettings/computedProperties.spec.ts b/test/sql/scaleAndSettings/computedProperties.spec.ts new file mode 100644 index 000000000..eaeede054 --- /dev/null +++ b/test/sql/scaleAndSettings/computedProperties.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; +import * as DataModels from "../../../src/Contracts/DataModels"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Computed Properties", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(true); + }); + + test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + + // Click Scale & Settings and open Settings tab + await explorer.openScaleAndSettings(context); + const computedPropertiesTab = explorer.frame.getByTestId("settings-tab-header/ComputedPropertiesTab"); + await computedPropertiesTab.click(); + }); + + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + + test("Add valid computed property", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT VALUE LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, { + timeout: ONE_MINUTE_MS, + }); + }); + + test("Add computed property with invalid query", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property with no VALUE keyword in query + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleMessage()).toContainText(`Failed to update container ${context.container.id}`, { + timeout: ONE_MINUTE_MS, + }); + }); + + test("Add computed property with invalid json", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property with no VALUE keyword in query + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString + "]"); + + // Save button should remain disabled due to invalid json + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeDisabled(); + }); + + const clearComputedPropertiesTextBoxContent = async ({ page }): Promise => { + // Get computed properties text box + const computedPropertiesTextBox = explorer.frame.getByRole("textbox", { name: "Computed properties" }); + await computedPropertiesTextBox.waitFor(); + const computedPropertiesEditor = explorer.frame.getByTestId("computed-properties-editor"); + await computedPropertiesEditor.click(); + + // Clear existing content + const isMac: boolean = process.platform === "darwin"; + await page.keyboard.press(isMac ? "Meta+A" : "Control+A"); + await page.keyboard.press("Backspace"); + }; +}); diff --git a/test/sql/scaleAndSettings/settings.spec.ts b/test/sql/scaleAndSettings/settings.spec.ts index 463cdacb7..3b00d31c2 100644 --- a/test/sql/scaleAndSettings/settings.spec.ts +++ b/test/sql/scaleAndSettings/settings.spec.ts @@ -15,7 +15,7 @@ test.describe("Settings under Scale & Settings", () => { const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); await containerNode.expand(); - // Click Scale & Settings and open Scale tab + // Click Scale & Settings and open Settings tab await explorer.openScaleAndSettings(context); const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab"); await settingsTab.click(); @@ -25,46 +25,86 @@ test.describe("Settings under Scale & Settings", () => { await context?.dispose(); }); - test("Update TTL to On (no default)", async () => { - const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" }); - await ttlOnNoDefaultRadioButton.click(); + test.describe("Set TTL", () => { + test("Update TTL to On (no default)", async () => { + const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" }); + await ttlOnNoDefaultRadioButton.click(); - await explorer.commandBarButton(CommandBarButton.Save).click(); - await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, { - timeout: ONE_MINUTE_MS, + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleMessage()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("Update TTL to On (with user entry)", async () => { + const ttlOnRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-option" }); + await ttlOnRadioButton.click(); + + // Enter TTL seconds + const ttlInput = explorer.frame.getByTestId("ttl-input"); + await ttlInput.fill("30000"); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleMessage()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("Update TTL to Off", async () => { + // By default TTL is set to off so we need to first set it to On + const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" }); + await ttlOnNoDefaultRadioButton.click(); + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleMessage()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + + // Set it to Off + const ttlOffRadioButton = explorer.frame.getByRole("radio", { name: "ttl-off-option" }); + await ttlOffRadioButton.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleMessage()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); }); }); - test("Update TTL to On (with user entry)", async () => { - const ttlOnRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-option" }); - await ttlOnRadioButton.click(); + test.describe("Set Geospatial Config", () => { + test("Set Geospatial Config to Geometry then Geography", async () => { + const geometryRadioButton = explorer.frame.getByRole("radio", { name: "geometry-option" }); + await geometryRadioButton.click(); - // Enter TTL seconds - const ttlInput = explorer.frame.getByTestId("ttl-input"); - await ttlInput.fill("30000"); + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleMessage()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); - await explorer.commandBarButton(CommandBarButton.Save).click(); - await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, { - timeout: ONE_MINUTE_MS, - }); - }); + const geographyRadioButton = explorer.frame.getByRole("radio", { name: "geography-option" }); + await geographyRadioButton.click(); - test("Update TTL to Off", async () => { - // By default TTL is set to off so we need to first set it to On - const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" }); - await ttlOnNoDefaultRadioButton.click(); - await explorer.commandBarButton(CommandBarButton.Save).click(); - await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, { - timeout: ONE_MINUTE_MS, - }); - - // Set it to Off - const ttlOffRadioButton = explorer.frame.getByRole("radio", { name: "ttl-off-option" }); - await ttlOffRadioButton.click(); - - await explorer.commandBarButton(CommandBarButton.Save).click(); - await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, { - timeout: ONE_MINUTE_MS, + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleMessage()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); }); }); }); diff --git a/test/testData.ts b/test/testData.ts index 94d38941f..352b5cd94 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -37,27 +37,35 @@ export interface PartitionKey { value: string | null; } -const partitionCount = 4; +export const partitionCount = 4; // If we increase this number, we need to split bulk creates into multiple batches. // Bulk operations are limited to 100 items per partition. -const itemsPerPartition = 100; +export const itemsPerPartition = 100; function createTestItems(): TestItem[] { const items: TestItem[] = []; for (let i = 0; i < partitionCount; i++) { for (let j = 0; j < itemsPerPartition; j++) { - const id = crypto.randomBytes(32).toString("base64"); + const id = createSafeRandomString(32); items.push({ id, partitionKey: `partition_${i}`, - randomData: crypto.randomBytes(32).toString("base64"), + randomData: createSafeRandomString(32), }); } } return items; } +// Document IDs cannot contain '/', '\', or '#' +function createSafeRandomString(byteLength: number): string { + return crypto + .randomBytes(byteLength) + .toString("base64") + .replace(/[\/\\#]/g, "_"); +} + export const TestData: TestItem[] = createTestItems(); export class TestContainerContext {