diff --git a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js index a1ff3f1d6d8..be926debab7 100644 --- a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js +++ b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js @@ -2,17 +2,18 @@ import React, { useState } from 'react'; import Portal from 'components/Portal'; import Modal from 'components/Modal'; import toast from 'react-hot-toast'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import importPostmanEnvironment from 'utils/importers/postman-environment'; import importBrunoEnvironment from 'utils/importers/bruno-environment'; import { readMultipleFiles } from 'utils/importers/file-reader'; import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; -import { toastError } from 'utils/common/error'; +import { sanitizeName } from 'utils/common/regex'; import { IconFileImport } from '@tabler/icons'; const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEnvironmentCreated }) => { const dispatch = useDispatch(); + const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments) || []; const [isDragOver, setIsDragOver] = useState(false); const isGlobal = type === 'global'; @@ -26,39 +27,69 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn const modalTestId = isGlobal ? 'import-global-environment-modal' : 'import-environment-modal'; const importTestId = isGlobal ? 'import-global-environment' : 'import-environment'; - const processEnvironments = async (environments, successMessage) => { - const validEnvironments = environments.filter((env) => { - if (env.name && env.name !== 'undefined') { - return true; - } else { - toast.error('Failed to import environment: env has no name'); - return false; - } - }); + const processEnvironments = async (environments, parseFailures = []) => { + const failures = [...parseFailures]; - if (validEnvironments.length === 0) { - toast.error('No valid environments found to import'); - return; + const named = []; + let unnamed = 0; + for (const env of environments) { + const name = typeof env.name === 'string' ? sanitizeName(env.name) : ''; + if (name && name !== 'undefined') named.push({ ...env, name }); + else unnamed++; } - try { - // Process environments sequentially to ensure unique name checking considers previously imported environments - let importedCount = 0; - for (const environment of validEnvironments) { + const existing = isGlobal ? globalEnvironments : collection?.environments || []; + const seen = new Set(existing.map((e) => sanitizeName(e.name || ''))); + + const toImport = []; + let skipped = 0; + for (const env of named) { + const key = env.name; + if (seen.has(key)) { + skipped++; + continue; + } + seen.add(key); + toImport.push(env); + } + + let imported = 0; + for (const environment of toImport) { + try { const action = isGlobal ? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color }) : importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid }); await dispatch(action); - importedCount++; + imported++; + } catch (error) { + console.error(`Failed to import environment "${environment.name}":`, error); + failures.push({ name: environment.name, message: error?.message || String(error) }); } + } + + if (imported > 0) { + toast.success(`Imported ${imported} environment${imported > 1 ? 's' : ''}.`); + } - toast.success(`${importedCount > 1 ? `${importedCount} environments` : 'Environment'} imported successfully`); - } catch (error) { - toast.error('An error occurred while importing the environment(s)'); - console.error(error); - throw error; + const notes = []; + if (skipped > 0) notes.push(`${skipped} already existed and ${skipped > 1 ? 'were' : 'was'} skipped`); + if (unnamed > 0) notes.push(`${unnamed} had no name`); + if (failures.length > 0) { + const names = failures.map((f) => f.name).slice(0, 3).join(', '); + const more = failures.length > 3 ? ` and ${failures.length - 3} more` : ''; + notes.push(`${failures.length} failed (${names}${more})`); } + + if (notes.length > 0) { + const message = notes.join('; ') + '.'; + if (failures.length > 0) toast.error(message); + else toast(message); + } else if (imported === 0) { + toast.error('No valid environments found to import.'); + } + + return { imported, skipped, unnamed, failures }; }; const detectEnvironmentFormat = (data) => { @@ -76,27 +107,40 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn }; const handleImportEnvironment = async (files) => { - try { - // Read and parse all files - const parsedFiles = await readMultipleFiles(Array.from(files)); - - // Detect format from first file's content - const format = detectEnvironmentFormat(parsedFiles[0].content); - let environments; - - if (format === 'postman') { - environments = await importPostmanEnvironment(parsedFiles); - } else { - environments = await importBrunoEnvironment(parsedFiles); + const environments = []; + const parseFailures = []; + for (const file of Array.from(files)) { + let parsedFile; + try { + [parsedFile] = await readMultipleFiles([file]); + } catch (err) { + console.error(`Failed to read ${file.name}:`, err); + parseFailures.push({ name: file.name, message: err?.message || String(err) }); + continue; } - await processEnvironments(environments); - onClose(); - if (onEnvironmentCreated) { - onEnvironmentCreated(); + try { + const format = detectEnvironmentFormat(parsedFile.content); + const result + = format === 'postman' + ? await importPostmanEnvironment([parsedFile]) + : await importBrunoEnvironment([parsedFile]); + environments.push(...result); + } catch (err) { + console.error(`Failed to parse ${parsedFile.fileName}:`, err); + parseFailures.push({ name: parsedFile.fileName, message: err?.message || String(err) }); } - } catch (err) { - toastError(err, 'Import environment failed'); + } + + const summary = await processEnvironments(environments, parseFailures); + + if (summary.imported === 0 && summary.skipped === 0) { + return; + } + + onClose(); + if (summary.imported > 0 && onEnvironmentCreated) { + onEnvironmentCreated(); } }; diff --git a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.spec.js b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.spec.js new file mode 100644 index 00000000000..b460bff0b8d --- /dev/null +++ b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.spec.js @@ -0,0 +1,266 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, waitFor, fireEvent, createEvent } from '@testing-library/react'; + +jest.mock('components/Portal', () => ({ __esModule: true, default: ({ children }) => children })); +jest.mock('components/Modal', () => ({ __esModule: true, default: ({ children }) => children })); +jest.mock('@tabler/icons', () => ({ __esModule: true, IconFileImport: () => null })); + +jest.mock('react-hot-toast', () => { + const fn = jest.fn(); + fn.success = jest.fn(); + fn.error = jest.fn(); + return { __esModule: true, default: fn }; +}); + +let mockDispatch; +let mockSelectorState; +jest.mock('react-redux', () => ({ + __esModule: true, + useDispatch: () => mockDispatch, + useSelector: (selector) => selector(mockSelectorState) +})); + +jest.mock('providers/ReduxStore/slices/collections/actions', () => ({ + __esModule: true, + importEnvironment: jest.fn((payload) => ({ thunk: 'importEnvironment', ...payload })) +})); +jest.mock('providers/ReduxStore/slices/global-environments', () => ({ + __esModule: true, + addGlobalEnvironment: jest.fn((payload) => ({ thunk: 'addGlobalEnvironment', ...payload })) +})); + +jest.mock('utils/importers/postman-environment', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('utils/importers/bruno-environment', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('utils/importers/file-reader', () => ({ __esModule: true, readMultipleFiles: jest.fn() })); + +import toast from 'react-hot-toast'; +import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import importPostmanEnvironment from 'utils/importers/postman-environment'; +import importBrunoEnvironment from 'utils/importers/bruno-environment'; +import { readMultipleFiles } from 'utils/importers/file-reader'; +import ImportEnvironmentModal from './index'; + +const brunoContent = (envs) => ({ info: { type: 'bruno-environment' }, envs }); +const postmanContent = (envs) => ({ id: 'pm-id', values: [], envs }); + +const makeFile = (name) => new File(['{}'], name, { type: 'application/json' }); +const collectionWith = (environments) => ({ uid: 'col-1', name: 'Col', environments }); + +// map: { 'file.json': { content } | { error } } +const setupFiles = (map) => { + readMultipleFiles.mockImplementation(async (files) => { + const file = files[0]; + const entry = map[file.name]; + if (!entry || entry.error) { + throw new Error(entry?.error || `Unable to parse JSON file: ${file.name}`); + } + return [{ fileName: file.name, content: entry.content }]; + }); +}; + +const dropFiles = (testId, files) => { + const node = screen.getByTestId(testId); + const event = createEvent.drop(node); + Object.defineProperty(event, 'dataTransfer', { value: { files } }); + fireEvent(node, event); +}; + +const renderModal = (props = {}) => + render( {}} onEnvironmentCreated={() => {}} {...props} />); + +describe('ImportEnvironmentModal — batch import summary', () => { + beforeEach(() => { + mockDispatch = jest.fn(() => Promise.resolve()); + mockSelectorState = { globalEnvironments: { globalEnvironments: [] } }; + + const readEnvs = ([parsedFile]) => { + if (parsedFile.content.importError) throw new Error(parsedFile.content.importError); + return parsedFile.content.envs || []; + }; + // Mirror importBrunoEnvironment: a missing name is normalized to 'Imported Environment'. + importBrunoEnvironment.mockImplementation(async (files) => + readEnvs(files).map((env) => ({ ...env, name: env.name || 'Imported Environment' })) + ); + importPostmanEnvironment.mockImplementation(async (files) => readEnvs(files)); + }); + + it('imports a mix of Bruno and Postman files and reports the combined count', async () => { + setupFiles({ + 'bruno.json': { content: brunoContent([{ name: 'B1', variables: [] }]) }, + 'postman.json': { content: postmanContent([{ name: 'P1', variables: [] }]) } + }); + const onClose = jest.fn(); + const onEnvironmentCreated = jest.fn(); + renderModal({ collection: collectionWith([]), onClose, onEnvironmentCreated }); + + dropFiles('import-environment', [makeFile('bruno.json'), makeFile('postman.json')]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Imported 2 environments.')); + expect(importBrunoEnvironment).toHaveBeenCalledTimes(1); + expect(importPostmanEnvironment).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(2); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(onEnvironmentCreated).toHaveBeenCalled(); + }); + + it('imports valid files even when one file is malformed, and reports the failure', async () => { + setupFiles({ + 'good1.json': { content: brunoContent([{ name: 'Good1', variables: [] }]) }, + 'bad.json': { error: 'Unable to parse JSON file: bad.json' }, + 'good2.json': { content: postmanContent([{ name: 'Good2', variables: [] }]) } + }); + renderModal({ collection: collectionWith([]) }); + + dropFiles('import-environment', [makeFile('good1.json'), makeFile('bad.json'), makeFile('good2.json')]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Imported 2 environments.')); + expect(mockDispatch).toHaveBeenCalledTimes(2); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('1 failed (bad.json)'))); + }); + + it('skips environments whose name already exists', async () => { + setupFiles({ 'dup.json': { content: brunoContent([{ name: 'Existing', variables: [] }]) } }); + const onClose = jest.fn(); + const onEnvironmentCreated = jest.fn(); + renderModal({ collection: collectionWith([{ name: 'Existing' }]), onClose, onEnvironmentCreated }); + + dropFiles('import-environment', [makeFile('dup.json')]); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('1 already existed and was skipped.')); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(onEnvironmentCreated).not.toHaveBeenCalled(); + }); + + it('skips a within-batch duplicate whose sanitized name collapses, without a "copy" suffix', async () => { + setupFiles({ + 'batch.json': { + content: brunoContent([ + { name: 'Prod/Env', variables: [{ name: 'first' }] }, + { name: 'Prod-Env', variables: [{ name: 'second' }] } + ]) + } + }); + renderModal({ collection: collectionWith([]) }); + + dropFiles('import-environment', [makeFile('batch.json')]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Imported 1 environment.')); + await waitFor(() => expect(toast).toHaveBeenCalledWith('1 already existed and was skipped.')); + expect(importEnvironment).toHaveBeenCalledTimes(1); + expect(importEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Prod-Env', variables: [{ name: 'first' }] }) + ); + expect(importEnvironment).not.toHaveBeenCalledWith( + expect.objectContaining({ name: expect.stringContaining('copy') }) + ); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('counts unnamed Postman environments while still importing the named ones', async () => { + // The Bruno importer defaults missing names to 'Imported Environment', so a + // genuinely nameless environment only reaches processEnvironments via Postman. + setupFiles({ + 'mixed.postman.json': { content: postmanContent([{ name: 'Named', variables: [] }, { variables: [] }]) } + }); + renderModal({ collection: collectionWith([]) }); + + dropFiles('import-environment', [makeFile('mixed.postman.json')]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Imported 1 environment.')); + await waitFor(() => expect(toast).toHaveBeenCalledWith('1 had no name.')); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('imports an unnamed Bruno environment as "Imported Environment" per the importer contract', async () => { + setupFiles({ 'noname.json': { content: brunoContent([{ variables: [] }]) } }); + renderModal({ collection: collectionWith([]) }); + + dropFiles('import-environment', [makeFile('noname.json')]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Imported 1 environment.')); + expect(importEnvironment).toHaveBeenCalledWith(expect.objectContaining({ name: 'Imported Environment' })); + expect(toast).not.toHaveBeenCalledWith('1 had no name.'); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('treats non-string and empty-after-sanitize names as unnamed', async () => { + setupFiles({ + 'odd.json': { + content: brunoContent([ + { name: 12345, variables: [] }, + { name: '///', variables: [] }, + { name: 'Valid', variables: [] } + ]) + } + }); + renderModal({ collection: collectionWith([]) }); + + dropFiles('import-environment', [makeFile('odd.json')]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Imported 1 environment.')); + await waitFor(() => expect(toast).toHaveBeenCalledWith('2 had no name.')); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('reports a failure when an environment fails to dispatch but imports the rest', async () => { + setupFiles({ + 'two.json': { content: brunoContent([{ name: 'Ok', variables: [] }, { name: 'Will-Fail', variables: [] }]) } + }); + mockDispatch.mockImplementation((action) => { + if (action.name === 'Will-Fail') return Promise.reject(new Error('disk full')); + return Promise.resolve(); + }); + renderModal({ collection: collectionWith([]) }); + + dropFiles('import-environment', [makeFile('two.json')]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Imported 1 environment.')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('1 failed (Will-Fail)'))); + expect(mockDispatch).toHaveBeenCalledTimes(2); + }); + + it('reports when nothing valid was found to import', async () => { + setupFiles({ 'empty.json': { content: brunoContent([]) } }); + const onClose = jest.fn(); + renderModal({ collection: collectionWith([]), onClose }); + + dropFiles('import-environment', [makeFile('empty.json')]); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No valid environments found to import.')); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + describe('name normalization', () => { + it('passes a sanitized name to importEnvironment for collection imports', async () => { + setupFiles({ + 'env.json': { content: brunoContent([{ name: 'Prod/Env', variables: [{ name: 'k' }], color: 'red' }]) } + }); + renderModal({ type: 'collection', collection: collectionWith([]) }); + + dropFiles('import-environment', [makeFile('env.json')]); + + await waitFor(() => expect(importEnvironment).toHaveBeenCalledTimes(1)); + expect(importEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Prod-Env', collectionUid: 'col-1' }) + ); + }); + + it('passes a sanitized name to addGlobalEnvironment for global imports', async () => { + setupFiles({ + 'env.json': { content: brunoContent([{ name: 'Prod/Env', variables: [{ name: 'k' }], color: 'red' }]) } + }); + mockSelectorState = { globalEnvironments: { globalEnvironments: [] } }; + renderModal({ type: 'global', collection: undefined }); + + dropFiles('import-global-environment', [makeFile('env.json')]); + + await waitFor(() => expect(addGlobalEnvironment).toHaveBeenCalledTimes(1)); + expect(addGlobalEnvironment).toHaveBeenCalledWith(expect.objectContaining({ name: 'Prod-Env' })); + }); + }); +});