mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
refactor(schema): simplify validation and OpenAPI generation (#2709)
This commit is contained in:
1 parent
4bc5593f9f
commit
de1fd95a99
510 files changed
+29494
-36131
No files matched your search
@@ -20,308 +20,146 @@ 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.each([
|
||||
[APIErrorCodes.INVALID_REQUEST, BadRequestError, HttpStatus.BAD_REQUEST],
|
||||
[APIErrorCodes.ACCESS_DENIED, ForbiddenError, HttpStatus.FORBIDDEN],
|
||||
[APIErrorCodes.UNKNOWN_USER, NotFoundError, HttpStatus.NOT_FOUND],
|
||||
[APIErrorCodes.GENERAL_ERROR, InternalServerError, HttpStatus.INTERNAL_SERVER_ERROR],
|
||||
] as const)('%s', (code, ErrorClass, status) => {
|
||||
it('uses the domain code as its default message', async () => {
|
||||
const error = new ErrorClass({code});
|
||||
const response = error.getResponse();
|
||||
|
||||
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);
|
||||
});
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
expect(error).toMatchObject({status, code, message: code});
|
||||
expect(response.status).toBe(status);
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
expect(error.toJSON()).toEqual({code, message: code});
|
||||
expect(await response.json()).toEqual({code, message: code});
|
||||
});
|
||||
|
||||
it('preserves data, headers, and localization variables', async () => {
|
||||
const error = new ErrorClass({
|
||||
code,
|
||||
data: {field: 'email'},
|
||||
headers: {'X-Permission-Required': 'admin'},
|
||||
messageVariables: {count: 5},
|
||||
});
|
||||
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([
|
||||
{path: 'email', message: 'Invalid email'},
|
||||
{path: '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);
|
||||
});
|
||||
const response = error.getResponse();
|
||||
|
||||
expect(error.messageVariables).toEqual({count: 5});
|
||||
expect(response.headers.get('X-Permission-Required')).toBe('admin');
|
||||
expect(await response.json()).toEqual({code, message: code, field: 'email'});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
BadRequestError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
])('preserves custom messages for client errors', (ErrorClass) => {
|
||||
const error = new ErrorClass({code: APIErrorCodes.INVALID_REQUEST, message: 'Custom error message'});
|
||||
|
||||
expect(error.toJSON()).toEqual({code: APIErrorCodes.INVALID_REQUEST, message: 'Custom error message'});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
[APIErrorCodes.INVALID_PHONE_NUMBER, InvalidPhoneNumberError, BadRequestError, HttpStatus.BAD_REQUEST],
|
||||
[APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED, PhoneCountryNotSupportedError, BadRequestError, HttpStatus.BAD_REQUEST],
|
||||
[
|
||||
APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED,
|
||||
PhoneInboundVerificationRequiredError,
|
||||
BadRequestError,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
],
|
||||
[APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE, PhoneLookupUnavailableError, BadRequestError, HttpStatus.BAD_REQUEST],
|
||||
[APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE, PhoneNumberNotInServiceError, BadRequestError, HttpStatus.BAD_REQUEST],
|
||||
[APIErrorCodes.PHONE_NUMBER_NOT_MOBILE, PhoneNumberNotMobileError, BadRequestError, HttpStatus.BAD_REQUEST],
|
||||
[
|
||||
APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW,
|
||||
PhoneVerificationNeedsReviewError,
|
||||
BadRequestError,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
],
|
||||
[APIErrorCodes.UNKNOWN_CHANNEL, UnknownChannelError, NotFoundError, HttpStatus.NOT_FOUND],
|
||||
[APIErrorCodes.UNKNOWN_MESSAGE, UnknownMessageError, NotFoundError, HttpStatus.NOT_FOUND],
|
||||
] as const)('%s', (code, ErrorClass, BaseClass, status) => {
|
||||
it('preserves the domain inheritance and response contract', async () => {
|
||||
const error = new ErrorClass();
|
||||
const response = error.getResponse();
|
||||
|
||||
expect(error).toBeInstanceOf(ErrorClass);
|
||||
expect(error).toBeInstanceOf(BaseClass);
|
||||
expect(error).toBeInstanceOf(FluxerError);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toMatchObject({code, status});
|
||||
expect(error.toJSON()).toEqual({code, message: code});
|
||||
expect(response.status).toBe(status);
|
||||
expect(await response.json()).toEqual({code, message: code});
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputValidationError', () => {
|
||||
const errors = [
|
||||
{path: 'email', message: 'Invalid email'},
|
||||
{path: 'password', message: 'Password too short'},
|
||||
];
|
||||
|
||||
it.each([
|
||||
['constructor', () => new InputValidationError(errors), errors],
|
||||
['createMultiple', () => InputValidationError.createMultiple(errors), errors],
|
||||
['create', () => InputValidationError.create('email', 'Invalid email'), [errors[0]]],
|
||||
] as const)('%s retains every field error in the response', async (_name, createError, expectedErrors) => {
|
||||
const error = createError();
|
||||
const response = error.getResponse();
|
||||
|
||||
expect(error).toBeInstanceOf(InputValidationError);
|
||||
expect(error.data).toEqual({errors: expectedErrors});
|
||||
expect(error.localizedErrors).toBeNull();
|
||||
expect(error.getLocalizedErrors()).toBeNull();
|
||||
expect(response.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect(await response.json()).toEqual({
|
||||
code: APIErrorCodes.INVALID_FORM_BODY,
|
||||
message: APIErrorCodes.INVALID_FORM_BODY,
|
||||
errors: expectedErrors,
|
||||
});
|
||||
});
|
||||
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.each([
|
||||
[PhoneCountryNotSupportedError, APIErrorCodes.PHONE_COUNTRY_NOT_SUPPORTED],
|
||||
[PhoneInboundVerificationRequiredError, APIErrorCodes.PHONE_INBOUND_VERIFICATION_REQUIRED],
|
||||
[PhoneLookupUnavailableError, APIErrorCodes.PHONE_LOOKUP_UNAVAILABLE],
|
||||
[PhoneNumberNotInServiceError, APIErrorCodes.PHONE_NUMBER_NOT_IN_SERVICE],
|
||||
[PhoneNumberNotMobileError, APIErrorCodes.PHONE_NUMBER_NOT_MOBILE],
|
||||
[PhoneVerificationNeedsReviewError, APIErrorCodes.PHONE_VERIFICATION_NEEDS_REVIEW],
|
||||
] as const)('%p', (ErrorClass, code) => {
|
||||
it('should have correct code from APIErrorCodes', () => {
|
||||
const error = new ErrorClass();
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
it('should be instance of BadRequestError', () => {
|
||||
const error = new ErrorClass();
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
});
|
||||
it('should generate a response body carrying the code', async () => {
|
||||
const error = new ErrorClass();
|
||||
const response = error.getResponse();
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as ErrorResponse;
|
||||
expect(body).toEqual({code, message: code});
|
||||
});
|
||||
});
|
||||
|
||||
it('retains explicitly supplied localization metadata separately from field messages', () => {
|
||||
const localizedErrors = [{path: 'email', code: ValidationErrorCodes.EMAIL_IS_REQUIRED}];
|
||||
const error = new InputValidationError(errors, localizedErrors);
|
||||
|
||||
expect(error.data).toEqual({errors});
|
||||
expect(error.localizedErrors).toEqual(localizedErrors);
|
||||
expect(error.getLocalizedErrors()).toEqual(localizedErrors);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a localized field error with interpolation variables', () => {
|
||||
const code = ValidationErrorCodes.STRING_LENGTH_INVALID;
|
||||
const variables = {min: 1, max: 100};
|
||||
const error = InputValidationError.fromCode('name', code, variables);
|
||||
|
||||
expect(error.data).toEqual({errors: [{path: 'name', code, message: code}]});
|
||||
expect(error.getLocalizedErrors()).toEqual([{path: 'name', code, variables}]);
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
it('preserves all localized codes, paths, and variables in order', () => {
|
||||
const localizedErrors = [
|
||||
{path: 'email', code: ValidationErrorCodes.EMAIL_IS_REQUIRED},
|
||||
{path: 'name', code: ValidationErrorCodes.STRING_LENGTH_INVALID, variables: {max: 100}},
|
||||
];
|
||||
const error = InputValidationError.fromCodes(localizedErrors);
|
||||
|
||||
expect(error.getLocalizedErrors()).toEqual(localizedErrors);
|
||||
expect(error.data).toEqual({
|
||||
errors: [
|
||||
{path: 'email', code: ValidationErrorCodes.EMAIL_IS_REQUIRED, message: ValidationErrorCodes.EMAIL_IS_REQUIRED},
|
||||
{
|
||||
path: 'name',
|
||||
code: ValidationErrorCodes.STRING_LENGTH_INVALID,
|
||||
message: ValidationErrorCodes.STRING_LENGTH_INVALID,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user