You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
290 lines
9.0 KiB
290 lines
9.0 KiB
import {renderHook} from '@testing-library/react';
|
|
import React, {ReactNode} from 'react';
|
|
import useHandleError from '../../../src/hooks/useHandleError';
|
|
import {FrameworkProvider} from '../../../src/providers/FrameworkProvider';
|
|
import {APIError, ValidationError} from '../../../src/utils/errors';
|
|
|
|
// Mock external dependencies
|
|
vi.mock('@sentry/react', () => ({
|
|
withScope: vi.fn((callback: any) => callback({
|
|
setTag: vi.fn(),
|
|
setContext: vi.fn()
|
|
})),
|
|
captureException: vi.fn(),
|
|
ErrorBoundary: ({children}: {children: any}) => children
|
|
}));
|
|
|
|
vi.mock('@tryghost/admin-x-design-system', () => ({
|
|
showToast: vi.fn()
|
|
}));
|
|
|
|
vi.mock('react-hot-toast', () => ({
|
|
default: {
|
|
remove: vi.fn()
|
|
}
|
|
}));
|
|
|
|
const mockShowToast = vi.fn();
|
|
const mockToastRemove = vi.fn();
|
|
|
|
import * as Sentry from '@sentry/react';
|
|
import {showToast} from '@tryghost/admin-x-design-system';
|
|
import toast from 'react-hot-toast';
|
|
|
|
const createWrapper = (sentryDSN?: string): React.FC<{children: ReactNode}> => {
|
|
const TestWrapper: React.FC<{children: ReactNode}> = ({children}) => (
|
|
<FrameworkProvider
|
|
externalNavigate={() => {}}
|
|
ghostVersion='5.x'
|
|
sentryDSN={sentryDSN || ''}
|
|
unsplashConfig={{
|
|
Authorization: '',
|
|
'Accept-Version': '',
|
|
'Content-Type': '',
|
|
'App-Pragma': '',
|
|
'X-Unsplash-Cache': true
|
|
}}
|
|
onDelete={() => {}}
|
|
onInvalidate={() => {}}
|
|
onUpdate={() => {}}
|
|
>
|
|
{children}
|
|
</FrameworkProvider>
|
|
);
|
|
TestWrapper.displayName = 'TestWrapper';
|
|
return TestWrapper;
|
|
};
|
|
|
|
describe('useHandleError', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Setup mocks
|
|
(Sentry.withScope as any).mockImplementation((callback: any) => {
|
|
const scope = {
|
|
setTag: vi.fn(),
|
|
setContext: vi.fn()
|
|
};
|
|
callback(scope);
|
|
});
|
|
|
|
(showToast as any).mockImplementation(mockShowToast);
|
|
(toast.remove as any).mockImplementation(mockToastRemove);
|
|
|
|
// Reset console.error mock
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('returns a function', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
expect(typeof result.current).toBe('function');
|
|
});
|
|
|
|
it('logs error to console', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
const error = new Error('Test error');
|
|
|
|
result.current(error);
|
|
|
|
expect(console.error).toHaveBeenCalledWith(error); // eslint-disable-line no-console
|
|
});
|
|
|
|
it('sends error to Sentry when DSN is provided', () => {
|
|
const wrapper = createWrapper('https://sentry.dsn');
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
const error = new Error('Test error');
|
|
|
|
result.current(error);
|
|
|
|
expect(Sentry.captureException).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('does not send to Sentry when no DSN is provided', () => {
|
|
const wrapper = createWrapper('');
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
const error = new Error('Test error');
|
|
|
|
result.current(error);
|
|
|
|
expect(Sentry.captureException).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('adds API error context to Sentry', () => {
|
|
const wrapper = createWrapper('https://sentry.dsn');
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
const mockResponse = new Response(null, {status: 404});
|
|
Object.defineProperty(mockResponse, 'url', {
|
|
value: 'https://api.example.com/test',
|
|
writable: false
|
|
});
|
|
|
|
const error = new APIError(mockResponse);
|
|
|
|
let scopeUsed: any;
|
|
(Sentry.withScope as any).mockImplementation((callback: any) => {
|
|
scopeUsed = {
|
|
setTag: vi.fn(),
|
|
setContext: vi.fn()
|
|
};
|
|
callback(scopeUsed);
|
|
});
|
|
|
|
result.current(error);
|
|
|
|
expect(scopeUsed.setTag).toHaveBeenCalledWith('api_url', 'https://api.example.com/test');
|
|
expect(scopeUsed.setTag).toHaveBeenCalledWith('api_response_status', 404);
|
|
});
|
|
|
|
it('removes existing toasts', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
const error = new Error('Test error');
|
|
|
|
result.current(error);
|
|
|
|
expect(toast.remove).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not show toast when withToast is false', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
const error = new Error('Test error');
|
|
|
|
result.current(error, {withToast: false});
|
|
|
|
expect(showToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not show toast for 418 status (test indicator)', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
const mockResponse = new Response(null, {status: 418});
|
|
const error = new APIError(mockResponse);
|
|
|
|
result.current(error);
|
|
|
|
expect(showToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows validation error message from context', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
const mockResponse = new Response();
|
|
const errorData = {
|
|
errors: [{
|
|
message: 'Field is required',
|
|
context: 'This field must be filled out',
|
|
code: 'VALIDATION_ERROR',
|
|
id: 'error-id',
|
|
help: 'Help text',
|
|
type: 'ValidationError',
|
|
details: null,
|
|
ghostErrorCode: null,
|
|
property: 'fieldName'
|
|
}]
|
|
};
|
|
|
|
const error = new ValidationError(mockResponse, errorData);
|
|
|
|
result.current(error);
|
|
|
|
expect(showToast).toHaveBeenCalledWith({
|
|
message: 'This field must be filled out',
|
|
type: 'error'
|
|
});
|
|
});
|
|
|
|
it('shows validation error message when no context available', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
const mockResponse = new Response();
|
|
const errorData = {
|
|
errors: [{
|
|
message: 'Field is required',
|
|
context: null,
|
|
code: 'VALIDATION_ERROR',
|
|
id: 'error-id',
|
|
help: 'Help text',
|
|
type: 'ValidationError',
|
|
details: null,
|
|
ghostErrorCode: null,
|
|
property: 'fieldName'
|
|
}]
|
|
};
|
|
|
|
const error = new ValidationError(mockResponse, errorData);
|
|
|
|
result.current(error);
|
|
|
|
expect(showToast).toHaveBeenCalledWith({
|
|
message: 'Field is required',
|
|
type: 'error'
|
|
});
|
|
});
|
|
|
|
it('shows API error message', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
const error = new APIError(undefined, undefined, 'API Error occurred');
|
|
|
|
result.current(error);
|
|
|
|
expect(showToast).toHaveBeenCalledWith({
|
|
message: 'API Error occurred',
|
|
type: 'error'
|
|
});
|
|
});
|
|
|
|
it('shows generic error message for unknown errors', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
const error = new Error('Unknown error');
|
|
|
|
result.current(error);
|
|
|
|
expect(showToast).toHaveBeenCalledWith({
|
|
message: 'Something went wrong, please try again.',
|
|
type: 'error'
|
|
});
|
|
});
|
|
|
|
it('handles string errors', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
result.current('String error');
|
|
|
|
expect(console.error).toHaveBeenCalledWith('String error'); // eslint-disable-line no-console
|
|
expect(showToast).toHaveBeenCalledWith({
|
|
message: 'Something went wrong, please try again.',
|
|
type: 'error'
|
|
});
|
|
});
|
|
|
|
it('handles null/undefined errors', () => {
|
|
const wrapper = createWrapper();
|
|
const {result} = renderHook(() => useHandleError(), {wrapper});
|
|
|
|
result.current(null);
|
|
|
|
expect(console.error).toHaveBeenCalledWith(null); // eslint-disable-line no-console
|
|
expect(showToast).toHaveBeenCalledWith({
|
|
message: 'Something went wrong, please try again.',
|
|
type: 'error'
|
|
});
|
|
});
|
|
}); |