Files
fluxer/packages/errors/src/__tests__/DomainErrors.test.ts
T

254 lines
11 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {AccountIdentityLockedError} from '@fluxer/errors/src/domains/auth/AccountIdentityLockedError';
import {EmailUnavailableOnInstanceError} from '@fluxer/errors/src/domains/auth/EmailUnavailableOnInstanceError';
import {UsernameSignInOnlyError} from '@fluxer/errors/src/domains/auth/UsernameSignInOnlyError';
import {MaxActiveThreadsError} from '@fluxer/errors/src/domains/channel/MaxActiveThreadsError';
import {SearchIndexNotReadyError} from '@fluxer/errors/src/domains/channel/SearchIndexNotReadyError';
import {ThreadArchivedError} from '@fluxer/errors/src/domains/channel/ThreadArchivedError';
import {ThreadLockedError} from '@fluxer/errors/src/domains/channel/ThreadLockedError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {UnknownThreadMemberError} from '@fluxer/errors/src/domains/channel/UnknownThreadMemberError';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
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 {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {PremiumPurchaseBlockedError} from '@fluxer/errors/src/domains/payment/PremiumPurchaseBlockedError';
import {StoreBillingUnavailableError} from '@fluxer/errors/src/domains/payment/StoreBillingUnavailableError';
import {StoreNotificationUnauthorizedError} from '@fluxer/errors/src/domains/payment/StoreNotificationUnauthorizedError';
import {StorePurchaseInvalidError} from '@fluxer/errors/src/domains/payment/StorePurchaseInvalidError';
import {StorePurchaseOwnedByOtherAccountError} from '@fluxer/errors/src/domains/payment/StorePurchaseOwnedByOtherAccountError';
import {StorePurchaseSandboxNotEntitledError} from '@fluxer/errors/src/domains/payment/StorePurchaseSandboxNotEntitledError';
import {UnknownStorePurchaseError} from '@fluxer/errors/src/domains/payment/UnknownStorePurchaseError';
import {AccountLimitedError} from '@fluxer/errors/src/domains/user/AccountLimitedError';
import {FluxerError} from '@fluxer/errors/src/FluxerError';
import {describe, expect, it} from 'vitest';
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();
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},
});
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.ACCOUNT_LIMITED, AccountLimitedError, ForbiddenError, HttpStatus.FORBIDDEN],
[APIErrorCodes.ACCOUNT_IDENTITY_LOCKED, AccountIdentityLockedError, ConflictError, HttpStatus.CONFLICT],
[
APIErrorCodes.EMAIL_UNAVAILABLE_ON_INSTANCE,
EmailUnavailableOnInstanceError,
BadRequestError,
HttpStatus.BAD_REQUEST,
],
[APIErrorCodes.USERNAME_SIGN_IN_ONLY, UsernameSignInOnlyError, BadRequestError, HttpStatus.BAD_REQUEST],
[APIErrorCodes.UNKNOWN_CHANNEL, UnknownChannelError, NotFoundError, HttpStatus.NOT_FOUND],
[APIErrorCodes.UNKNOWN_MESSAGE, UnknownMessageError, NotFoundError, HttpStatus.NOT_FOUND],
[APIErrorCodes.STORE_PURCHASE_INVALID, StorePurchaseInvalidError, BadRequestError, HttpStatus.BAD_REQUEST],
[
APIErrorCodes.STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT,
StorePurchaseOwnedByOtherAccountError,
ForbiddenError,
HttpStatus.FORBIDDEN,
],
[
APIErrorCodes.STORE_PURCHASE_SANDBOX_NOT_ENTITLED,
StorePurchaseSandboxNotEntitledError,
ForbiddenError,
HttpStatus.FORBIDDEN,
],
[
APIErrorCodes.STORE_BILLING_UNAVAILABLE,
StoreBillingUnavailableError,
ServiceUnavailableError,
HttpStatus.SERVICE_UNAVAILABLE,
],
[APIErrorCodes.UNKNOWN_STORE_PURCHASE, UnknownStorePurchaseError, NotFoundError, HttpStatus.NOT_FOUND],
[
APIErrorCodes.STORE_NOTIFICATION_UNAUTHORIZED,
StoreNotificationUnauthorizedError,
FluxerError,
HttpStatus.UNAUTHORIZED,
],
[APIErrorCodes.THREAD_ARCHIVED, ThreadArchivedError, BadRequestError, HttpStatus.BAD_REQUEST],
[APIErrorCodes.THREAD_LOCKED, ThreadLockedError, BadRequestError, HttpStatus.BAD_REQUEST],
[APIErrorCodes.UNKNOWN_THREAD_MEMBER, UnknownThreadMemberError, 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('thread limit and index errors', () => {
it('reports the thread limit in data and message variables', async () => {
const error = new MaxActiveThreadsError(1000);
expect(error.status).toBe(HttpStatus.BAD_REQUEST);
expect(error.messageVariables).toEqual({count: 1000});
expect(await error.getResponse().json()).toEqual({
code: APIErrorCodes.MAX_ACTIVE_THREADS,
message: APIErrorCodes.MAX_ACTIVE_THREADS,
max_active_threads: 1000,
});
});
it('answers an unready search index with 202 and a retry hint', async () => {
const response = new SearchIndexNotReadyError(2).getResponse();
expect(response.status).toBe(202);
expect(await response.json()).toEqual({
code: APIErrorCodes.SEARCH_INDEX_NOT_READY,
message: APIErrorCodes.SEARCH_INDEX_NOT_READY,
documents_indexed: 0,
retry_after: 2,
});
});
});
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,
});
});
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);
});
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}]);
});
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,
},
],
});
});
});
describe('PremiumPurchaseBlockedError', () => {
it('keeps the reason and extra data of existing callers', () => {
const error = new PremiumPurchaseBlockedError('existing_subscription', {subscription_status: 'active'});
expect(error.status).toBe(HttpStatus.FORBIDDEN);
expect(error.toJSON()).toEqual({
code: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED,
message: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED,
subscription_status: 'active',
reason: 'existing_subscription',
});
});
it('includes the blocking provider when one is given', () => {
const error = new PremiumPurchaseBlockedError('existing_subscription', {provider: 'app_store'});
expect(error.toJSON()).toEqual({
code: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED,
message: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED,
provider: 'app_store',
reason: 'existing_subscription',
});
});
it('defaults to the purchase_disabled reason with no provider', () => {
expect(new PremiumPurchaseBlockedError().toJSON()).toEqual({
code: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED,
message: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED,
reason: 'purchase_disabled',
});
});
});