Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ const CloneMockServerModal = ({
port: Yup.number()
.min(1, 'Port must be at least 1')
.max(65535, 'Port must be 65535 or less')
.required('Port is required')
.test('duplicate-port', 'This port is already used by another mock server', (value) => (
!isMockServerPortTaken(configuredInstances, value)
))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,7 @@ const CreateMockServerModal = ({
}),
port: Yup.number()
.min(1, 'Port must be at least 1')
.max(65535, 'Port must be 65535 or less')
.required('Port is required'),
.max(65535, 'Port must be 65535 or less'),
globalDelay: Yup.number().min(0, 'Delay cannot be negative')
}, [['sourceType', 'linkSource']]),
onSubmit: async (values) => {
Expand Down Expand Up @@ -329,6 +328,12 @@ const CreateMockServerModal = ({
formik.handleSubmit();
};

const handleCancel = () => {
formik.resetForm({ values: formik.values });
setPortError(null);
onClose();
};

const handleDelete = () => {
if (editingInstance && onDelete) {
onDelete(editingInstance);
Expand All @@ -342,7 +347,7 @@ const CreateMockServerModal = ({
title={isEditing ? 'Mock Server Settings' : 'Create Mock Server'}
confirmText={isEditing ? 'Save' : 'Create'}
handleConfirm={handleConfirm}
handleCancel={onClose}
handleCancel={handleCancel}
footerLeft={isEditing && onDelete ? (
<Button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import statusCodePhraseMap from 'components/ResponsePane/StatusCode/get-status-code-phrase';
import { collectCollectionExamples } from 'utils/mock-server/mock-responses';
import {
collectCollectionExamples,
getMockResponseNameError,
isMockResponseNameTaken,
MOCK_RESPONSE_NAME_MAX_LENGTH
} from 'utils/mock-server/mock-responses';

const BODY_TYPES = [
{ value: 'json', label: 'JSON' },
Expand All @@ -13,7 +18,7 @@ const BODY_TYPES = [

const DESCRIPTION_MAX_LENGTH = 1000;

const CreateMockResponseModal = ({ collection, onCreate, onClose }) => {
const CreateMockResponseModal = ({ collection, existingResponses = [], onCreate, onClose }) => {
const nameInputRef = useRef();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
Expand Down Expand Up @@ -50,8 +55,16 @@ const CreateMockResponseModal = ({ collection, onCreate, onClose }) => {
}, []);

const handleConfirm = async () => {
if (!nameValue.trim()) {
setNameError('Mock response name is required');
const trimmedName = nameValue.trim();

const validationError = getMockResponseNameError(trimmedName);
if (validationError) {
setNameError(validationError);
return;
}

if (isMockResponseNameTaken(existingResponses, trimmedName)) {
setNameError('A mock response with this name already exists');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return;
}

Expand All @@ -63,7 +76,7 @@ const CreateMockResponseModal = ({ collection, onCreate, onClose }) => {
setIsSaving(true);
try {
await onCreate({
name: nameValue.trim(),
name: trimmedName,
description: description.trim(),
statusCode: Number(statusValue) || 200,
bodyType: bodyTypeValue,
Expand Down Expand Up @@ -104,6 +117,7 @@ const CreateMockResponseModal = ({ collection, onCreate, onClose }) => {
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
maxLength={MOCK_RESPONSE_NAME_MAX_LENGTH}
value={nameValue}
onChange={(event) => {
setName(event.target.value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ const MockResponsesList = ({ instance, collection }) => {
{showCreateModal ? (
<CreateMockResponseModal
collection={isSpecServer ? null : resolvedCollection}
existingResponses={responses}
onCreate={handleCreate}
onClose={() => setShowCreateModal(false)}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { useEffect, useRef, useState } from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import {
getMockResponseNameError,
isMockResponseNameTaken,
MOCK_RESPONSE_NAME_MAX_LENGTH
} from 'utils/mock-server/mock-responses';

const RenameMockResponseModal = ({
response,
existingResponses = [],
onClose,
onConfirm,
isSaving = false
}) => {
const inputRef = useRef();
const [name, setName] = useState(response?.name || '');
const [nameError, setNameError] = useState('');

useEffect(() => {
if (inputRef.current) {
Expand All @@ -20,7 +27,15 @@ const RenameMockResponseModal = ({

const handleConfirm = () => {
const trimmedName = name.trim();
if (!trimmedName) {

const validationError = getMockResponseNameError(trimmedName);
if (validationError) {
setNameError(validationError);
return;
}

if (isMockResponseNameTaken(existingResponses, trimmedName, response?.uid)) {
setNameError('A mock response with this name already exists');
return;
}

Expand Down Expand Up @@ -48,10 +63,19 @@ const RenameMockResponseModal = ({
ref={inputRef}
type="text"
className="textbox mt-2 w-full"
maxLength={MOCK_RESPONSE_NAME_MAX_LENGTH}
value={name}
onChange={(event) => setName(event.target.value)}
onChange={(event) => {
setName(event.target.value);
if (nameError) {
setNameError('');
}
}}
data-testid="mock-response-rename-name-input"
/>
{nameError ? (
<div className="text-red-500 mt-1">{nameError}</div>
) : null}
</div>
</Modal>
</Portal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,19 @@ const RenameMockServerModal = ({ instance, onClose }) => {
}
}, []);

const handleCancel = () => {
formik.resetForm({ values: formik.values });
onClose();
};

return (
<Portal>
<Modal
size="md"
title="Rename Mock Server"
confirmText="Rename"
handleConfirm={() => formik.handleSubmit()}
handleCancel={onClose}
handleCancel={handleCancel}
dataTestId="mock-server-rename-modal"
>
<form className="bruno-form" onSubmit={(event) => event.preventDefault()}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const MockResponseSidebarItem = ({
}) => {
const dispatch = useDispatch();
const activeTabUid = useSelector((state) => state.tabs?.activeTabUid);
const existingResponses = useSelector((state) => state.mockServer.mockResponses[instance.uid] || []);
const [showRenameModal, setShowRenameModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
Expand Down Expand Up @@ -142,6 +143,7 @@ const MockResponseSidebarItem = ({
{showRenameModal ? (
<RenameMockResponseModal
response={response}
existingResponses={existingResponses}
isSaving={isRenaming}
onClose={() => {
if (!isRenaming) {
Expand Down
27 changes: 27 additions & 0 deletions packages/bruno-app/src/utils/mock-server/mock-responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,33 @@ export const resolveMockResponseEditorCollection = ({
return enrichedCollection;
};

export const MOCK_RESPONSE_NAME_MAX_LENGTH = 255;

export const getMockResponseNameError = (name) => {
const value = name == null ? '' : String(name).trim();

if (!value) {
return 'Mock response name is required';
}

if (value.length > MOCK_RESPONSE_NAME_MAX_LENGTH) {
return `Name must be ${MOCK_RESPONSE_NAME_MAX_LENGTH} characters or less`;
}

return null;
};

export const isMockResponseNameTaken = (responses = [], name, excludeUid = null) => {
const normalized = name?.trim().toLowerCase();
if (!normalized) {
return false;
}

return responses.some((response) => (
response.uid !== excludeUid && response.name?.trim().toLowerCase() === normalized
));
};

export const cloneMockResponseRecord = (response, { name } = {}) => {
const cloned = JSON.parse(JSON.stringify(response));
cloned.uid = uuid();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ jest.mock('utils/common', () => {
};
});

import { cloneMockResponseRecord, resolveMockResponseCollection, resolveMockResponseEditorCollection } from './mock-responses';
import {
cloneMockResponseRecord,
getMockResponseNameError,
isMockResponseNameTaken,
MOCK_RESPONSE_NAME_MAX_LENGTH,
resolveMockResponseCollection,
resolveMockResponseEditorCollection
} from './mock-responses';

describe('mock-responses', () => {
it('clones mock responses with new uids and a copy name', () => {
Expand Down Expand Up @@ -108,4 +115,63 @@ describe('resolveMockResponseEditorCollection', () => {
expect(enriched.workspaceProcessEnvVariables).toEqual({ NODE_ENV: 'test' });
expect(enriched.environments[0].variables[0].value).toBe('https://api.example.com');
});

describe('getMockResponseNameError', () => {
it('flags empty and whitespace-only names', () => {
expect(getMockResponseNameError('')).toBe('Mock response name is required');
expect(getMockResponseNameError(' ')).toBe('Mock response name is required');
expect(getMockResponseNameError(null)).toBe('Mock response name is required');
expect(getMockResponseNameError(undefined)).toBe('Mock response name is required');
});

it('flags names longer than the max length after trimming', () => {
const overflow = 'a'.repeat(MOCK_RESPONSE_NAME_MAX_LENGTH + 1);
expect(getMockResponseNameError(overflow))
.toBe(`Name must be ${MOCK_RESPONSE_NAME_MAX_LENGTH} characters or less`);
});

it('accepts names within bounds', () => {
expect(getMockResponseNameError('Order 200')).toBeNull();
expect(getMockResponseNameError('a'.repeat(MOCK_RESPONSE_NAME_MAX_LENGTH))).toBeNull();
// trailing spaces are ignored — they get trimmed on save
expect(getMockResponseNameError('Order 200 ')).toBeNull();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('measures the trimmed length, not the raw length', () => {
const paddedAtLimit = ` ${'a'.repeat(MOCK_RESPONSE_NAME_MAX_LENGTH)} `;
const paddedOverLimit = ` ${'a'.repeat(MOCK_RESPONSE_NAME_MAX_LENGTH + 1)} `;
expect(getMockResponseNameError(paddedAtLimit)).toBeNull();
expect(getMockResponseNameError(paddedOverLimit))
.toBe(`Name must be ${MOCK_RESPONSE_NAME_MAX_LENGTH} characters or less`);
});
});

describe('isMockResponseNameTaken', () => {
const responses = [
{ uid: 'r-1', name: 'Success' },
{ uid: 'r-2', name: ' Not Found ' }
];

it('detects duplicates case-insensitively and ignores surrounding whitespace', () => {
expect(isMockResponseNameTaken(responses, 'success')).toBe(true);
expect(isMockResponseNameTaken(responses, 'SUCCESS')).toBe(true);
expect(isMockResponseNameTaken(responses, ' Success ')).toBe(true);
expect(isMockResponseNameTaken(responses, 'not found')).toBe(true);
});

it('excludes the record being renamed', () => {
expect(isMockResponseNameTaken(responses, 'success', 'r-1')).toBe(false);
});

it('returns false for empty or missing names', () => {
expect(isMockResponseNameTaken(responses, '')).toBe(false);
expect(isMockResponseNameTaken(responses, ' ')).toBe(false);
expect(isMockResponseNameTaken(responses, null)).toBe(false);
});

it('handles an empty response list', () => {
expect(isMockResponseNameTaken([], 'anything')).toBe(false);
expect(isMockResponseNameTaken(undefined, 'anything')).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,17 @@ const saveMockResponse = (location, response) => {
};

const responses = [...getMockServerResponses(location)];

const normalizedName = nextResponse.name?.trim().toLowerCase();
if (normalizedName) {
const isDuplicate = responses.some((item) => (
item.uid !== nextResponse.uid && item.name?.trim().toLowerCase() === normalizedName
));
if (isDuplicate) {
throw new Error('A mock response with this name already exists');
}
}

const index = responses.findIndex((item) => item.uid === nextResponse.uid);

if (index >= 0) {
Expand Down
35 changes: 35 additions & 0 deletions packages/bruno-electron/tests/mock-response-store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,41 @@ describe('mock-response-store', () => {
expect(store.mockServers['mock-2'].responses).toHaveLength(1);
});

it('rejects a duplicate mock response name on the same server (case- and whitespace-insensitive)', () => {
const location = {
mockServerUid: 'mock-1',
sourceType: 'spec',
workspacePath
};

saveMockResponse(location, {
uid: 'response-1',
name: 'Users',
request: { url: '/users', method: 'GET' },
response: { status: 200, body: { type: 'json', content: '{}' } },
rules: { operator: 'AND', conditions: [] }
});

expect(() => saveMockResponse(location, {
uid: 'response-2',
name: ' users ',
request: { url: '/users', method: 'GET' },
response: { status: 200, body: { type: 'json', content: '{}' } },
rules: { operator: 'AND', conditions: [] }
})).toThrow('A mock response with this name already exists');

// Same uid, same name is a legitimate update — must not throw
expect(() => saveMockResponse(location, {
uid: 'response-1',
name: 'Users',
request: { url: '/users', method: 'POST' },
response: { status: 201, body: { type: 'json', content: '{}' } },
rules: { operator: 'AND', conditions: [] }
})).not.toThrow();

expect(listMockResponses(location)).toHaveLength(1);
});

it('removes a mock server block from workspace mockserver.yml on delete', () => {
const location = {
mockServerUid: 'mock-1',
Expand Down
Loading