mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
254 lines
11 KiB
TypeScript
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',
|
|
});
|
|
});
|
|
});
|