From bf1e1eb2e2800c47616b50d4e94e1340b46b5fc0 Mon Sep 17 00:00:00 2001 From: Karen Grigoryan Date: Sun, 22 Mar 2026 03:27:05 +0100 Subject: [PATCH] [Snapshot Restore] Migrate flaky repository_add integration test to unit tests - Removes flaky `repository_add.test.ts` client integration test - Adds pure unit tests for `validateRepository` (24 tests) - Adds unit tests for `RepositoryFormStepOne` (10 tests) - Adds unit tests for `RepositoryForm` orchestration and payloads (14 tests) - Adds unit test for `RepositoryAdd` page title (1 test) - Unskips previously flaky settings validation (issue #248548) Closes #248548 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client_integration/repository_add.test.ts | 714 ------------------ .../repository_form/repository_form.test.tsx | 419 ++++++++++ .../repository_form/step_one.test.tsx | 228 ++++++ .../components/repository_form/step_one.tsx | 8 +- .../repository_form/type_settings/index.tsx | 3 +- .../repository_add/repository_add.test.tsx | 198 +++++ .../repository_add/repository_add.tsx | 5 +- .../application/services/http/use_request.ts | 2 +- .../validation/validate_repository.test.ts | 199 +++++ 9 files changed, 1054 insertions(+), 722 deletions(-) delete mode 100644 x-pack/platform/plugins/private/snapshot_restore/__jest__/client_integration/repository_add.test.ts create mode 100644 x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/repository_form.test.tsx create mode 100644 x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/step_one.test.tsx create mode 100644 x-pack/platform/plugins/private/snapshot_restore/public/application/sections/repository_add/repository_add.test.tsx create mode 100644 x-pack/platform/plugins/private/snapshot_restore/public/application/services/validation/validate_repository.test.ts diff --git a/x-pack/platform/plugins/private/snapshot_restore/__jest__/client_integration/repository_add.test.ts b/x-pack/platform/plugins/private/snapshot_restore/__jest__/client_integration/repository_add.test.ts deleted file mode 100644 index 47948879fa7a0..0000000000000 --- a/x-pack/platform/plugins/private/snapshot_restore/__jest__/client_integration/repository_add.test.ts +++ /dev/null @@ -1,714 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import './helpers/mocks'; - -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { INVALID_NAME_CHARS } from '../../public/application/services/validation/validate_repository'; -import { API_BASE_PATH } from '../../common'; -import { getRepository } from '../../test/fixtures'; -import type { RepositoryType } from '../../common/types'; -import { setupEnvironment } from './helpers/setup_environment'; -import { renderApp } from './helpers/render_app'; - -const repositoryTypes = ['fs', 'url', 'source', 'azure', 'gcs', 's3', 'hdfs']; - -interface Deferred { - promise: Promise; - resolve: (value: T) => void; - reject: (reason?: unknown) => void; -} - -const createDeferred = (): Deferred => { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; -}; - -const waitForRepositoryTypesToLoad = async () => { - await waitFor(() => { - expect(screen.queryByTestId('sectionLoading')).not.toBeInTheDocument(); - }); - await screen.findByTestId(`${repositoryTypes[0]}RepositoryType`); -}; - -const waitForNoRepositoryTypesError = async () => { - await waitFor(() => { - expect(screen.queryByTestId('sectionLoading')).not.toBeInTheDocument(); - }); - await screen.findByTestId('noRepositoryTypesError'); -}; - -describe('', () => { - const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('on component mount', () => { - beforeEach(async () => { - httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes); - renderApp(httpSetup, { initialEntries: ['/add_repository'] }); - await waitForRepositoryTypesToLoad(); - }); - - test('should set the correct page title', async () => { - const pageTitle = await screen.findByTestId('pageTitle'); - expect(pageTitle).toHaveTextContent('Register repository'); - }); - - test('should not let the user go to the next step if some fields are missing', async () => { - await screen.findByTestId('nextButton'); - fireEvent.click(screen.getByTestId('nextButton')); - - // Error text can appear both in the form-wide error summary and the field-level error. - expect(screen.getAllByText('Repository name is required.').length).toBeGreaterThan(0); - expect(screen.getAllByText('Type is required.').length).toBeGreaterThan(0); - }); - }); - - describe('loading states', () => { - test('should indicate that the repository types are loading', async () => { - const typesDeferred = createDeferred(); - // Promise cast needed: mock helper signature doesn't explicitly allow Promise for loading-state tests - httpRequestsMockHelpers.setLoadRepositoryTypesResponse( - typesDeferred.promise as unknown as string[] - ); - - renderApp(httpSetup, { initialEntries: ['/add_repository'] }); - - const loading = await screen.findByTestId('sectionLoading'); - expect(loading).toHaveTextContent('Loading repository types…'); - - // Resolve to avoid leaving a dangling request. - typesDeferred.resolve(repositoryTypes); - await waitForRepositoryTypesToLoad(); - }); - }); - - describe('when no repository types are not found', () => { - beforeEach(async () => { - httpRequestsMockHelpers.setLoadRepositoryTypesResponse([]); - renderApp(httpSetup, { initialEntries: ['/add_repository'] }); - await waitForNoRepositoryTypesError(); - }); - - test('should show an error callout ', async () => { - const callout = screen.getByTestId('noRepositoryTypesError'); - expect(callout).toHaveTextContent('No repository types available'); - }); - }); - - describe('when repository types are found', () => { - beforeEach(async () => { - httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes); - renderApp(httpSetup, { initialEntries: ['/add_repository'] }); - await waitForRepositoryTypesToLoad(); - }); - - test('should have 1 card for each repository type', async () => { - repositoryTypes.forEach((type) => { - expect(screen.getByTestId(`${type}RepositoryType`)).toBeInTheDocument(); - }); - }); - }); - - describe('form validations', () => { - beforeEach(async () => { - httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes); - renderApp(httpSetup, { initialEntries: ['/add_repository'] }); - await waitForRepositoryTypesToLoad(); - }); - - describe('name (step 1)', () => { - it('should not allow spaces in the name', async () => { - await screen.findByTestId('nameInput'); - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: 'with space' } }); - fireEvent.click(screen.getByTestId('nextButton')); - - // Error text can appear both in the form-wide error summary and the field-level error. - expect(screen.getAllByText('Spaces are not allowed in the name.').length).toBeGreaterThan( - 0 - ); - }); - - it('should not allow invalid characters', async () => { - await screen.findByTestId('nameInput'); - - const expectErrorForChar = (char: string) => { - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: `with${char}` } }); - fireEvent.click(screen.getByTestId('nextButton')); - - // Error text can appear both in the form-wide error summary and the field-level error. - expect( - screen.getAllByText(`Character "${char}" is not allowed in the name.`).length - ).toBeGreaterThan(0); - }; - - INVALID_NAME_CHARS.forEach(expectErrorForChar); - }); - }); - - // FLAKY: https://github.com/elastic/kibana/issues/248548 - describe.skip('settings (step 2)', () => { - const typeToErrorMessagesMap: Record = { - fs: ['Location is required.'], - url: ['URL is required.'], - s3: ['Bucket is required.'], - gcs: ['Bucket is required.'], - hdfs: ['URI is required.'], - }; - - test('should validate required repository settings', async () => { - const user = userEvent.setup(); - await screen.findByTestId('nameInput'); - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: 'my-repo' } }); - - const selectRepoTypeAndExpectErrors = async (type: RepositoryType) => { - await user.click(screen.getByTestId(`${type}RepositoryType`)); - await user.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - await user.click(screen.getByTestId('submitButton')); - - const expectedErrors = typeToErrorMessagesMap[type]; - expectedErrors.forEach((error) => { - // Error text can appear both in the form-wide error summary and the field-level error. - expect(screen.getAllByText(error).length).toBeGreaterThan(0); - }); - - await user.click(screen.getByTestId('backButton')); - // Navigating back remounts step 1 and triggers repository types loading again. - // Ensure the async request resolves inside RTL's async act wrapper. - await waitForRepositoryTypesToLoad(); - }; - - await selectRepoTypeAndExpectErrors('fs'); - await selectRepoTypeAndExpectErrors('url'); - await selectRepoTypeAndExpectErrors('s3'); - await selectRepoTypeAndExpectErrors('gcs'); - await selectRepoTypeAndExpectErrors('hdfs'); - }); - }); - }); - - describe('form payload & api errors', () => { - const fsRepository = getRepository({ - settings: { - chunkSize: '10mb', - location: '/tmp/es-backups', - maxSnapshotBytesPerSec: '1g', - maxRestoreBytesPerSec: '1g', - }, - }); - - beforeEach(async () => { - httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes); - renderApp(httpSetup, { initialEntries: ['/add_repository'] }); - await waitForRepositoryTypesToLoad(); - }); - - describe('not source only', () => { - test('should send the correct payload for FS repository', async () => { - await screen.findByTestId('nameInput'); - - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: fsRepository.name } }); - fireEvent.click(screen.getByTestId(`${fsRepository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('locationInput'), { - target: { value: fsRepository.settings.location }, - }); - fireEvent.click(screen.getByTestId('compressToggle')); - fireEvent.change(screen.getByTestId('chunkSizeInput'), { - target: { value: fsRepository.settings.chunkSize }, - }); - fireEvent.change(screen.getByTestId('maxSnapshotBytesInput'), { - target: { value: fsRepository.settings.maxSnapshotBytesPerSec }, - }); - fireEvent.change(screen.getByTestId('maxRestoreBytesInput'), { - target: { value: fsRepository.settings.maxRestoreBytesPerSec }, - }); - fireEvent.click(screen.getByTestId('readOnlyToggle')); - - fireEvent.click(screen.getByTestId('submitButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}repositories`, - expect.objectContaining({ - body: JSON.stringify({ - name: fsRepository.name, - type: fsRepository.type, - settings: { - location: fsRepository.settings.location, - compress: false, - chunkSize: fsRepository.settings.chunkSize, - maxSnapshotBytesPerSec: fsRepository.settings.maxSnapshotBytesPerSec, - maxRestoreBytesPerSec: fsRepository.settings.maxRestoreBytesPerSec, - readonly: true, - }, - }), - }) - ); - }); - }); - - test('should send the correct payload for Azure repository', async () => { - const azureRepository = getRepository({ - type: 'azure', - settings: { - chunkSize: '10mb', - maxSnapshotBytesPerSec: '1g', - maxRestoreBytesPerSec: '1g', - client: 'client', - container: 'container', - basePath: 'path', - }, - }); - - await screen.findByTestId('nameInput'); - - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { - target: { value: azureRepository.name }, - }); - fireEvent.click(screen.getByTestId(`${azureRepository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('clientInput'), { - target: { value: azureRepository.settings.client }, - }); - fireEvent.change(screen.getByTestId('containerInput'), { - target: { value: azureRepository.settings.container }, - }); - fireEvent.change(screen.getByTestId('basePathInput'), { - target: { value: azureRepository.settings.basePath }, - }); - fireEvent.click(screen.getByTestId('compressToggle')); - fireEvent.change(screen.getByTestId('chunkSizeInput'), { - target: { value: azureRepository.settings.chunkSize }, - }); - fireEvent.change(screen.getByTestId('maxSnapshotBytesInput'), { - target: { value: azureRepository.settings.maxSnapshotBytesPerSec }, - }); - fireEvent.change(screen.getByTestId('maxRestoreBytesInput'), { - target: { value: azureRepository.settings.maxRestoreBytesPerSec }, - }); - fireEvent.click(screen.getByTestId('readOnlyToggle')); - - fireEvent.click(screen.getByTestId('submitButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}repositories`, - expect.objectContaining({ - body: JSON.stringify({ - name: azureRepository.name, - type: azureRepository.type, - settings: { - client: azureRepository.settings.client, - container: azureRepository.settings.container, - basePath: azureRepository.settings.basePath, - compress: false, - chunkSize: azureRepository.settings.chunkSize, - maxSnapshotBytesPerSec: azureRepository.settings.maxSnapshotBytesPerSec, - maxRestoreBytesPerSec: azureRepository.settings.maxRestoreBytesPerSec, - readonly: true, - }, - }), - }) - ); - }); - }); - - test('should send the correct payload for GCS repository', async () => { - const gcsRepository = getRepository({ - type: 'gcs', - settings: { - chunkSize: '10mb', - maxSnapshotBytesPerSec: '1g', - maxRestoreBytesPerSec: '1g', - client: 'test_client', - bucket: 'test_bucket', - basePath: 'test_path', - }, - }); - - await screen.findByTestId('nameInput'); - - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { - target: { value: gcsRepository.name }, - }); - fireEvent.click(screen.getByTestId(`${gcsRepository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('clientInput'), { - target: { value: gcsRepository.settings.client }, - }); - fireEvent.change(screen.getByTestId('bucketInput'), { - target: { value: gcsRepository.settings.bucket }, - }); - fireEvent.change(screen.getByTestId('basePathInput'), { - target: { value: gcsRepository.settings.basePath }, - }); - fireEvent.click(screen.getByTestId('compressToggle')); - fireEvent.change(screen.getByTestId('chunkSizeInput'), { - target: { value: gcsRepository.settings.chunkSize }, - }); - fireEvent.change(screen.getByTestId('maxSnapshotBytesInput'), { - target: { value: gcsRepository.settings.maxSnapshotBytesPerSec }, - }); - fireEvent.change(screen.getByTestId('maxRestoreBytesInput'), { - target: { value: gcsRepository.settings.maxRestoreBytesPerSec }, - }); - fireEvent.click(screen.getByTestId('readOnlyToggle')); - - fireEvent.click(screen.getByTestId('submitButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}repositories`, - expect.objectContaining({ - body: JSON.stringify({ - name: gcsRepository.name, - type: gcsRepository.type, - settings: { - client: gcsRepository.settings.client, - bucket: gcsRepository.settings.bucket, - basePath: gcsRepository.settings.basePath, - compress: false, - chunkSize: gcsRepository.settings.chunkSize, - maxSnapshotBytesPerSec: gcsRepository.settings.maxSnapshotBytesPerSec, - maxRestoreBytesPerSec: gcsRepository.settings.maxRestoreBytesPerSec, - readonly: true, - }, - }), - }) - ); - }); - }); - - test('should send the correct payload for HDFS repository', async () => { - const hdfsRepository = getRepository({ - type: 'hdfs', - settings: { - uri: 'uri', - path: 'test_path', - chunkSize: '10mb', - maxSnapshotBytesPerSec: '1g', - maxRestoreBytesPerSec: '1g', - }, - }); - - await screen.findByTestId('nameInput'); - - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { - target: { value: hdfsRepository.name }, - }); - fireEvent.click(screen.getByTestId(`${hdfsRepository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('uriInput'), { - target: { value: hdfsRepository.settings.uri }, - }); - fireEvent.change(screen.getByTestId('pathInput'), { - target: { value: hdfsRepository.settings.path }, - }); - fireEvent.click(screen.getByTestId('compressToggle')); - fireEvent.change(screen.getByTestId('chunkSizeInput'), { - target: { value: hdfsRepository.settings.chunkSize }, - }); - fireEvent.change(screen.getByTestId('maxSnapshotBytesInput'), { - target: { value: hdfsRepository.settings.maxSnapshotBytesPerSec }, - }); - fireEvent.change(screen.getByTestId('maxRestoreBytesInput'), { - target: { value: hdfsRepository.settings.maxRestoreBytesPerSec }, - }); - fireEvent.click(screen.getByTestId('readOnlyToggle')); - - fireEvent.click(screen.getByTestId('submitButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}repositories`, - expect.objectContaining({ - body: JSON.stringify({ - name: hdfsRepository.name, - type: hdfsRepository.type, - settings: { - uri: `hdfs://${hdfsRepository.settings.uri}`, - path: hdfsRepository.settings.path, - compress: false, - chunkSize: hdfsRepository.settings.chunkSize, - maxSnapshotBytesPerSec: hdfsRepository.settings.maxSnapshotBytesPerSec, - maxRestoreBytesPerSec: hdfsRepository.settings.maxRestoreBytesPerSec, - readonly: true, - }, - }), - }) - ); - }); - }); - - test('should send the correct payload for S3 repository', async () => { - await screen.findByTestId('nameInput'); - - const s3Repository = getRepository({ - type: 's3', - settings: { - bucket: 'test_bucket', - client: 'test_client', - basePath: 'test_path', - bufferSize: '1g', - chunkSize: '10mb', - maxSnapshotBytesPerSec: '1g', - maxRestoreBytesPerSec: '1g', - }, - }); - - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: s3Repository.name } }); - fireEvent.click(screen.getByTestId(`${s3Repository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('bucketInput'), { - target: { value: s3Repository.settings.bucket }, - }); - fireEvent.change(screen.getByTestId('clientInput'), { - target: { value: s3Repository.settings.client }, - }); - fireEvent.change(screen.getByTestId('basePathInput'), { - target: { value: s3Repository.settings.basePath }, - }); - fireEvent.change(screen.getByTestId('bufferSizeInput'), { - target: { value: s3Repository.settings.bufferSize }, - }); - fireEvent.click(screen.getByTestId('compressToggle')); - fireEvent.change(screen.getByTestId('chunkSizeInput'), { - target: { value: s3Repository.settings.chunkSize }, - }); - fireEvent.change(screen.getByTestId('maxSnapshotBytesInput'), { - target: { value: s3Repository.settings.maxSnapshotBytesPerSec }, - }); - fireEvent.change(screen.getByTestId('maxRestoreBytesInput'), { - target: { value: s3Repository.settings.maxRestoreBytesPerSec }, - }); - fireEvent.click(screen.getByTestId('readOnlyToggle')); - - fireEvent.click(screen.getByTestId('submitButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}repositories`, - expect.objectContaining({ - body: JSON.stringify({ - name: s3Repository.name, - type: s3Repository.type, - settings: { - bucket: s3Repository.settings.bucket, - client: s3Repository.settings.client, - basePath: s3Repository.settings.basePath, - bufferSize: s3Repository.settings.bufferSize, - compress: false, - chunkSize: s3Repository.settings.chunkSize, - maxSnapshotBytesPerSec: s3Repository.settings.maxSnapshotBytesPerSec, - maxRestoreBytesPerSec: s3Repository.settings.maxRestoreBytesPerSec, - readonly: true, - }, - }), - }) - ); - }); - }); - - test('should surface the API errors from the "save" HTTP request', async () => { - await screen.findByTestId('nameInput'); - - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: fsRepository.name } }); - fireEvent.click(screen.getByTestId(`${fsRepository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('locationInput'), { - target: { value: fsRepository.settings.location }, - }); - fireEvent.click(screen.getByTestId('compressToggle')); - - const error = { - statusCode: 400, - error: 'Bad request', - message: 'Repository payload is invalid', - }; - - httpRequestsMockHelpers.setSaveRepositoryResponse(undefined, error); - - fireEvent.click(screen.getByTestId('submitButton')); - - const errorCallout = await screen.findByTestId('saveRepositoryApiError'); - expect(errorCallout).toHaveTextContent(error.message); - }); - }); - - describe('source only', () => { - beforeEach(() => { - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: fsRepository.name } }); - fireEvent.click(screen.getByTestId(`${fsRepository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('sourceOnlyToggle')); // toggle source - fireEvent.click(screen.getByTestId('nextButton')); - }); - - test('should send the correct payload', async () => { - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('locationInput'), { - target: { value: fsRepository.settings.location }, - }); - - fireEvent.click(screen.getByTestId('submitButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}repositories`, - expect.objectContaining({ - body: JSON.stringify({ - name: fsRepository.name, - type: 'source', - settings: { - delegateType: fsRepository.type, - location: fsRepository.settings.location, - }, - }), - }) - ); - }); - }); - }); - }); - - describe('settings for s3 repository', () => { - beforeEach(async () => { - httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes); - renderApp(httpSetup, { initialEntries: ['/add_repository'] }); - await waitForRepositoryTypesToLoad(); - }); - - test('should correctly set the intelligent_tiering storage class', async () => { - const s3Repository = getRepository({ - type: 's3', - settings: { - bucket: 'test_bucket', - storageClass: 'intelligent_tiering', - }, - }); - - await screen.findByTestId('nameInput'); - - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: s3Repository.name } }); - fireEvent.click(screen.getByTestId(`${s3Repository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('bucketInput'), { - target: { value: s3Repository.settings.bucket }, - }); - fireEvent.change(screen.getByTestId('storageClassSelect'), { - target: { value: s3Repository.settings.storageClass }, - }); - - fireEvent.click(screen.getByTestId('submitButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}repositories`, - expect.objectContaining({ - body: JSON.stringify({ - name: s3Repository.name, - type: s3Repository.type, - settings: { - bucket: s3Repository.settings.bucket, - storageClass: s3Repository.settings.storageClass, - }, - }), - }) - ); - }); - }); - - test('should correctly set the onezone_ia storage class', async () => { - const s3Repository = getRepository({ - type: 's3', - settings: { - bucket: 'test_bucket', - storageClass: 'onezone_ia', - }, - }); - - await screen.findByTestId('nameInput'); - - // Fill step 1 required fields and go to step 2 - fireEvent.change(screen.getByTestId('nameInput'), { target: { value: s3Repository.name } }); - fireEvent.click(screen.getByTestId(`${s3Repository.type}RepositoryType`)); - fireEvent.click(screen.getByTestId('nextButton')); - await screen.findByTestId('stepTwo'); - - // Fill step 2 - fireEvent.change(screen.getByTestId('bucketInput'), { - target: { value: s3Repository.settings.bucket }, - }); - fireEvent.change(screen.getByTestId('storageClassSelect'), { - target: { value: s3Repository.settings.storageClass }, - }); - - fireEvent.click(screen.getByTestId('submitButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}repositories`, - expect.objectContaining({ - body: JSON.stringify({ - name: s3Repository.name, - type: s3Repository.type, - settings: { - bucket: s3Repository.settings.bucket, - storageClass: s3Repository.settings.storageClass, - }, - }), - }) - ); - }); - }); - }); -}); diff --git a/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/repository_form.test.tsx b/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/repository_form.test.tsx new file mode 100644 index 0000000000000..a123294c27f0a --- /dev/null +++ b/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/repository_form.test.tsx @@ -0,0 +1,419 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '@kbn/code-editor-mock/jest_helper'; + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { i18n } from '@kbn/i18n'; + +import type { RepositoryType } from '../../../../common/types'; +import { textService } from '../../services/text'; +import { RepositoryForm } from './repository_form'; + +const repositoryTypes: RepositoryType[] = ['fs', 'url', 'source', 'azure', 'gcs', 's3', 'hdfs']; + +jest.mock('../../services/http', () => { + const actual = jest.requireActual('../../services/http'); + return { + ...actual, + useLoadRepositoryTypes: jest.fn().mockReturnValue({ + isLoading: false, + error: null, + data: repositoryTypes, + }), + }; +}); + +const mockDocLinks = { + links: { + plugins: { + snapshotRestoreRepos: 'https://doc-link', + s3Repo: 'https://doc-link', + hdfsRepo: 'https://doc-link', + azureRepo: 'https://doc-link', + gcsRepo: 'https://doc-link', + }, + snapshotRestore: { + registerSharedFileSystem: 'https://doc-link', + registerUrl: 'https://doc-link', + registerSourceOnly: 'https://doc-link', + guide: 'https://doc-link', + }, + }, +}; + +jest.mock('../../app_context', () => { + const actual = jest.requireActual('../../app_context'); + + return { + ...actual, + useCore: () => ({ docLinks: mockDocLinks }), + useServices: () => ({ + i18n: { + translate: (_key: string, { defaultMessage }: { defaultMessage: string }) => defaultMessage, + }, + }), + }; +}); + +textService.setup(i18n); + +const emptyRepository = { name: '', type: null, settings: {} }; + +const onSaveMock = jest.fn(); +const clearSaveErrorMock = jest.fn(); + +const renderForm = (overrides: Record = {}) => { + return render( + + + + ); +}; + +/** + * Render the form directly on step 2 by passing `isEditing: true` + * with a pre-populated repository. This skips rendering step 1 + * (which has 7 expensive EuiCard components) and keeps tests fast. + */ +const renderOnStepTwo = (type: RepositoryType, name: string) => { + renderForm({ + repository: { name, type, settings: {} }, + isEditing: true, + }); +}; + +const goToStepTwo = (name: string, type: RepositoryType) => { + fireEvent.change(screen.getByTestId('nameInput'), { target: { value: name } }); + fireEvent.click(screen.getByTestId(`${type}RepositoryType`)); + fireEvent.click(screen.getByTestId('nextButton')); +}; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('step navigation', () => { + describe('WHEN the form renders', () => { + it('SHOULD show step 1 initially', () => { + renderForm(); + + expect(screen.getByTestId('nextButton')).toBeInTheDocument(); + expect(screen.getByTestId('nameInput')).toBeInTheDocument(); + }); + }); + + describe('WHEN step 1 fields are missing and next is clicked', () => { + it('SHOULD not proceed to step 2 and show validation errors', () => { + renderForm(); + + fireEvent.click(screen.getByTestId('nextButton')); + + expect(screen.queryByTestId('stepTwo')).not.toBeInTheDocument(); + expect(screen.getAllByText('Repository name is required.').length).toBeGreaterThan(0); + expect(screen.getAllByText('Type is required.').length).toBeGreaterThan(0); + }); + }); + + describe('WHEN step 1 is valid and next is clicked', () => { + it('SHOULD navigate to step 2', () => { + renderForm(); + goToStepTwo('my-repo', 'fs'); + + expect(screen.getByTestId('stepTwo')).toBeInTheDocument(); + }); + }); + + describe('WHEN the back button is clicked on step 2', () => { + it('SHOULD navigate back to step 1', () => { + renderForm(); + goToStepTwo('my-repo', 'fs'); + + expect(screen.getByTestId('stepTwo')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('backButton')); + + expect(screen.getByTestId('nameInput')).toBeInTheDocument(); + expect(screen.queryByTestId('stepTwo')).not.toBeInTheDocument(); + }); + }); + }); + + describe('form payload', () => { + describe('WHEN submitting an FS repository', () => { + it('SHOULD call onSave with the correct payload', () => { + renderOnStepTwo('fs', 'my-fs-repo'); + + fireEvent.change(screen.getByTestId('locationInput'), { + target: { value: '/tmp/es-backups' }, + }); + fireEvent.click(screen.getByTestId('compressToggle')); + fireEvent.change(screen.getByTestId('chunkSizeInput'), { + target: { value: '10mb' }, + }); + fireEvent.change(screen.getByTestId('maxSnapshotBytesInput'), { + target: { value: '1g' }, + }); + fireEvent.change(screen.getByTestId('maxRestoreBytesInput'), { + target: { value: '1g' }, + }); + fireEvent.click(screen.getByTestId('readOnlyToggle')); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).toHaveBeenCalledWith({ + name: 'my-fs-repo', + type: 'fs', + settings: { + location: '/tmp/es-backups', + compress: false, + chunkSize: '10mb', + maxSnapshotBytesPerSec: '1g', + maxRestoreBytesPerSec: '1g', + readonly: true, + }, + }); + }); + }); + + describe('WHEN submitting an Azure repository', () => { + it('SHOULD call onSave with the correct payload', () => { + renderOnStepTwo('azure', 'my-azure-repo'); + + fireEvent.change(screen.getByTestId('clientInput'), { target: { value: 'client' } }); + fireEvent.change(screen.getByTestId('containerInput'), { + target: { value: 'container' }, + }); + fireEvent.change(screen.getByTestId('basePathInput'), { target: { value: 'path' } }); + fireEvent.click(screen.getByTestId('compressToggle')); + fireEvent.click(screen.getByTestId('readOnlyToggle')); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).toHaveBeenCalledWith({ + name: 'my-azure-repo', + type: 'azure', + settings: { + client: 'client', + container: 'container', + basePath: 'path', + compress: false, + readonly: true, + }, + }); + }); + }); + + describe('WHEN submitting a GCS repository', () => { + it('SHOULD call onSave with the correct payload', () => { + renderOnStepTwo('gcs', 'my-gcs-repo'); + + fireEvent.change(screen.getByTestId('clientInput'), { + target: { value: 'test_client' }, + }); + fireEvent.change(screen.getByTestId('bucketInput'), { + target: { value: 'test_bucket' }, + }); + fireEvent.change(screen.getByTestId('basePathInput'), { + target: { value: 'test_path' }, + }); + fireEvent.click(screen.getByTestId('compressToggle')); + fireEvent.click(screen.getByTestId('readOnlyToggle')); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).toHaveBeenCalledWith({ + name: 'my-gcs-repo', + type: 'gcs', + settings: { + client: 'test_client', + bucket: 'test_bucket', + basePath: 'test_path', + compress: false, + readonly: true, + }, + }); + }); + }); + + describe('WHEN submitting an HDFS repository', () => { + it('SHOULD call onSave with the correct payload', () => { + renderOnStepTwo('hdfs', 'my-hdfs-repo'); + + fireEvent.change(screen.getByTestId('uriInput'), { target: { value: 'uri' } }); + fireEvent.change(screen.getByTestId('pathInput'), { target: { value: 'test_path' } }); + fireEvent.click(screen.getByTestId('compressToggle')); + fireEvent.click(screen.getByTestId('readOnlyToggle')); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).toHaveBeenCalledWith({ + name: 'my-hdfs-repo', + type: 'hdfs', + settings: { + uri: 'hdfs://uri', + path: 'test_path', + compress: false, + readonly: true, + }, + }); + }); + }); + + describe('WHEN submitting an S3 repository', () => { + it('SHOULD call onSave with the correct payload', () => { + renderOnStepTwo('s3', 'my-s3-repo'); + + fireEvent.change(screen.getByTestId('bucketInput'), { + target: { value: 'test_bucket' }, + }); + fireEvent.change(screen.getByTestId('clientInput'), { + target: { value: 'test_client' }, + }); + fireEvent.change(screen.getByTestId('basePathInput'), { + target: { value: 'test_path' }, + }); + fireEvent.change(screen.getByTestId('bufferSizeInput'), { + target: { value: '1g' }, + }); + fireEvent.click(screen.getByTestId('compressToggle')); + fireEvent.click(screen.getByTestId('readOnlyToggle')); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).toHaveBeenCalledWith({ + name: 'my-s3-repo', + type: 's3', + settings: { + bucket: 'test_bucket', + client: 'test_client', + basePath: 'test_path', + bufferSize: '1g', + compress: false, + readonly: true, + }, + }); + }); + }); + + describe('WHEN submitting a source-only FS repository', () => { + it('SHOULD call onSave with delegateType in the payload', () => { + renderForm(); + + fireEvent.change(screen.getByTestId('nameInput'), { + target: { value: 'my-source-repo' }, + }); + fireEvent.click(screen.getByTestId('fsRepositoryType')); + fireEvent.click(screen.getByTestId('sourceOnlyToggle')); + fireEvent.click(screen.getByTestId('nextButton')); + + expect(screen.getByTestId('stepTwo')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('locationInput'), { + target: { value: '/tmp/es-backups' }, + }); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).toHaveBeenCalledWith({ + name: 'my-source-repo', + type: 'source', + settings: { + delegateType: 'fs', + location: '/tmp/es-backups', + }, + }); + }); + }); + }); + + describe('S3 storage class settings', () => { + describe('WHEN intelligent_tiering storage class is selected', () => { + it('SHOULD call onSave with the correct storageClass', () => { + renderOnStepTwo('s3', 'my-s3-repo'); + + fireEvent.change(screen.getByTestId('bucketInput'), { + target: { value: 'test_bucket' }, + }); + fireEvent.change(screen.getByTestId('storageClassSelect'), { + target: { value: 'intelligent_tiering' }, + }); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ + bucket: 'test_bucket', + storageClass: 'intelligent_tiering', + }), + }) + ); + }); + }); + + describe('WHEN onezone_ia storage class is selected', () => { + it('SHOULD call onSave with the correct storageClass', () => { + renderOnStepTwo('s3', 'my-s3-repo'); + + fireEvent.change(screen.getByTestId('bucketInput'), { + target: { value: 'test_bucket' }, + }); + fireEvent.change(screen.getByTestId('storageClassSelect'), { + target: { value: 'onezone_ia' }, + }); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ + bucket: 'test_bucket', + storageClass: 'onezone_ia', + }), + }) + ); + }); + }); + }); + + describe('WHEN a save error is provided', () => { + it('SHOULD display the save error on step 2', () => { + renderForm({ + repository: { name: 'my-repo', type: 'fs', settings: { location: '/tmp' } }, + isEditing: true, + saveError:
Repository payload is invalid
, + }); + + expect(screen.getByTestId('saveRepositoryApiError')).toHaveTextContent( + 'Repository payload is invalid' + ); + }); + }); + + describe('WHEN step 2 settings validation fails', () => { + it('SHOULD not call onSave and show validation errors', () => { + renderOnStepTwo('fs', 'my-fs-repo'); + + fireEvent.click(screen.getByTestId('submitButton')); + + expect(onSaveMock).not.toHaveBeenCalled(); + expect(screen.getAllByText('Location is required.').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/step_one.test.tsx b/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/step_one.test.tsx new file mode 100644 index 0000000000000..87990c03821ca --- /dev/null +++ b/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/step_one.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { i18n } from '@kbn/i18n'; + +import type { Repository, EmptyRepository, RepositoryType } from '../../../../common/types'; +import { textService } from '../../services/text'; +import { RepositoryFormStepOne } from './step_one'; + +/** Build a test repository object, bypassing strict union settings types. */ +const testRepo = (overrides: Record) => + overrides as unknown as Repository | EmptyRepository; + +const repositoryTypes: RepositoryType[] = ['fs', 'url', 'source', 'azure', 'gcs', 's3', 'hdfs']; + +const mockUseLoadRepositoryTypes = jest.fn(); + +jest.mock('../../services/http', () => { + const actual = jest.requireActual('../../services/http'); + return { + ...actual, + useLoadRepositoryTypes: (...args: unknown[]) => mockUseLoadRepositoryTypes(...args), + }; +}); + +jest.mock('../../app_context', () => { + const actual = jest.requireActual('../../app_context'); + + return { + ...actual, + useCore: () => ({ + docLinks: { + links: { + plugins: { + snapshotRestoreRepos: 'https://doc-link', + s3Repo: 'https://doc-link', + hdfsRepo: 'https://doc-link', + azureRepo: 'https://doc-link', + gcsRepo: 'https://doc-link', + }, + snapshotRestore: { + registerSharedFileSystem: 'https://doc-link', + registerUrl: 'https://doc-link', + registerSourceOnly: 'https://doc-link', + guide: 'https://doc-link', + }, + }, + }, + }), + }; +}); + +textService.setup(i18n); + +const defaultProps: { + repository: Repository | EmptyRepository; + onNext: jest.Mock; + updateRepository: jest.Mock; + validation: { isValid: boolean; errors: Record }; +} = { + repository: { name: '', type: null, settings: {} }, + onNext: jest.fn(), + updateRepository: jest.fn(), + validation: { isValid: true, errors: {} }, +}; + +const renderStepOne = (overrides: Partial = {}) => { + return render( + + + + ); +}; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseLoadRepositoryTypes.mockReturnValue({ + isLoading: false, + error: null, + data: repositoryTypes, + }); + }); + + describe('WHEN repository types are loaded', () => { + it('SHOULD render a card for each repository type', () => { + renderStepOne(); + + repositoryTypes.forEach((type) => { + expect(screen.getByTestId(`${type}RepositoryType`)).toBeInTheDocument(); + }); + }); + }); + + describe('WHEN repository types are loading', () => { + it('SHOULD show a loading indicator', () => { + mockUseLoadRepositoryTypes.mockReturnValue({ + isLoading: true, + error: null, + data: [], + }); + + renderStepOne(); + + expect(screen.getByTestId('sectionLoading')).toHaveTextContent('Loading repository types…'); + }); + }); + + describe('WHEN repository types are empty', () => { + it('SHOULD show a no-repository-types error callout', () => { + mockUseLoadRepositoryTypes.mockReturnValue({ + isLoading: false, + error: null, + data: [], + }); + + renderStepOne(); + + expect(screen.getByTestId('noRepositoryTypesError')).toHaveTextContent( + 'No repository types available' + ); + }); + }); + + describe('WHEN the next button is clicked', () => { + it('SHOULD call onNext', () => { + const onNext = jest.fn(); + renderStepOne({ onNext }); + + fireEvent.click(screen.getByTestId('nextButton')); + + expect(onNext).toHaveBeenCalledTimes(1); + }); + }); + + describe('WHEN a repository type card is clicked', () => { + it('SHOULD call updateRepository with the selected type', () => { + const updateRepository = jest.fn(); + renderStepOne({ updateRepository }); + + fireEvent.click(screen.getByTestId('fsRepositoryType')); + + expect(updateRepository).toHaveBeenCalledWith({ + type: 'fs', + settings: {}, + }); + }); + }); + + describe('WHEN source-only toggle is enabled', () => { + it('SHOULD call updateRepository with source type and delegateType', () => { + const updateRepository = jest.fn(); + renderStepOne({ + updateRepository, + repository: testRepo({ name: 'test', type: 'fs', settings: {} }), + }); + + fireEvent.click(screen.getByTestId('sourceOnlyToggle')); + + expect(updateRepository).toHaveBeenCalledWith({ + type: 'source', + settings: { + delegateType: 'fs', + }, + }); + }); + }); + + describe('WHEN source-only toggle is disabled', () => { + it('SHOULD call updateRepository reverting to the delegate type', () => { + const updateRepository = jest.fn(); + renderStepOne({ + updateRepository, + repository: testRepo({ name: 'test', type: 'source', settings: { delegateType: 'fs' } }), + }); + + fireEvent.click(screen.getByTestId('sourceOnlyToggle')); + + expect(updateRepository).toHaveBeenCalledWith({ + type: 'fs', + settings: {}, + }); + }); + }); + + describe('WHEN validation errors exist', () => { + it('SHOULD display name validation error', () => { + renderStepOne({ + validation: { + isValid: false, + errors: { name: ['Repository name is required.'] }, + }, + }); + + expect(screen.getByText('Repository name is required.')).toBeInTheDocument(); + }); + + it('SHOULD display type validation error', () => { + renderStepOne({ + validation: { + isValid: false, + errors: { type: ['Type is required.'] }, + }, + }); + + expect(screen.getByText('Type is required.')).toBeInTheDocument(); + }); + + it('SHOULD display a validation error callout', () => { + renderStepOne({ + validation: { + isValid: false, + errors: { name: ['Repository name is required.'] }, + }, + }); + + expect(screen.getByTestId('repositoryFormError')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/step_one.tsx b/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/step_one.tsx index 38fcfb344c3f3..eaa68621a3451 100644 --- a/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/step_one.tsx +++ b/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/step_one.tsx @@ -24,15 +24,17 @@ import { EuiTitle, } from '@elastic/eui'; +import type { Error } from '@kbn/es-ui-shared-plugin/public'; +import { SectionError } from '@kbn/es-ui-shared-plugin/public'; + import type { Repository, RepositoryType, EmptyRepository } from '../../../../common/types'; import { REPOSITORY_TYPES } from '../../../../common'; -import type { Error } from '../../../shared_imports'; -import { SectionError } from '../../../shared_imports'; import { useLoadRepositoryTypes } from '../../services/http'; import { textService } from '../../services/text'; import type { RepositoryValidation } from '../../services/validation'; -import { SectionLoading, RepositoryTypeLogo } from '..'; +import { SectionLoading } from '../loading'; +import { RepositoryTypeLogo } from '../repository_type_logo'; import { useCore } from '../../app_context'; import { getRepositoryTypeDocUrl } from '../../lib/type_to_doc_url'; diff --git a/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/type_settings/index.tsx b/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/type_settings/index.tsx index 541ba5eae0a7d..dcec31e63a725 100644 --- a/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/type_settings/index.tsx +++ b/x-pack/platform/plugins/private/snapshot_restore/public/application/components/repository_form/type_settings/index.tsx @@ -8,9 +8,10 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { SectionError } from '@kbn/es-ui-shared-plugin/public'; + import { REPOSITORY_TYPES } from '../../../../../common'; import type { Repository, RepositoryType, EmptyRepository } from '../../../../../common/types'; -import { SectionError } from '../../../../shared_imports'; import { useServices } from '../../../app_context'; import type { RepositorySettingsValidation } from '../../../services/validation'; diff --git a/x-pack/platform/plugins/private/snapshot_restore/public/application/sections/repository_add/repository_add.test.tsx b/x-pack/platform/plugins/private/snapshot_restore/public/application/sections/repository_add/repository_add.test.tsx new file mode 100644 index 0000000000000..6be18ce7b5b2f --- /dev/null +++ b/x-pack/platform/plugins/private/snapshot_restore/public/application/sections/repository_add/repository_add.test.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '@kbn/code-editor-mock/jest_helper'; + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { createMemoryHistory } from 'history'; +import { Router } from '@kbn/shared-ux-router'; +import { i18n } from '@kbn/i18n'; + +import { textService } from '../../services/text'; +import { breadcrumbService, docTitleService } from '../../services/navigation'; +import { RepositoryAdd } from './repository_add'; + +jest.mock('../../components/repository_form', () => ({ + ...jest.requireActual('../../components/repository_form'), + RepositoryForm: ({ + onSave, + saveError, + }: { + onSave: (repository: unknown) => void; + saveError?: React.ReactNode; + }) => ( +
+ +
{saveError}
+
+ ), +})); + +jest.mock('../../services/http', () => { + const actual = jest.requireActual('../../services/http'); + + return { + ...actual, + useLoadRepositoryTypes: jest.fn().mockReturnValue({ + isLoading: false, + error: null, + data: ['fs'], + }), + addRepository: jest.fn(), + }; +}); + +jest.mock('../../app_context', () => { + const actual = jest.requireActual('../../app_context'); + + return { + ...actual, + useCore: () => ({ + docLinks: { + links: { + plugins: { + snapshotRestoreRepos: 'https://doc-link', + s3Repo: 'https://doc-link', + hdfsRepo: 'https://doc-link', + azureRepo: 'https://doc-link', + gcsRepo: 'https://doc-link', + }, + snapshotRestore: { + registerSharedFileSystem: 'https://doc-link', + registerUrl: 'https://doc-link', + registerSourceOnly: 'https://doc-link', + guide: 'https://doc-link', + }, + }, + }, + }), + useServices: () => ({ + i18n: { + translate: (_key: string, { defaultMessage }: { defaultMessage: string }) => defaultMessage, + }, + }), + }; +}); + +textService.setup(i18n); +breadcrumbService.setup(() => undefined); +docTitleService.setup(() => undefined); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('SHOULD set the correct page title', async () => { + const history = createMemoryHistory({ initialEntries: ['/add_repository'] }); + + render( + + + + + + ); + + expect(screen.getByTestId('pageTitle')).toHaveTextContent('Register repository'); + }); + + it('SHOULD surface API error when save fails', async () => { + const { addRepository } = await import('../../services/http'); + jest.mocked(addRepository).mockResolvedValueOnce({ + data: null, + error: { + statusCode: 400, + error: 'Bad Request', + message: 'Repository payload is invalid', + }, + }); + + const history = createMemoryHistory({ initialEntries: ['/add_repository'] }); + + render( + + + + + + ); + + fireEvent.click(screen.getByTestId('repositoryFormSave')); + + expect(await screen.findByTestId('saveRepositoryApiError')).toHaveTextContent( + 'Repository payload is invalid' + ); + }); + + it('SHOULD redirect to the repository details page when save succeeds', async () => { + const { addRepository } = await import('../../services/http'); + jest.mocked(addRepository).mockResolvedValueOnce({ data: null, error: null }); + + const history = createMemoryHistory({ initialEntries: ['/add_repository'] }); + + render( + + + + + + ); + + fireEvent.click(screen.getByTestId('repositoryFormSave')); + + await waitFor(() => { + expect(history.location.pathname).toBe('/repositories/my-repo'); + }); + }); + + it('SHOULD honor the redirect query param when save succeeds', async () => { + const { addRepository } = await import('../../services/http'); + jest.mocked(addRepository).mockResolvedValueOnce({ data: null, error: null }); + + const history = createMemoryHistory({ + initialEntries: ['/add_repository?redirect=%2Fsomewhere%2Felse'], + }); + + render( + + + + + + ); + + fireEvent.click(screen.getByTestId('repositoryFormSave')); + + await waitFor(() => { + expect(history.location.pathname).toBe('/somewhere/else'); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/snapshot_restore/public/application/sections/repository_add/repository_add.tsx b/x-pack/platform/plugins/private/snapshot_restore/public/application/sections/repository_add/repository_add.tsx index b4b07fc9d579c..68bac74e7c138 100644 --- a/x-pack/platform/plugins/private/snapshot_restore/public/application/sections/repository_add/repository_add.tsx +++ b/x-pack/platform/plugins/private/snapshot_restore/public/application/sections/repository_add/repository_add.tsx @@ -11,11 +11,10 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { RouteComponentProps } from 'react-router-dom'; import { EuiPageSection, EuiSpacer, EuiPageHeader } from '@elastic/eui'; +import { SectionError } from '@kbn/es-ui-shared-plugin/public'; import type { Repository, EmptyRepository } from '../../../../common/types'; -import { SectionError } from '../../../shared_imports'; - -import { RepositoryForm } from '../../components'; +import { RepositoryForm } from '../../components/repository_form'; import type { Section } from '../../constants'; import { BASE_PATH } from '../../constants'; import { breadcrumbService, docTitleService } from '../../services/navigation'; diff --git a/x-pack/platform/plugins/private/snapshot_restore/public/application/services/http/use_request.ts b/x-pack/platform/plugins/private/snapshot_restore/public/application/services/http/use_request.ts index 766d271136ba5..9455fcfe1ea9b 100644 --- a/x-pack/platform/plugins/private/snapshot_restore/public/application/services/http/use_request.ts +++ b/x-pack/platform/plugins/private/snapshot_restore/public/application/services/http/use_request.ts @@ -12,7 +12,7 @@ import type { } from '../../../shared_imports'; import { sendRequest as _sendRequest, useRequest as _useRequest } from '../../../shared_imports'; -import { httpService } from '.'; +import { httpService } from './http'; export const sendRequest = (config: SendRequestConfig) => { return _sendRequest(httpService.httpClient, config); diff --git a/x-pack/platform/plugins/private/snapshot_restore/public/application/services/validation/validate_repository.test.ts b/x-pack/platform/plugins/private/snapshot_restore/public/application/services/validation/validate_repository.test.ts new file mode 100644 index 0000000000000..1ad6682cab74b --- /dev/null +++ b/x-pack/platform/plugins/private/snapshot_restore/public/application/services/validation/validate_repository.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import type { Repository, EmptyRepository } from '../../../../common/types'; +import { textService } from '../text'; +import { validateRepository, INVALID_NAME_CHARS } from './validate_repository'; + +textService.setup(i18n); + +/** + * Helper to build test repository objects that intentionally omit required + * settings fields (e.g. testing validation of missing location/bucket). + * The production types are strict unions, but the validator handles missing + * fields at runtime — that is exactly what we are testing. + */ +const repo = (overrides: Record) => + overrides as unknown as Repository | EmptyRepository; + +describe('validateRepository', () => { + describe('WHEN name is empty', () => { + it('SHOULD return a name-required error', () => { + const result = validateRepository({ name: '', type: 'fs', settings: { location: '/tmp' } }); + + expect(result.isValid).toBe(false); + expect(result.errors.name).toEqual(['Repository name is required.']); + }); + }); + + describe('WHEN name is whitespace only', () => { + it('SHOULD return a name-required error', () => { + const result = validateRepository({ + name: ' ', + type: 'fs', + settings: { location: '/tmp' }, + }); + + expect(result.isValid).toBe(false); + expect(result.errors.name).toEqual(['Spaces are not allowed in the name.']); + }); + }); + + describe('WHEN name contains spaces', () => { + it('SHOULD return a spaces-not-allowed error', () => { + const result = validateRepository({ + name: 'with space', + type: 'fs', + settings: { location: '/tmp' }, + }); + + expect(result.isValid).toBe(false); + expect(result.errors.name).toEqual(['Spaces are not allowed in the name.']); + }); + }); + + describe('WHEN name contains invalid characters', () => { + it.each(INVALID_NAME_CHARS)('SHOULD return an invalid-character error for "%s"', (char) => { + const result = validateRepository({ + name: `with${char}`, + type: 'fs', + settings: { location: '/tmp' }, + }); + + expect(result.isValid).toBe(false); + expect(result.errors.name).toEqual([`Character "${char}" is not allowed in the name.`]); + }); + }); + + describe('WHEN type is empty', () => { + it('SHOULD return a type-required error', () => { + const result = validateRepository({ name: 'my-repo', type: null, settings: {} }, false); + + expect(result.isValid).toBe(false); + expect(result.errors.type).toEqual(['Type is required.']); + }); + }); + + describe('WHEN type is source but delegateType is empty', () => { + it('SHOULD return a type-required error', () => { + const result = validateRepository( + repo({ name: 'my-repo', type: 'source', settings: { delegateType: null } }), + false + ); + + expect(result.isValid).toBe(false); + expect(result.errors.type).toEqual(['Type is required.']); + }); + }); + + describe('WHEN a valid repository is provided', () => { + it('SHOULD return isValid true', () => { + const result = validateRepository({ + name: 'my-repo', + type: 'fs', + settings: { location: '/tmp/backups' }, + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual({}); + }); + }); + + describe('settings validation', () => { + describe('WHEN fs repository has empty location', () => { + it('SHOULD return a location-required error', () => { + const result = validateRepository(repo({ name: 'my-repo', type: 'fs', settings: {} })); + + expect(result.isValid).toBe(false); + expect(result.errors.settings).toEqual({ + location: ['Location is required.'], + }); + }); + }); + + describe('WHEN url repository has empty url', () => { + it('SHOULD return a url-required error', () => { + const result = validateRepository(repo({ name: 'my-repo', type: 'url', settings: {} })); + + expect(result.isValid).toBe(false); + expect(result.errors.settings).toEqual({ + url: ['URL is required.'], + }); + }); + }); + + describe('WHEN s3 repository has empty bucket', () => { + it('SHOULD return a bucket-required error', () => { + const result = validateRepository(repo({ name: 'my-repo', type: 's3', settings: {} })); + + expect(result.isValid).toBe(false); + expect(result.errors.settings).toEqual({ + bucket: ['Bucket is required.'], + }); + }); + }); + + describe('WHEN gcs repository has empty bucket', () => { + it('SHOULD return a bucket-required error', () => { + const result = validateRepository(repo({ name: 'my-repo', type: 'gcs', settings: {} })); + + expect(result.isValid).toBe(false); + expect(result.errors.settings).toEqual({ + bucket: ['Bucket is required.'], + }); + }); + }); + + describe('WHEN hdfs repository has empty uri and path', () => { + it('SHOULD return uri-required and path-required errors', () => { + const result = validateRepository(repo({ name: 'my-repo', type: 'hdfs', settings: {} })); + + expect(result.isValid).toBe(false); + expect(result.errors.settings).toEqual({ + uri: ['URI is required.'], + path: ['Path is required.'], + }); + }); + }); + + describe('WHEN azure repository has no required settings', () => { + it('SHOULD not produce settings errors', () => { + const result = validateRepository(repo({ name: 'my-repo', type: 'azure', settings: {} })); + + expect(result.isValid).toBe(true); + expect(result.errors.settings).toBeUndefined(); + }); + }); + + describe('WHEN source repository delegates to fs with empty location', () => { + it('SHOULD return the delegate type settings errors', () => { + const result = validateRepository( + repo({ name: 'my-repo', type: 'source', settings: { delegateType: 'fs' } }) + ); + + expect(result.isValid).toBe(false); + expect(result.errors.settings).toEqual({ + location: ['Location is required.'], + }); + }); + }); + + describe('WHEN validateSettings is false', () => { + it('SHOULD skip settings validation', () => { + const result = validateRepository( + repo({ name: 'my-repo', type: 'fs', settings: {} }), + false + ); + + expect(result.isValid).toBe(true); + expect(result.errors.settings).toBeUndefined(); + }); + }); + }); +});