refactor progress
This commit is contained in:
101
packages/errors/src/__tests__/AppErrorHandlers.test.tsx
Normal file
101
packages/errors/src/__tests__/AppErrorHandlers.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {Locales} from '@fluxer/constants/src/Locales';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import type {BaseHonoEnv} from '@fluxer/hono_types/src/HonoTypes';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface ErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function createApp(): Hono<BaseHonoEnv> {
|
||||
const app = new Hono<BaseHonoEnv>();
|
||||
app.onError(AppErrorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('AppErrorHandler i18n fallbacks', () => {
|
||||
it('localizes unexpected errors from Accept-Language when middleware locale is missing', async () => {
|
||||
const app = createApp();
|
||||
|
||||
app.get('/test', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
'accept-language': 'fr-CA,fr;q=0.9,en;q=0.8',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe(APIErrorCodes.INTERNAL_SERVER_ERROR);
|
||||
expect(body.message).toBe('Erreur interne du serveur.');
|
||||
});
|
||||
|
||||
it('localizes FluxerError responses without errorI18nService in context', async () => {
|
||||
const app = createApp();
|
||||
|
||||
app.get('/test', () => {
|
||||
throw new BadRequestError({code: APIErrorCodes.BAD_REQUEST});
|
||||
});
|
||||
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
'accept-language': 'fr',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe(APIErrorCodes.BAD_REQUEST);
|
||||
expect(body.message).toBe('Requête invalide.');
|
||||
});
|
||||
|
||||
it('prefers requestLocale context over Accept-Language header', async () => {
|
||||
const app = createApp();
|
||||
|
||||
app.use('*', async (ctx, next) => {
|
||||
ctx.set('requestLocale', Locales.EN_US);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.get('/test', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const response = await app.request('/test', {
|
||||
headers: {
|
||||
'accept-language': 'fr',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe(APIErrorCodes.INTERNAL_SERVER_ERROR);
|
||||
expect(body.message).toBe('Internal server error.');
|
||||
});
|
||||
});
|
||||
122
packages/errors/src/__tests__/CaptchaErrors.test.tsx
Normal file
122
packages/errors/src/__tests__/CaptchaErrors.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings';
|
||||
import {getErrorMessage} from '@fluxer/errors/src/i18n/ErrorI18n';
|
||||
import type {ErrorI18nKey} from '@fluxer/errors/src/i18n/ErrorI18nTypes.generated';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('CaptchaErrors', () => {
|
||||
describe('CaptchaRequiredError', () => {
|
||||
it('should have correct code and name', () => {
|
||||
const error = new CaptchaRequiredError();
|
||||
|
||||
expect(error.code).toBe('CAPTCHA_REQUIRED');
|
||||
expect(error.name).toBe('CaptchaRequiredError');
|
||||
});
|
||||
|
||||
it('should have status 400', () => {
|
||||
const error = new CaptchaRequiredError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
|
||||
it('should extend BadRequestError', () => {
|
||||
const error = new CaptchaRequiredError();
|
||||
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
});
|
||||
|
||||
it('should extend FluxerError', () => {
|
||||
const error = new CaptchaRequiredError();
|
||||
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
|
||||
it('should have an i18n mapping that resolves to the correct message', () => {
|
||||
const error = new CaptchaRequiredError();
|
||||
const i18nKey = ErrorCodeToI18nKey[error.code as keyof typeof ErrorCodeToI18nKey] as ErrorI18nKey;
|
||||
|
||||
expect(i18nKey).toBe('captcha.required');
|
||||
expect(getErrorMessage(i18nKey, 'en-US')).toBe('Captcha is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InvalidCaptchaError', () => {
|
||||
it('should have correct code and name', () => {
|
||||
const error = new InvalidCaptchaError();
|
||||
|
||||
expect(error.code).toBe('INVALID_CAPTCHA');
|
||||
expect(error.name).toBe('InvalidCaptchaError');
|
||||
});
|
||||
|
||||
it('should have status 400', () => {
|
||||
const error = new InvalidCaptchaError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
|
||||
it('should extend BadRequestError', () => {
|
||||
const error = new InvalidCaptchaError();
|
||||
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
});
|
||||
|
||||
it('should extend FluxerError', () => {
|
||||
const error = new InvalidCaptchaError();
|
||||
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
|
||||
it('should have an i18n mapping that resolves to the correct message', () => {
|
||||
const error = new InvalidCaptchaError();
|
||||
const i18nKey = ErrorCodeToI18nKey[error.code as keyof typeof ErrorCodeToI18nKey] as ErrorI18nKey;
|
||||
|
||||
expect(i18nKey).toBe('captcha.invalid');
|
||||
expect(getErrorMessage(i18nKey, 'en-US')).toBe('Invalid captcha.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error differentiation', () => {
|
||||
it('should have different codes for required vs invalid', () => {
|
||||
const requiredError = new CaptchaRequiredError();
|
||||
const invalidError = new InvalidCaptchaError();
|
||||
|
||||
expect(requiredError.code).not.toBe(invalidError.code);
|
||||
});
|
||||
|
||||
it('should have different i18n messages for required vs invalid', () => {
|
||||
const requiredKey = ErrorCodeToI18nKey['CAPTCHA_REQUIRED'] as ErrorI18nKey;
|
||||
const invalidKey = ErrorCodeToI18nKey['INVALID_CAPTCHA'] as ErrorI18nKey;
|
||||
|
||||
expect(getErrorMessage(requiredKey, 'en-US')).not.toBe(getErrorMessage(invalidKey, 'en-US'));
|
||||
});
|
||||
|
||||
it('should have different names for required vs invalid', () => {
|
||||
const requiredError = new CaptchaRequiredError();
|
||||
const invalidError = new InvalidCaptchaError();
|
||||
|
||||
expect(requiredError.name).not.toBe(invalidError.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
379
packages/errors/src/__tests__/DomainErrors.test.tsx
Normal file
379
packages/errors/src/__tests__/DomainErrors.test.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError';
|
||||
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
|
||||
import {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface ErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
describe('Domain Errors', () => {
|
||||
describe('core domain errors', () => {
|
||||
describe('BadRequestError', () => {
|
||||
it('should create error with required code', () => {
|
||||
const error = new BadRequestError({code: APIErrorCodes.INVALID_REQUEST});
|
||||
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.code).toBe(APIErrorCodes.INVALID_REQUEST);
|
||||
expect(error.message).toBe(APIErrorCodes.INVALID_REQUEST);
|
||||
});
|
||||
|
||||
it('should allow custom message', () => {
|
||||
const error = new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_FORM_BODY,
|
||||
message: 'Custom bad request message',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Custom bad request message');
|
||||
});
|
||||
|
||||
it('should include data', () => {
|
||||
const error = new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
data: {field: 'test'},
|
||||
});
|
||||
|
||||
expect(error.data).toEqual({field: 'test'});
|
||||
});
|
||||
|
||||
it('should include headers', () => {
|
||||
const error = new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
headers: {'X-Custom': 'value'},
|
||||
});
|
||||
|
||||
expect(error.headers).toEqual({'X-Custom': 'value'});
|
||||
});
|
||||
|
||||
it('should include messageVariables for i18n', () => {
|
||||
const error = new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
messageVariables: {count: 5},
|
||||
});
|
||||
|
||||
expect(error.messageVariables).toEqual({count: 5});
|
||||
});
|
||||
|
||||
it('should be instance of FluxerError', () => {
|
||||
const error = new BadRequestError({code: APIErrorCodes.INVALID_REQUEST});
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotFoundError', () => {
|
||||
it('should create error with status 404', () => {
|
||||
const error = new NotFoundError({code: APIErrorCodes.UNKNOWN_USER});
|
||||
|
||||
expect(error.status).toBe(404);
|
||||
expect(error.code).toBe(APIErrorCodes.UNKNOWN_USER);
|
||||
expect(error.message).toBe(APIErrorCodes.UNKNOWN_USER);
|
||||
});
|
||||
|
||||
it('should allow messageVariables for i18n', () => {
|
||||
const error = new NotFoundError({
|
||||
code: APIErrorCodes.UNKNOWN_USER,
|
||||
messageVariables: {userId: '12345'},
|
||||
});
|
||||
|
||||
expect(error.messageVariables).toEqual({userId: '12345'});
|
||||
});
|
||||
|
||||
it('should be instance of FluxerError', () => {
|
||||
const error = new NotFoundError({code: APIErrorCodes.UNKNOWN_USER});
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ForbiddenError', () => {
|
||||
it('should create error with status 403', () => {
|
||||
const error = new ForbiddenError({code: APIErrorCodes.ACCESS_DENIED});
|
||||
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.code).toBe(APIErrorCodes.ACCESS_DENIED);
|
||||
expect(error.message).toBe(APIErrorCodes.ACCESS_DENIED);
|
||||
});
|
||||
|
||||
it('should be instance of FluxerError', () => {
|
||||
const error = new ForbiddenError({code: APIErrorCodes.ACCESS_DENIED});
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InternalServerError', () => {
|
||||
it('should create error with status 500', () => {
|
||||
const error = new InternalServerError({code: APIErrorCodes.GENERAL_ERROR});
|
||||
|
||||
expect(error.status).toBe(500);
|
||||
expect(error.code).toBe(APIErrorCodes.GENERAL_ERROR);
|
||||
expect(error.message).toBe(APIErrorCodes.GENERAL_ERROR);
|
||||
});
|
||||
|
||||
it('should be instance of FluxerError', () => {
|
||||
const error = new InternalServerError({code: APIErrorCodes.GENERAL_ERROR});
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputValidationError', () => {
|
||||
it('should create error with validation errors', () => {
|
||||
const error = new InputValidationError([{path: 'email', message: 'Invalid email format'}]);
|
||||
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.code).toBe(APIErrorCodes.INVALID_FORM_BODY);
|
||||
expect(error.data).toEqual({
|
||||
errors: [{path: 'email', message: 'Invalid email format'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('should support localized errors', () => {
|
||||
const error = new InputValidationError(
|
||||
[{path: 'name', message: ValidationErrorCodes.EMAIL_IS_REQUIRED}],
|
||||
[{path: 'name', code: ValidationErrorCodes.EMAIL_IS_REQUIRED}],
|
||||
);
|
||||
|
||||
expect(error.localizedErrors).toEqual([{path: 'name', code: ValidationErrorCodes.EMAIL_IS_REQUIRED}]);
|
||||
expect(error.getLocalizedErrors()).toEqual([{path: 'name', code: ValidationErrorCodes.EMAIL_IS_REQUIRED}]);
|
||||
});
|
||||
|
||||
it('should return null for localizedErrors when not provided', () => {
|
||||
const error = new InputValidationError([{path: 'field', message: 'error'}]);
|
||||
|
||||
expect(error.localizedErrors).toBeNull();
|
||||
expect(error.getLocalizedErrors()).toBeNull();
|
||||
});
|
||||
|
||||
it('should create from single field using static method', () => {
|
||||
const error = InputValidationError.create('username', 'Username is required');
|
||||
|
||||
expect(error).toBeInstanceOf(InputValidationError);
|
||||
expect(error.data).toEqual({
|
||||
errors: [{path: 'username', message: 'Username is required'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create from multiple fields using static method', () => {
|
||||
const error = InputValidationError.createMultiple([
|
||||
{field: 'email', message: 'Invalid email'},
|
||||
{field: 'password', message: 'Password too short'},
|
||||
]);
|
||||
|
||||
expect(error.data).toEqual({
|
||||
errors: [
|
||||
{path: 'email', message: 'Invalid email'},
|
||||
{path: 'password', message: 'Password too short'},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create from error code using static method', () => {
|
||||
const error = InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_IS_REQUIRED, {maxLength: 255});
|
||||
|
||||
expect(error.localizedErrors).toEqual([
|
||||
{path: 'email', code: ValidationErrorCodes.EMAIL_IS_REQUIRED, variables: {maxLength: 255}},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create from multiple error codes using static method', () => {
|
||||
const error = InputValidationError.fromCodes([
|
||||
{path: 'email', code: ValidationErrorCodes.EMAIL_IS_REQUIRED},
|
||||
{path: 'name', code: ValidationErrorCodes.STRING_LENGTH_INVALID, variables: {max: 100}},
|
||||
]);
|
||||
|
||||
expect(error.localizedErrors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth domain errors', () => {
|
||||
describe('InvalidPhoneNumberError', () => {
|
||||
it('should have correct code from APIErrorCodes', () => {
|
||||
const error = new InvalidPhoneNumberError();
|
||||
|
||||
expect(error.code).toBe(APIErrorCodes.INVALID_PHONE_NUMBER);
|
||||
expect(error.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
|
||||
it('should be instance of BadRequestError', () => {
|
||||
const error = new InvalidPhoneNumberError();
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
});
|
||||
|
||||
it('should be instance of FluxerError', () => {
|
||||
const error = new InvalidPhoneNumberError();
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel domain errors', () => {
|
||||
describe('UnknownChannelError', () => {
|
||||
it('should have correct code from APIErrorCodes', () => {
|
||||
const error = new UnknownChannelError();
|
||||
|
||||
expect(error.code).toBe(APIErrorCodes.UNKNOWN_CHANNEL);
|
||||
expect(error.status).toBe(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should be instance of NotFoundError', () => {
|
||||
const error = new UnknownChannelError();
|
||||
expect(error).toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it('should be instance of FluxerError', () => {
|
||||
const error = new UnknownChannelError();
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnknownMessageError', () => {
|
||||
it('should have correct code from APIErrorCodes', () => {
|
||||
const error = new UnknownMessageError();
|
||||
|
||||
expect(error.code).toBe(APIErrorCodes.UNKNOWN_MESSAGE);
|
||||
expect(error.status).toBe(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should be instance of NotFoundError', () => {
|
||||
const error = new UnknownMessageError();
|
||||
expect(error).toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error response generation', () => {
|
||||
it('should generate correct JSON response for domain errors', async () => {
|
||||
const error = new UnknownChannelError();
|
||||
const response = error.getResponse();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe(APIErrorCodes.UNKNOWN_CHANNEL);
|
||||
});
|
||||
|
||||
it('should include data in response', async () => {
|
||||
const error = new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
data: {field: 'test', reason: 'invalid'},
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(body).toEqual({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
message: APIErrorCodes.INVALID_REQUEST,
|
||||
field: 'test',
|
||||
reason: 'invalid',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include custom headers in response', async () => {
|
||||
const error = new ForbiddenError({
|
||||
code: APIErrorCodes.ACCESS_DENIED,
|
||||
headers: {'X-Permission-Required': 'admin'},
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
|
||||
expect(response.headers.get('X-Permission-Required')).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error serialization', () => {
|
||||
it('should serialize domain errors to JSON correctly', () => {
|
||||
const error = new UnknownChannelError();
|
||||
const json = error.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
code: APIErrorCodes.UNKNOWN_CHANNEL,
|
||||
message: APIErrorCodes.UNKNOWN_CHANNEL,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include data in JSON serialization', () => {
|
||||
const error = new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
data: {extra: 'info'},
|
||||
});
|
||||
|
||||
const json = error.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
message: APIErrorCodes.INVALID_REQUEST,
|
||||
extra: 'info',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error inheritance chain', () => {
|
||||
it('should maintain correct prototype chain', () => {
|
||||
const error = new InvalidPhoneNumberError();
|
||||
|
||||
expect(error).toBeInstanceOf(InvalidPhoneNumberError);
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('should be catchable at any level of the chain', () => {
|
||||
const error = new UnknownChannelError();
|
||||
|
||||
try {
|
||||
throw error;
|
||||
} catch (e) {
|
||||
if (e instanceof NotFoundError) {
|
||||
expect(e.code).toBe(APIErrorCodes.UNKNOWN_CHANNEL);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
throw error;
|
||||
} catch (e) {
|
||||
if (e instanceof FluxerError) {
|
||||
expect(e.status).toBe(404);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
throw error;
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
expect(e).toBeInstanceOf(UnknownChannelError);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
359
packages/errors/src/__tests__/ErrorHandler.test.tsx
Normal file
359
packages/errors/src/__tests__/ErrorHandler.test.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import {createErrorHandler, type ErrorHandlerOptions} from '@fluxer/errors/src/ErrorHandler';
|
||||
import {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import {Hono} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
interface ErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function createTestApp(options: ErrorHandlerOptions = {}) {
|
||||
const app = new Hono();
|
||||
app.onError(createErrorHandler(options));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('createErrorHandler', () => {
|
||||
describe('FluxerError handling', () => {
|
||||
it('should return FluxerError response directly', async () => {
|
||||
const app = createTestApp();
|
||||
app.get('/test', () => {
|
||||
throw new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
message: 'Test error message',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body).toEqual({
|
||||
code: 'TEST_ERROR',
|
||||
message: 'Test error message',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include FluxerError data in response', async () => {
|
||||
const app = createTestApp();
|
||||
app.get('/test', () => {
|
||||
throw new FluxerError({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
status: 400,
|
||||
data: {field: 'email'},
|
||||
});
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(body).toEqual({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
field: 'email',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include FluxerError custom headers', async () => {
|
||||
const app = createTestApp();
|
||||
app.get('/test', () => {
|
||||
throw new FluxerError({
|
||||
code: 'RATE_LIMITED',
|
||||
status: 429,
|
||||
headers: {'Retry-After': '60'},
|
||||
});
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.headers.get('Retry-After')).toBe('60');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTPException handling', () => {
|
||||
it('should handle HTTPException with JSON response', async () => {
|
||||
const app = createTestApp();
|
||||
app.get('/test', () => {
|
||||
throw new HTTPException(403, {message: 'Access denied'});
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('FORBIDDEN');
|
||||
expect(body.message).toBe('Access denied');
|
||||
});
|
||||
|
||||
it('should use default message for HTTPException without message', async () => {
|
||||
const app = createTestApp();
|
||||
app.get('/test', () => {
|
||||
throw new HTTPException(500);
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.message).toBe('An error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic Error handling', () => {
|
||||
it('should return 500 for generic errors', async () => {
|
||||
const app = createTestApp();
|
||||
app.get('/test', () => {
|
||||
throw new Error('Something went wrong');
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('INTERNAL_SERVER_ERROR');
|
||||
expect(body.message).toBe('Something went wrong. Please try again later.');
|
||||
});
|
||||
|
||||
it('should not expose error message without includeStack option', async () => {
|
||||
const app = createTestApp({includeStack: false});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Sensitive error details');
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(body.message).toBe('Something went wrong. Please try again later.');
|
||||
expect(body.message).not.toContain('Sensitive');
|
||||
});
|
||||
|
||||
it('should expose error message with includeStack option', async () => {
|
||||
const app = createTestApp({includeStack: true});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Error details for debugging');
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(body.message).toBe('Error details for debugging');
|
||||
});
|
||||
|
||||
it('should include stack trace with includeStack option', async () => {
|
||||
const app = createTestApp({includeStack: true});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(body).toHaveProperty('stack');
|
||||
expect(body.stack).toContain('Error: Test error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logError callback', () => {
|
||||
it('should call logError with error and context', async () => {
|
||||
const logError = vi.fn();
|
||||
const app = createTestApp({logError});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Logged error');
|
||||
});
|
||||
|
||||
await app.request('/test');
|
||||
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(logError.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect((logError.mock.calls[0][0] as Error).message).toBe('Logged error');
|
||||
});
|
||||
|
||||
it('should call logError for FluxerError', async () => {
|
||||
const logError = vi.fn();
|
||||
const app = createTestApp({logError});
|
||||
app.get('/test', () => {
|
||||
throw new FluxerError({code: 'TEST', status: 400});
|
||||
});
|
||||
|
||||
await app.request('/test');
|
||||
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(logError.mock.calls[0][0]).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customHandler callback', () => {
|
||||
it('should use customHandler response when provided', async () => {
|
||||
const customHandler = vi.fn().mockReturnValue(
|
||||
new Response(JSON.stringify({custom: true}), {
|
||||
status: 418,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
}),
|
||||
);
|
||||
const app = createTestApp({customHandler});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Custom handled');
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(418);
|
||||
const body = (await response.json()) as {custom: boolean};
|
||||
expect(body).toEqual({custom: true});
|
||||
expect(customHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fall back to default handling when customHandler returns undefined', async () => {
|
||||
const customHandler = vi.fn().mockReturnValue(undefined);
|
||||
const app = createTestApp({customHandler});
|
||||
app.get('/test', () => {
|
||||
throw new FluxerError({code: 'FALLBACK', status: 400});
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body.code).toBe('FALLBACK');
|
||||
});
|
||||
|
||||
it('should support async customHandler', async () => {
|
||||
const customHandler = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({async: true}), {
|
||||
status: 202,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
}),
|
||||
);
|
||||
const app = createTestApp({customHandler});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Async handled');
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
const body = (await response.json()) as {async: boolean};
|
||||
expect(body).toEqual({async: true});
|
||||
});
|
||||
});
|
||||
|
||||
describe('responseFormat option', () => {
|
||||
it('should return JSON by default', async () => {
|
||||
const app = createTestApp();
|
||||
app.get('/test', () => {
|
||||
throw new HTTPException(400);
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('should return XML when responseFormat is xml', async () => {
|
||||
const app = createTestApp({responseFormat: 'xml'});
|
||||
app.get('/test', () => {
|
||||
throw new HTTPException(400, {message: 'Bad request'});
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get('Content-Type')).toBe('application/xml');
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toContain('<?xml version="1.0"');
|
||||
expect(body).toContain('<Error>');
|
||||
expect(body).toContain('<Code>BAD_REQUEST</Code>');
|
||||
expect(body).toContain('<Message>Bad request</Message>');
|
||||
});
|
||||
|
||||
it('should escape XML special characters', async () => {
|
||||
const app = createTestApp({responseFormat: 'xml'});
|
||||
app.get('/test', () => {
|
||||
throw new HTTPException(400, {message: 'Error with <special> & "chars"'});
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
const body = await response.text();
|
||||
|
||||
expect(body).toContain('<special>');
|
||||
expect(body).toContain('&');
|
||||
expect(body).toContain('"chars"');
|
||||
});
|
||||
|
||||
it('should return XML for internal errors when responseFormat is xml', async () => {
|
||||
const app = createTestApp({responseFormat: 'xml'});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Internal error');
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.headers.get('Content-Type')).toBe('application/xml');
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toContain('<Code>INTERNAL_SERVER_ERROR</Code>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined options', () => {
|
||||
it('should support logError and includeStack together', async () => {
|
||||
const logError = vi.fn();
|
||||
const app = createTestApp({logError, includeStack: true});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Combined test');
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(body.message).toBe('Combined test');
|
||||
expect(body).toHaveProperty('stack');
|
||||
});
|
||||
|
||||
it('should call logError before customHandler', async () => {
|
||||
const callOrder: Array<string> = [];
|
||||
const logError = vi.fn(() => callOrder.push('log'));
|
||||
const customHandler = vi.fn(() => {
|
||||
callOrder.push('custom');
|
||||
return undefined;
|
||||
});
|
||||
const app = createTestApp({logError, customHandler});
|
||||
app.get('/test', () => {
|
||||
throw new Error('Order test');
|
||||
});
|
||||
|
||||
await app.request('/test');
|
||||
|
||||
expect(callOrder).toEqual(['log', 'custom']);
|
||||
});
|
||||
});
|
||||
});
|
||||
273
packages/errors/src/__tests__/FluxerError.test.tsx
Normal file
273
packages/errors/src/__tests__/FluxerError.test.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('FluxerError', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create an error with required options', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(error.code).toBe('TEST_ERROR');
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.message).toBe('TEST_ERROR');
|
||||
expect(error.name).toBe('FluxerError');
|
||||
});
|
||||
|
||||
it('should use provided message instead of code', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
message: 'Custom error message',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(error.code).toBe('TEST_ERROR');
|
||||
expect(error.message).toBe('Custom error message');
|
||||
});
|
||||
|
||||
it('should include optional data', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
status: 400,
|
||||
data: {field: 'username', reason: 'invalid'},
|
||||
});
|
||||
|
||||
expect(error.data).toEqual({field: 'username', reason: 'invalid'});
|
||||
});
|
||||
|
||||
it('should include optional headers', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
status: 400,
|
||||
headers: {'X-Custom-Header': 'value'},
|
||||
});
|
||||
|
||||
expect(error.headers).toEqual({'X-Custom-Header': 'value'});
|
||||
});
|
||||
|
||||
it('should include message variables for i18n', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'RATE_LIMITED',
|
||||
status: 429,
|
||||
messageVariables: {retryAfter: 60},
|
||||
});
|
||||
|
||||
expect(error.messageVariables).toEqual({retryAfter: 60});
|
||||
});
|
||||
|
||||
it('should include cause for error chaining', () => {
|
||||
const cause = new Error('Original error');
|
||||
const error = new FluxerError({
|
||||
code: 'WRAPPED_ERROR',
|
||||
status: 500,
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it('should be an instance of HTTPException', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(HTTPException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponse', () => {
|
||||
it('should return a JSON Response with correct status', async () => {
|
||||
const error = new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
message: 'Test message',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({
|
||||
code: 'TEST_ERROR',
|
||||
message: 'Test message',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include data in response body', async () => {
|
||||
const error = new FluxerError({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
status: 400,
|
||||
data: {errors: [{field: 'email', message: 'Invalid email'}]},
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body).toEqual({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
errors: [{field: 'email', message: 'Invalid email'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('should include custom headers in response', async () => {
|
||||
const error = new FluxerError({
|
||||
code: 'RATE_LIMITED',
|
||||
status: 429,
|
||||
headers: {'Retry-After': '60', 'X-RateLimit-Reset': '1234567890'},
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
|
||||
expect(response.headers.get('Retry-After')).toBe('60');
|
||||
expect(response.headers.get('X-RateLimit-Reset')).toBe('1234567890');
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('should handle empty data', async () => {
|
||||
const error = new FluxerError({
|
||||
code: 'SIMPLE_ERROR',
|
||||
status: 403,
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body).toEqual({
|
||||
code: 'SIMPLE_ERROR',
|
||||
message: 'SIMPLE_ERROR',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJSON', () => {
|
||||
it('should serialize to JSON object', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
message: 'Test message',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const json = error.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
code: 'TEST_ERROR',
|
||||
message: 'Test message',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include data in JSON output', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
status: 400,
|
||||
data: {field: 'username'},
|
||||
});
|
||||
|
||||
const json = error.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
field: 'username',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include status or headers in JSON output', () => {
|
||||
const error = new FluxerError({
|
||||
code: 'TEST_ERROR',
|
||||
status: 500,
|
||||
headers: {'X-Custom': 'value'},
|
||||
});
|
||||
|
||||
const json = error.toJSON();
|
||||
|
||||
expect(json).not.toHaveProperty('status');
|
||||
expect(json).not.toHaveProperty('headers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status codes', () => {
|
||||
it('should handle 4xx client error status codes', () => {
|
||||
const testCases = [
|
||||
{status: 400, name: 'Bad Request'},
|
||||
{status: 401, name: 'Unauthorized'},
|
||||
{status: 403, name: 'Forbidden'},
|
||||
{status: 404, name: 'Not Found'},
|
||||
{status: 409, name: 'Conflict'},
|
||||
{status: 429, name: 'Too Many Requests'},
|
||||
] as const;
|
||||
|
||||
for (const {status} of testCases) {
|
||||
const error = new FluxerError({code: 'TEST', status});
|
||||
expect(error.status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle 5xx server error status codes', () => {
|
||||
const testCases = [
|
||||
{status: 500, name: 'Internal Server Error'},
|
||||
{status: 501, name: 'Not Implemented'},
|
||||
{status: 502, name: 'Bad Gateway'},
|
||||
{status: 503, name: 'Service Unavailable'},
|
||||
{status: 504, name: 'Gateway Timeout'},
|
||||
] as const;
|
||||
|
||||
for (const {status} of testCases) {
|
||||
const error = new FluxerError({code: 'TEST', status});
|
||||
expect(error.status).toBe(status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error properties', () => {
|
||||
it('should have correct name property', () => {
|
||||
const error = new FluxerError({code: 'TEST', status: 400});
|
||||
expect(error.name).toBe('FluxerError');
|
||||
});
|
||||
|
||||
it('should be throwable', () => {
|
||||
const error = new FluxerError({code: 'THROWN_ERROR', status: 400});
|
||||
|
||||
expect(() => {
|
||||
throw error;
|
||||
}).toThrow(FluxerError);
|
||||
});
|
||||
|
||||
it('should be catchable as Error', () => {
|
||||
const error = new FluxerError({code: 'CAUGHT_ERROR', status: 400});
|
||||
|
||||
try {
|
||||
throw error;
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(e).toBeInstanceOf(FluxerError);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
342
packages/errors/src/__tests__/HttpErrors.test.tsx
Normal file
342
packages/errors/src/__tests__/HttpErrors.test.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import {
|
||||
BadGatewayError,
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
GatewayTimeoutError,
|
||||
GoneError,
|
||||
InternalServerError,
|
||||
MethodNotAllowedError,
|
||||
NotFoundError,
|
||||
NotImplementedError,
|
||||
ServiceUnavailableError,
|
||||
UnauthorizedError,
|
||||
} from '@fluxer/errors/src/HttpErrors';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('HttpErrors', () => {
|
||||
describe('BadRequestError', () => {
|
||||
it('should have status 400 and default code/message', () => {
|
||||
const error = new BadRequestError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect(error.code).toBe('BAD_REQUEST');
|
||||
expect(error.message).toBe('Bad Request');
|
||||
expect(error.name).toBe('BadRequestError');
|
||||
});
|
||||
|
||||
it('should allow custom code', () => {
|
||||
const error = new BadRequestError({code: 'INVALID_INPUT'});
|
||||
|
||||
expect(error.code).toBe('INVALID_INPUT');
|
||||
expect(error.message).toBe('Bad Request');
|
||||
});
|
||||
|
||||
it('should allow custom message', () => {
|
||||
const error = new BadRequestError({message: 'Invalid request payload'});
|
||||
|
||||
expect(error.code).toBe('BAD_REQUEST');
|
||||
expect(error.message).toBe('Invalid request payload');
|
||||
});
|
||||
|
||||
it('should allow custom code and message', () => {
|
||||
const error = new BadRequestError({
|
||||
code: 'VALIDATION_FAILED',
|
||||
message: 'Request validation failed',
|
||||
});
|
||||
|
||||
expect(error.code).toBe('VALIDATION_FAILED');
|
||||
expect(error.message).toBe('Request validation failed');
|
||||
});
|
||||
|
||||
it('should include data', () => {
|
||||
const error = new BadRequestError({
|
||||
data: {field: 'email', reason: 'invalid format'},
|
||||
});
|
||||
|
||||
expect(error.data).toEqual({field: 'email', reason: 'invalid format'});
|
||||
});
|
||||
|
||||
it('should include headers', () => {
|
||||
const error = new BadRequestError({
|
||||
headers: {'X-Error-Type': 'validation'},
|
||||
});
|
||||
|
||||
expect(error.headers).toEqual({'X-Error-Type': 'validation'});
|
||||
});
|
||||
|
||||
it('should include cause', () => {
|
||||
const cause = new Error('Original error');
|
||||
const error = new BadRequestError({cause});
|
||||
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it('should be instance of FluxerError', () => {
|
||||
const error = new BadRequestError();
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnauthorizedError', () => {
|
||||
it('should have status 401 and default code/message', () => {
|
||||
const error = new UnauthorizedError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.UNAUTHORIZED);
|
||||
expect(error.code).toBe('UNAUTHORIZED');
|
||||
expect(error.message).toBe('Unauthorized');
|
||||
expect(error.name).toBe('UnauthorizedError');
|
||||
});
|
||||
|
||||
it('should allow custom code', () => {
|
||||
const error = new UnauthorizedError({code: 'INVALID_TOKEN'});
|
||||
|
||||
expect(error.code).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should include WWW-Authenticate header', () => {
|
||||
const error = new UnauthorizedError({
|
||||
headers: {'WWW-Authenticate': 'Bearer realm="api"'},
|
||||
});
|
||||
|
||||
expect(error.headers).toEqual({'WWW-Authenticate': 'Bearer realm="api"'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ForbiddenError', () => {
|
||||
it('should have status 403 and default code/message', () => {
|
||||
const error = new ForbiddenError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.FORBIDDEN);
|
||||
expect(error.code).toBe('FORBIDDEN');
|
||||
expect(error.message).toBe('Forbidden');
|
||||
expect(error.name).toBe('ForbiddenError');
|
||||
});
|
||||
|
||||
it('should allow custom code for permission errors', () => {
|
||||
const error = new ForbiddenError({code: 'MISSING_PERMISSIONS'});
|
||||
|
||||
expect(error.code).toBe('MISSING_PERMISSIONS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotFoundError', () => {
|
||||
it('should have status 404 and default code/message', () => {
|
||||
const error = new NotFoundError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.NOT_FOUND);
|
||||
expect(error.code).toBe('NOT_FOUND');
|
||||
expect(error.message).toBe('Not Found');
|
||||
expect(error.name).toBe('NotFoundError');
|
||||
});
|
||||
|
||||
it('should allow custom code for resource not found', () => {
|
||||
const error = new NotFoundError({code: 'UNKNOWN_USER'});
|
||||
|
||||
expect(error.code).toBe('UNKNOWN_USER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MethodNotAllowedError', () => {
|
||||
it('should have status 405 and default code/message', () => {
|
||||
const error = new MethodNotAllowedError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.METHOD_NOT_ALLOWED);
|
||||
expect(error.code).toBe('METHOD_NOT_ALLOWED');
|
||||
expect(error.message).toBe('Method Not Allowed');
|
||||
expect(error.name).toBe('MethodNotAllowedError');
|
||||
});
|
||||
|
||||
it('should include Allow header', () => {
|
||||
const error = new MethodNotAllowedError({
|
||||
headers: {Allow: 'GET, POST'},
|
||||
});
|
||||
|
||||
expect(error.headers).toEqual({Allow: 'GET, POST'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConflictError', () => {
|
||||
it('should have status 409 and default code/message', () => {
|
||||
const error = new ConflictError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(error.code).toBe('CONFLICT');
|
||||
expect(error.message).toBe('Conflict');
|
||||
expect(error.name).toBe('ConflictError');
|
||||
});
|
||||
|
||||
it('should allow custom code for conflict scenarios', () => {
|
||||
const error = new ConflictError({code: 'USERNAME_TAKEN'});
|
||||
|
||||
expect(error.code).toBe('USERNAME_TAKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoneError', () => {
|
||||
it('should have status 410 and default code/message', () => {
|
||||
const error = new GoneError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.GONE);
|
||||
expect(error.code).toBe('GONE');
|
||||
expect(error.message).toBe('Gone');
|
||||
expect(error.name).toBe('GoneError');
|
||||
});
|
||||
|
||||
it('should allow custom code for deleted resources', () => {
|
||||
const error = new GoneError({code: 'RESOURCE_DELETED'});
|
||||
|
||||
expect(error.code).toBe('RESOURCE_DELETED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InternalServerError', () => {
|
||||
it('should have status 500 and default code/message', () => {
|
||||
const error = new InternalServerError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
expect(error.code).toBe('INTERNAL_SERVER_ERROR');
|
||||
expect(error.message).toBe('Internal Server Error');
|
||||
expect(error.name).toBe('InternalServerError');
|
||||
});
|
||||
|
||||
it('should allow custom code', () => {
|
||||
const error = new InternalServerError({code: 'DATABASE_ERROR'});
|
||||
|
||||
expect(error.code).toBe('DATABASE_ERROR');
|
||||
});
|
||||
|
||||
it('should preserve cause for debugging', () => {
|
||||
const cause = new Error('Database connection failed');
|
||||
const error = new InternalServerError({cause});
|
||||
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotImplementedError', () => {
|
||||
it('should have status 501 and default code/message', () => {
|
||||
const error = new NotImplementedError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.NOT_IMPLEMENTED);
|
||||
expect(error.code).toBe('NOT_IMPLEMENTED');
|
||||
expect(error.message).toBe('Not Implemented');
|
||||
expect(error.name).toBe('NotImplementedError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceUnavailableError', () => {
|
||||
it('should have status 503 and default code/message', () => {
|
||||
const error = new ServiceUnavailableError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
expect(error.code).toBe('SERVICE_UNAVAILABLE');
|
||||
expect(error.message).toBe('Service Unavailable');
|
||||
expect(error.name).toBe('ServiceUnavailableError');
|
||||
});
|
||||
|
||||
it('should include Retry-After header', () => {
|
||||
const error = new ServiceUnavailableError({
|
||||
headers: {'Retry-After': '300'},
|
||||
});
|
||||
|
||||
expect(error.headers).toEqual({'Retry-After': '300'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BadGatewayError', () => {
|
||||
it('should have status 502 and default code/message', () => {
|
||||
const error = new BadGatewayError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.BAD_GATEWAY);
|
||||
expect(error.code).toBe('BAD_GATEWAY');
|
||||
expect(error.message).toBe('Bad Gateway');
|
||||
expect(error.name).toBe('BadGatewayError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GatewayTimeoutError', () => {
|
||||
it('should have status 504 and default code/message', () => {
|
||||
const error = new GatewayTimeoutError();
|
||||
|
||||
expect(error.status).toBe(HttpStatus.GATEWAY_TIMEOUT);
|
||||
expect(error.code).toBe('GATEWAY_TIMEOUT');
|
||||
expect(error.message).toBe('Gateway Timeout');
|
||||
expect(error.name).toBe('GatewayTimeoutError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error inheritance', () => {
|
||||
it('all HTTP errors should extend FluxerError', () => {
|
||||
const errors = [
|
||||
new BadRequestError(),
|
||||
new UnauthorizedError(),
|
||||
new ForbiddenError(),
|
||||
new NotFoundError(),
|
||||
new MethodNotAllowedError(),
|
||||
new ConflictError(),
|
||||
new GoneError(),
|
||||
new InternalServerError(),
|
||||
new NotImplementedError(),
|
||||
new ServiceUnavailableError(),
|
||||
new BadGatewayError(),
|
||||
new GatewayTimeoutError(),
|
||||
];
|
||||
|
||||
for (const error of errors) {
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('response generation', () => {
|
||||
it('should generate correct response for each error type', async () => {
|
||||
const testCases = [
|
||||
{error: new BadRequestError(), expectedStatus: 400},
|
||||
{error: new UnauthorizedError(), expectedStatus: 401},
|
||||
{error: new ForbiddenError(), expectedStatus: 403},
|
||||
{error: new NotFoundError(), expectedStatus: 404},
|
||||
{error: new MethodNotAllowedError(), expectedStatus: 405},
|
||||
{error: new ConflictError(), expectedStatus: 409},
|
||||
{error: new GoneError(), expectedStatus: 410},
|
||||
{error: new InternalServerError(), expectedStatus: 500},
|
||||
{error: new NotImplementedError(), expectedStatus: 501},
|
||||
{error: new BadGatewayError(), expectedStatus: 502},
|
||||
{error: new ServiceUnavailableError(), expectedStatus: 503},
|
||||
{error: new GatewayTimeoutError(), expectedStatus: 504},
|
||||
];
|
||||
|
||||
for (const {error, expectedStatus} of testCases) {
|
||||
const response = error.getResponse();
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('code');
|
||||
expect(body).toHaveProperty('message');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
297
packages/errors/src/__tests__/RateLimitError.test.tsx
Normal file
297
packages/errors/src/__tests__/RateLimitError.test.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
interface RateLimitResponseBody {
|
||||
code: string;
|
||||
retry_after: number;
|
||||
global: boolean;
|
||||
}
|
||||
|
||||
describe('RateLimitError', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-27T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('valid inputs', () => {
|
||||
it('should create error with valid values', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.code).toBe('RATE_LIMITED');
|
||||
expect(error.status).toBe(429);
|
||||
expect(error.data?.retry_after).toBe(5);
|
||||
expect(error.headers?.['Retry-After']).toBe('5');
|
||||
expect(error.headers?.['X-RateLimit-Limit']).toBe('10');
|
||||
});
|
||||
|
||||
it('should use retryAfterDecimal when provided', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
retryAfterDecimal: 4.5,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.data?.retry_after).toBe(4.5);
|
||||
expect(error.headers?.['Retry-After']).toBe('5');
|
||||
});
|
||||
|
||||
it('should set global flag correctly', () => {
|
||||
const globalError = new RateLimitError({
|
||||
global: true,
|
||||
retryAfter: 1,
|
||||
limit: 50,
|
||||
resetTime: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
expect(globalError.data?.global).toBe(true);
|
||||
expect(globalError.headers?.['X-RateLimit-Global']).toBe('true');
|
||||
|
||||
const bucketError = new RateLimitError({
|
||||
global: false,
|
||||
retryAfter: 1,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
expect(bucketError.data?.global).toBe(false);
|
||||
expect(bucketError.headers?.['X-RateLimit-Global']).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitization of invalid inputs', () => {
|
||||
it('should handle undefined retryAfter', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: undefined as unknown as number,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['Retry-After']).toBe('1');
|
||||
expect(Number.isNaN(Number(error.headers?.['Retry-After']))).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle NaN retryAfter', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: NaN,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['Retry-After']).toBe('1');
|
||||
expect(error.data?.retry_after).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle Infinity retryAfter', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: Infinity,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['Retry-After']).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle negative retryAfter', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: -5,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['Retry-After']).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle zero retryAfter', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 0,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['Retry-After']).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle invalid resetTime', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
limit: 10,
|
||||
resetTime: new Date(NaN),
|
||||
});
|
||||
|
||||
const resetTimestamp = Number(error.headers?.['X-RateLimit-Reset']);
|
||||
const nowTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
expect(Number.isFinite(resetTimestamp)).toBe(true);
|
||||
expect(resetTimestamp).toBeGreaterThan(nowTimestamp);
|
||||
});
|
||||
|
||||
it('should handle resetTime in the past', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() - 10000),
|
||||
});
|
||||
|
||||
const resetTimestamp = Number(error.headers?.['X-RateLimit-Reset']);
|
||||
const nowTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
expect(resetTimestamp).toBeGreaterThan(nowTimestamp);
|
||||
});
|
||||
|
||||
it('should handle invalid limit', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
limit: NaN,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['X-RateLimit-Limit']).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle zero limit', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
limit: 0,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['X-RateLimit-Limit']).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle NaN retryAfterDecimal', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
retryAfterDecimal: NaN,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
expect(error.data?.retry_after).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('response format', () => {
|
||||
it('should produce valid JSON response', async () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 10,
|
||||
limit: 50,
|
||||
resetTime: new Date(Date.now() + 10000),
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
const body = (await response.json()) as RateLimitResponseBody;
|
||||
|
||||
expect(body.code).toBe('RATE_LIMITED');
|
||||
expect(body.retry_after).toBe(10);
|
||||
expect(body.global).toBe(false);
|
||||
expect(typeof body.retry_after).toBe('number');
|
||||
});
|
||||
|
||||
it('should never produce null retry_after in response', async () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: undefined as unknown as number,
|
||||
retryAfterDecimal: undefined,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
const body = (await response.json()) as RateLimitResponseBody;
|
||||
|
||||
expect(body.retry_after).not.toBeNull();
|
||||
expect(typeof body.retry_after).toBe('number');
|
||||
expect(Number.isFinite(body.retry_after)).toBe(true);
|
||||
});
|
||||
|
||||
it('should set correct status code', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
expect(response.status).toBe(429);
|
||||
});
|
||||
|
||||
it('should include all required headers', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 5,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 5000),
|
||||
global: true,
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
expect(response.headers.get('Retry-After')).toBe('5');
|
||||
expect(response.headers.get('X-RateLimit-Global')).toBe('true');
|
||||
expect(response.headers.get('X-RateLimit-Limit')).toBe('10');
|
||||
expect(response.headers.get('X-RateLimit-Remaining')).toBe('0');
|
||||
expect(response.headers.get('X-RateLimit-Reset')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle all inputs being invalid simultaneously', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: NaN,
|
||||
retryAfterDecimal: Infinity,
|
||||
limit: -1,
|
||||
resetTime: new Date(NaN),
|
||||
});
|
||||
|
||||
expect(error.headers?.['Retry-After']).toBe('1');
|
||||
expect(error.headers?.['X-RateLimit-Limit']).toBe('1');
|
||||
expect(Number.isFinite(Number(error.headers?.['X-RateLimit-Reset']))).toBe(true);
|
||||
expect(Number.isFinite(error.data?.retry_after as number)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle very large retryAfter values', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 999999999,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 999999999000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['Retry-After']).toBe('999999999');
|
||||
});
|
||||
|
||||
it('should ceil fractional retryAfter for header', () => {
|
||||
const error = new RateLimitError({
|
||||
retryAfter: 1.1,
|
||||
limit: 10,
|
||||
resetTime: new Date(Date.now() + 2000),
|
||||
});
|
||||
|
||||
expect(error.headers?.['Retry-After']).toBe('2');
|
||||
});
|
||||
});
|
||||
});
|
||||
227
packages/errors/src/__tests__/ValidationError.test.tsx
Normal file
227
packages/errors/src/__tests__/ValidationError.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
||||
import {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import {ValidationError} from '@fluxer/errors/src/ValidationError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface ValidationErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
errors: Array<{field: string; code: string; message: string}>;
|
||||
}
|
||||
|
||||
describe('ValidationError', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create error with single field error', () => {
|
||||
const error = new ValidationError({
|
||||
errors: [{field: 'email', code: 'INVALID_EMAIL', message: 'Invalid email format'}],
|
||||
});
|
||||
|
||||
expect(error.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect(error.code).toBe('VALIDATION_ERROR');
|
||||
expect(error.message).toBe('Validation failed');
|
||||
expect(error.name).toBe('ValidationError');
|
||||
expect(error.errors).toEqual([{field: 'email', code: 'INVALID_EMAIL', message: 'Invalid email format'}]);
|
||||
});
|
||||
|
||||
it('should create error with multiple field errors', () => {
|
||||
const fieldErrors = [
|
||||
{field: 'email', code: 'REQUIRED', message: 'Email is required'},
|
||||
{field: 'password', code: 'TOO_SHORT', message: 'Password must be at least 8 characters'},
|
||||
{field: 'username', code: 'INVALID_CHARS', message: 'Username contains invalid characters'},
|
||||
];
|
||||
|
||||
const error = new ValidationError({errors: fieldErrors});
|
||||
|
||||
expect(error.errors).toHaveLength(3);
|
||||
expect(error.errors).toEqual(fieldErrors);
|
||||
});
|
||||
|
||||
it('should allow custom code', () => {
|
||||
const error = new ValidationError({
|
||||
code: 'INVALID_FORM_BODY',
|
||||
errors: [{field: 'name', code: 'REQUIRED', message: 'Name is required'}],
|
||||
});
|
||||
|
||||
expect(error.code).toBe('INVALID_FORM_BODY');
|
||||
});
|
||||
|
||||
it('should allow custom message', () => {
|
||||
const error = new ValidationError({
|
||||
message: 'Input validation failed',
|
||||
errors: [{field: 'name', code: 'REQUIRED', message: 'Name is required'}],
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Input validation failed');
|
||||
});
|
||||
|
||||
it('should be instance of FluxerError', () => {
|
||||
const error = new ValidationError({
|
||||
errors: [{field: 'field', code: 'CODE', message: 'message'}],
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponse', () => {
|
||||
it('should return JSON response with errors array', async () => {
|
||||
const error = new ValidationError({
|
||||
errors: [{field: 'email', code: 'INVALID', message: 'Invalid email'}],
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
errors: [{field: 'email', code: 'INVALID', message: 'Invalid email'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('should include multiple errors in response', async () => {
|
||||
const fieldErrors = [
|
||||
{field: 'email', code: 'REQUIRED', message: 'Email is required'},
|
||||
{field: 'password', code: 'TOO_SHORT', message: 'Password too short'},
|
||||
];
|
||||
|
||||
const error = new ValidationError({errors: fieldErrors});
|
||||
const response = error.getResponse();
|
||||
const body = (await response.json()) as ValidationErrorBody;
|
||||
|
||||
expect(body.errors).toHaveLength(2);
|
||||
expect(body.errors).toEqual(fieldErrors);
|
||||
});
|
||||
|
||||
it('should include custom code and message in response', async () => {
|
||||
const error = new ValidationError({
|
||||
code: 'CUSTOM_VALIDATION',
|
||||
message: 'Custom validation message',
|
||||
errors: [{field: 'field', code: 'CODE', message: 'message'}],
|
||||
});
|
||||
|
||||
const response = error.getResponse();
|
||||
const body = (await response.json()) as ValidationErrorBody;
|
||||
|
||||
expect(body.code).toBe('CUSTOM_VALIDATION');
|
||||
expect(body.message).toBe('Custom validation message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromField static method', () => {
|
||||
it('should create ValidationError from single field', () => {
|
||||
const error = ValidationError.fromField('username', 'TAKEN', 'Username is already taken');
|
||||
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
expect(error.errors).toEqual([{field: 'username', code: 'TAKEN', message: 'Username is already taken'}]);
|
||||
});
|
||||
|
||||
it('should have default code and message', () => {
|
||||
const error = ValidationError.fromField('field', 'code', 'message');
|
||||
|
||||
expect(error.code).toBe('VALIDATION_ERROR');
|
||||
expect(error.message).toBe('Validation failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromFields static method', () => {
|
||||
it('should create ValidationError from multiple fields', () => {
|
||||
const fieldErrors = [
|
||||
{field: 'email', code: 'REQUIRED', message: 'Email is required'},
|
||||
{field: 'password', code: 'WEAK', message: 'Password is too weak'},
|
||||
];
|
||||
|
||||
const error = ValidationError.fromFields(fieldErrors);
|
||||
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
expect(error.errors).toEqual(fieldErrors);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const error = ValidationError.fromFields([]);
|
||||
|
||||
expect(error.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error data structure', () => {
|
||||
it('should store errors in data property', () => {
|
||||
const fieldErrors = [{field: 'test', code: 'TEST', message: 'Test error'}];
|
||||
const error = new ValidationError({errors: fieldErrors});
|
||||
|
||||
expect(error.data).toEqual({errors: fieldErrors});
|
||||
});
|
||||
|
||||
it('should serialize correctly with toJSON', () => {
|
||||
const error = new ValidationError({
|
||||
errors: [{field: 'field', code: 'CODE', message: 'message'}],
|
||||
});
|
||||
|
||||
const json = error.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
errors: [{field: 'field', code: 'CODE', message: 'message'}],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle field errors with special characters', () => {
|
||||
const error = new ValidationError({
|
||||
errors: [{field: 'user.email', code: 'INVALID', message: "Email can't be empty"}],
|
||||
});
|
||||
|
||||
expect(error.errors[0].field).toBe('user.email');
|
||||
expect(error.errors[0].message).toBe("Email can't be empty");
|
||||
});
|
||||
|
||||
it('should handle nested field paths', () => {
|
||||
const error = new ValidationError({
|
||||
errors: [
|
||||
{field: 'address.street', code: 'REQUIRED', message: 'Street is required'},
|
||||
{field: 'address.city', code: 'REQUIRED', message: 'City is required'},
|
||||
{field: 'address.zip', code: 'INVALID_FORMAT', message: 'Invalid ZIP code format'},
|
||||
],
|
||||
});
|
||||
|
||||
expect(error.errors).toHaveLength(3);
|
||||
expect(error.errors[0].field).toBe('address.street');
|
||||
});
|
||||
|
||||
it('should handle array index field paths', () => {
|
||||
const error = new ValidationError({
|
||||
errors: [
|
||||
{field: 'items[0].name', code: 'REQUIRED', message: 'Item name is required'},
|
||||
{field: 'items[1].quantity', code: 'MIN', message: 'Quantity must be at least 1'},
|
||||
],
|
||||
});
|
||||
|
||||
expect(error.errors).toHaveLength(2);
|
||||
expect(error.errors[0].field).toBe('items[0].name');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user