diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 557bd569c6f..661ebd5f579 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING**: Added two new params, `captureException` and `fetchFunction` to `SubscriptionService` constructor args. ([#7835](https://github.com/MetaMask/core/pull/7835)) + - `fetchFunction` is to use the client provided `Fetch` API. + - `captureException` is to capture the error thrown and report to Sentry. + ### Changed +- Updated `SubscriptionServiceError` to include more information for Sentry reporting. ([#7835](https://github.com/MetaMask/core/pull/7835)) - Bump `@metamask/profile-sync-controller` from `^27.0.0` to `^27.1.0` ([#7849](https://github.com/MetaMask/core/pull/7849)) - Bump `@metamask/transaction-controller` from `^62.12.0` to `^62.14.0` ([#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832)) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index dfa036df6f7..628d2b2735d 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -436,6 +436,23 @@ describe('SubscriptionController', () => { }); }); + it('should surface triggerAccessTokenRefresh errors', async () => { + await withController( + async ({ controller, mockService, mockPerformSignOut }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_GET_SUBSCRIPTIONS_RESPONSE, + ); + mockPerformSignOut.mockImplementation(() => { + throw new Error('Wallet is locked'); + }); + + await expect(controller.getSubscriptions()).rejects.toThrow( + 'Wallet is locked', + ); + }, + ); + }); + it('should update state when subscription is fetched', async () => { const initialSubscription = { ...MOCK_SUBSCRIPTION, id: 'sub_old' }; const newSubscription = { ...MOCK_SUBSCRIPTION, id: 'sub_new' }; diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 849cd02283a..ff04d102e60 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -1,10 +1,10 @@ -import { handleFetch } from '@metamask/controller-utils'; - import { Env, getEnvUrls, SubscriptionControllerErrorMessage, + SubscriptionServiceErrorMessage, } from './constants'; +import * as constants from './constants'; import { SubscriptionServiceError } from './errors'; import { SUBSCRIPTION_URL, @@ -29,13 +29,6 @@ import { SubscriptionUserEvent, } from './types'; -// Mock the handleFetch function -jest.mock('@metamask/controller-utils', () => ({ - handleFetch: jest.fn(), -})); - -const handleFetchMock = handleFetch as jest.Mock; - // Mock data const MOCK_SUBSCRIPTION: Subscription = { id: 'sub_123456789', @@ -115,6 +108,38 @@ function createMockEligibilityResponse( }; } +type MockConfig = SubscriptionServiceConfig & { + fetchMock: jest.Mock; + captureExceptionMock: jest.Mock; +}; + +type MockResponseOptions = { + ok?: boolean; + status?: number; + jsonData?: unknown; + textData?: string; + contentType?: string | null; +}; + +function createMockResponse({ + ok = true, + status = 200, + jsonData, + textData = '', + contentType = 'application/json', +}: MockResponseOptions): Response { + return { + ok, + status, + headers: { + get: (key: string) => + key.toLowerCase() === 'content-type' ? contentType : null, + }, + json: jest.fn().mockResolvedValue(jsonData), + text: jest.fn().mockResolvedValue(textData), + } as unknown as Response; +} + /** * Creates a mock subscription service config for testing * @@ -122,15 +147,20 @@ function createMockEligibilityResponse( * @param [params.env] - The environment to use for the config * @returns The mock configuration object */ -function createMockConfig({ - env = Env.DEV, -}: { env?: Env } = {}): SubscriptionServiceConfig { +function createMockConfig({ env = Env.DEV }: { env?: Env } = {}): MockConfig { + const fetchMock = jest.fn(); + const captureExceptionMock = jest.fn(); + return { env, auth: { getAccessToken: jest.fn().mockResolvedValue(MOCK_ACCESS_TOKEN), }, - }; + fetchFunction: fetchMock, + captureException: captureExceptionMock, + fetchMock, + captureExceptionMock, + } as MockConfig; } /** @@ -189,11 +219,15 @@ describe('SubscriptionService', () => { describe('getSubscriptions', () => { it('should fetch subscriptions successfully', async () => { await withMockSubscriptionService(async ({ service, config }) => { - handleFetchMock.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION], - trialedProducts: [], - }); + config.fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION], + trialedProducts: [], + }, + }), + ); const result = await service.getSubscriptions(); @@ -206,23 +240,112 @@ describe('SubscriptionService', () => { }); }); - it('should throw SubscriptionServiceError for error responses', async () => { - await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockRejectedValue(new Error('Network error')); + it('should throw when URL construction fails', async () => { + const config = createMockConfig({ env: 'invalid' as Env }); + const service = new SubscriptionService(config); + + await expect(service.getSubscriptions()).rejects.toThrow( + 'invalid environment configuration', + ); + expect(config.fetchMock).not.toHaveBeenCalled(); + expect(config.captureExceptionMock).toHaveBeenCalledTimes(1); + const capturedError = config.captureExceptionMock.mock + .calls[0][0] as Error & { cause?: Error }; + expect(capturedError.message).toBe( + 'Failed to get subscription API URL. invalid environment configuration', + ); + }); - await expect(service.getSubscriptions()).rejects.toThrow( - SubscriptionServiceError, + it('should capture non-Error URL construction failures', async () => { + const config = createMockConfig(); + const service = new SubscriptionService(config); + const getEnvUrlsSpy = jest + .spyOn(constants, 'getEnvUrls') + .mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'string error'; + }); + + try { + const error = await service + .getSubscriptions() + .catch((rejection) => rejection); + expect(error).toBe('string error'); + } finally { + getEnvUrlsSpy.mockRestore(); + } + + expect(config.fetchMock).not.toHaveBeenCalled(); + expect(config.captureExceptionMock).toHaveBeenCalledTimes(1); + const capturedError = config.captureExceptionMock.mock + .calls[0][0] as Error & { cause?: Error }; + expect(capturedError.message).toBe( + 'Failed to get subscription API URL. Unknown error when getting subscription API URL', + ); + expect(capturedError.cause).toBeInstanceOf(Error); + expect(capturedError.cause?.message).toBe( + 'Unknown error when getting subscription API URL', + ); + }); + + it('should throw SubscriptionServiceError for network errors', async () => { + await withMockSubscriptionService(async ({ service, config }) => { + const networkError = new Error('Network error'); + config.fetchMock.mockRejectedValue(networkError); + + const error = await service.getSubscriptions().then( + () => { + throw new Error('Expected getSubscriptions to throw'); + }, + (rejection) => rejection, ); + + expect(error).toBeInstanceOf(SubscriptionServiceError); + const serviceError = error as SubscriptionServiceError; + expect(serviceError.message).toBe( + `Failed to make request. ${SubscriptionServiceErrorMessage.FailedToGetSubscriptions} (url: ${getTestUrl(Env.DEV)}/v1/subscriptions)`, + ); + expect(serviceError.cause).toBe(networkError); + expect(config.captureExceptionMock).toHaveBeenCalledTimes(1); + }); + + await withMockSubscriptionService(async ({ service, config }) => { + config.fetchMock.mockRejectedValue('string error'); + + const requestPromise = service.getSubscriptions(); + + await expect(requestPromise).rejects.toThrow(SubscriptionServiceError); + await expect(requestPromise).rejects.toThrow( + `Failed to make request. ${SubscriptionServiceErrorMessage.FailedToGetSubscriptions} (url: ${getTestUrl(Env.DEV)}/v1/subscriptions)`, + ); + const error = await requestPromise.catch((rejection) => rejection); + expect(error).toBeInstanceOf(SubscriptionServiceError); + const serviceError = error as SubscriptionServiceError; + expect(serviceError.cause).toBeInstanceOf(Error); + expect(serviceError.cause?.message).toBe( + SubscriptionServiceErrorMessage.FailedToGetSubscriptions, + ); + expect(config.captureExceptionMock).toHaveBeenCalledTimes(1); }); }); - it('should throw SubscriptionServiceError for network errors', async () => { - await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockRejectedValue(new Error('Network error')); + it('should throw SubscriptionServiceError for non-ok responses', async () => { + await withMockSubscriptionService(async ({ service, config }) => { + config.fetchMock.mockResolvedValue( + createMockResponse({ + ok: false, + status: 500, + jsonData: { error: 'Internal Server Error' }, + }), + ); + + const requestPromise = service.getSubscriptions(); - await expect(service.getSubscriptions()).rejects.toThrow( - SubscriptionServiceError, + await expect(requestPromise).rejects.toThrow(SubscriptionServiceError); + await expect(requestPromise).rejects.toThrow( + `Failed to make request. ${SubscriptionServiceErrorMessage.FailedToGetSubscriptions} (url: ${getTestUrl(Env.DEV)}/v1/subscriptions)`, ); + expect(config.captureExceptionMock).toHaveBeenCalledTimes(1); }); }); @@ -233,8 +356,25 @@ describe('SubscriptionService', () => { 'string error', ); - await expect(service.getSubscriptions()).rejects.toThrow( - SubscriptionServiceError, + const requestPromise = service.getSubscriptions(); + + await expect(requestPromise).rejects.toThrow(SubscriptionServiceError); + await expect(requestPromise).rejects.toThrow( + 'Failed to get authorization header. Unknown error when getting authorization header', + ); + }); + + await withMockSubscriptionService(async ({ service, config }) => { + // Simulate a non-Error thrown from the auth.getAccessToken mock + (config.auth.getAccessToken as jest.Mock).mockRejectedValue( + new Error('Wallet is locked'), + ); + + const requestPromise = service.getSubscriptions(); + + await expect(requestPromise).rejects.toThrow(SubscriptionServiceError); + await expect(requestPromise).rejects.toThrow( + 'Failed to get authorization header. Wallet is locked', ); }); }); @@ -243,7 +383,9 @@ describe('SubscriptionService', () => { describe('cancelSubscription', () => { it('should cancel subscription successfully', async () => { await withMockSubscriptionService(async ({ service, config }) => { - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.cancelSubscription({ subscriptionId: 'sub_123456789' }); @@ -252,8 +394,8 @@ describe('SubscriptionService', () => { }); it('should throw SubscriptionServiceError for network errors', async () => { - await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockRejectedValue(new Error('Network error')); + await withMockSubscriptionService(async ({ service, config }) => { + config.fetchMock.mockRejectedValue(new Error('Network error')); await expect( service.cancelSubscription({ subscriptionId: 'sub_123456789' }), @@ -265,7 +407,9 @@ describe('SubscriptionService', () => { describe('uncancelSubscription', () => { it('should cancel subscription successfully', async () => { await withMockSubscriptionService(async ({ service, config }) => { - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.unCancelSubscription({ subscriptionId: 'sub_123456789' }); @@ -274,8 +418,8 @@ describe('SubscriptionService', () => { }); it('should throw SubscriptionServiceError for network errors', async () => { - await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockRejectedValue(new Error('Network error')); + await withMockSubscriptionService(async ({ service, config }) => { + config.fetchMock.mockRejectedValue(new Error('Network error')); await expect( service.unCancelSubscription({ subscriptionId: 'sub_123456789' }), @@ -286,8 +430,10 @@ describe('SubscriptionService', () => { describe('startSubscription', () => { it('should start subscription successfully', async () => { - await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockResolvedValue(MOCK_START_SUBSCRIPTION_RESPONSE); + await withMockSubscriptionService(async ({ service, config }) => { + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: MOCK_START_SUBSCRIPTION_RESPONSE }), + ); const result = await service.startSubscriptionWithCard( MOCK_START_SUBSCRIPTION_REQUEST, @@ -306,7 +452,9 @@ describe('SubscriptionService', () => { recurringInterval: RECURRING_INTERVALS.month, }; - handleFetchMock.mockResolvedValue(MOCK_START_SUBSCRIPTION_RESPONSE); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: MOCK_START_SUBSCRIPTION_RESPONSE }), + ); const result = await service.startSubscriptionWithCard(request); @@ -330,7 +478,7 @@ describe('SubscriptionService', () => { describe('startCryptoSubscription', () => { it('should start crypto subscription successfully', async () => { - await withMockSubscriptionService(async ({ service }) => { + await withMockSubscriptionService(async ({ service, config }) => { const request: StartCryptoSubscriptionRequest = { products: [PRODUCT_TYPES.SHIELD], isTrialRequested: false, @@ -347,7 +495,9 @@ describe('SubscriptionService', () => { status: SUBSCRIPTION_STATUSES.active, }; - handleFetchMock.mockResolvedValue(response); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: response }), + ); const result = await service.startSubscriptionWithCrypto(request); @@ -366,7 +516,9 @@ describe('SubscriptionService', () => { const config = createMockConfig(); const service = new SubscriptionService(config); - handleFetchMock.mockResolvedValue(mockPricingResponse); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: mockPricingResponse }), + ); const result = await service.getPricing(); @@ -382,11 +534,13 @@ describe('SubscriptionService', () => { recurringInterval: RECURRING_INTERVALS.month, }; - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.updatePaymentMethodCard(request); - expect(handleFetchMock).toHaveBeenCalledWith( + expect(config.fetchMock).toHaveBeenCalledWith( SUBSCRIPTION_URL( config.env, 'subscriptions/sub_123456789/payment-method/card', @@ -415,11 +569,13 @@ describe('SubscriptionService', () => { billingCycles: 3, }; - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.updatePaymentMethodCrypto(request); - expect(handleFetchMock).toHaveBeenCalledWith( + expect(config.fetchMock).toHaveBeenCalledWith( SUBSCRIPTION_URL( config.env, 'subscriptions/sub_123456789/payment-method/crypto', @@ -439,10 +595,14 @@ describe('SubscriptionService', () => { describe('getBillingPortalUrl', () => { it('should get billing portal url successfully', async () => { - await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockResolvedValue({ - url: 'https://billing-portal.com', - }); + await withMockSubscriptionService(async ({ service, config }) => { + config.fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + url: 'https://billing-portal.com', + }, + }), + ); const result = await service.getBillingPortalUrl(); @@ -453,9 +613,11 @@ describe('SubscriptionService', () => { describe('getShieldSubscriptionEligibility', () => { it('should get shield subscription eligibility successfully', async () => { - await withMockSubscriptionService(async ({ service }) => { + await withMockSubscriptionService(async ({ service, config }) => { const mockResponse = createMockEligibilityResponse(); - handleFetchMock.mockResolvedValue([mockResponse]); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: [mockResponse] }), + ); const results = await service.getSubscriptionsEligibilities(); @@ -464,14 +626,16 @@ describe('SubscriptionService', () => { }); it('should get shield subscription eligibility with cohort information', async () => { - await withMockSubscriptionService(async ({ service }) => { + await withMockSubscriptionService(async ({ service, config }) => { const mockResponse = createMockEligibilityResponse({ cohorts: MOCK_COHORTS, assignedCohort: 'post_tx', assignedAt: '2024-01-01T00:00:00Z', }); - handleFetchMock.mockResolvedValue([mockResponse]); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: [mockResponse] }), + ); const results = await service.getSubscriptionsEligibilities({ balanceCategory: '1k-9.9k', @@ -482,12 +646,16 @@ describe('SubscriptionService', () => { }); it('should get shield subscription eligibility with default values', async () => { - await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockResolvedValue([ - { - product: PRODUCT_TYPES.SHIELD, - }, - ]); + await withMockSubscriptionService(async ({ service, config }) => { + config.fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: [ + { + product: PRODUCT_TYPES.SHIELD, + }, + ], + }), + ); const results = await service.getSubscriptionsEligibilities(); @@ -504,13 +672,15 @@ describe('SubscriptionService', () => { it('should pass balanceCategory as query parameter when provided', async () => { await withMockSubscriptionService(async ({ service, config }) => { const mockResponse = createMockEligibilityResponse(); - handleFetchMock.mockResolvedValue([mockResponse]); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: [mockResponse] }), + ); await service.getSubscriptionsEligibilities({ balanceCategory: '100-999', }); - expect(handleFetchMock).toHaveBeenCalledWith( + expect(config.fetchMock).toHaveBeenCalledWith( expect.stringContaining('balanceCategory=100-999'), expect.objectContaining({ method: 'GET', @@ -524,12 +694,14 @@ describe('SubscriptionService', () => { it('should not pass balanceCategory query parameter when not provided', async () => { await withMockSubscriptionService(async ({ service, config }) => { const mockResponse = createMockEligibilityResponse(); - handleFetchMock.mockResolvedValue([mockResponse]); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: [mockResponse] }), + ); await service.getSubscriptionsEligibilities(); - expect(handleFetchMock).toHaveBeenCalledWith( - expect.not.stringContaining('balanceCategory'), + expect(config.fetchMock).toHaveBeenCalledWith( + expect.not.stringMatching(/balanceCategory/u), expect.objectContaining({ method: 'GET', headers: MOCK_HEADERS, @@ -543,13 +715,15 @@ describe('SubscriptionService', () => { describe('submitUserEvent', () => { it('should submit user event successfully', async () => { await withMockSubscriptionService(async ({ service, config }) => { - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.submitUserEvent({ event: SubscriptionUserEvent.ShieldEntryModalViewed, }); - expect(handleFetchMock).toHaveBeenCalledWith( + expect(config.fetchMock).toHaveBeenCalledWith( SUBSCRIPTION_URL(config.env, 'user-events'), { method: 'POST', @@ -564,14 +738,16 @@ describe('SubscriptionService', () => { it('should submit user event with cohort successfully', async () => { await withMockSubscriptionService(async ({ service, config }) => { - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.submitUserEvent({ event: SubscriptionUserEvent.ShieldEntryModalViewed, cohort: 'post_tx', }); - expect(handleFetchMock).toHaveBeenCalledWith( + expect(config.fetchMock).toHaveBeenCalledWith( SUBSCRIPTION_URL(config.env, 'user-events'), { method: 'POST', @@ -589,11 +765,13 @@ describe('SubscriptionService', () => { describe('assignUserToCohort', () => { it('should assign user to cohort successfully', async () => { await withMockSubscriptionService(async ({ service, config }) => { - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.assignUserToCohort({ cohort: 'post_tx' }); - expect(handleFetchMock).toHaveBeenCalledWith( + expect(config.fetchMock).toHaveBeenCalledWith( SUBSCRIPTION_URL(config.env, 'cohorts/assign'), { method: 'POST', @@ -608,8 +786,8 @@ describe('SubscriptionService', () => { }); it('should handle cohort assignment errors', async () => { - await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockRejectedValue(new Error('Network error')); + await withMockSubscriptionService(async ({ service, config }) => { + config.fetchMock.mockRejectedValue(new Error('Network error')); await expect( service.assignUserToCohort({ cohort: 'wallet_home' }), @@ -621,7 +799,9 @@ describe('SubscriptionService', () => { describe('submitSponsorshipIntents', () => { it('should submit sponsorship intents successfully', async () => { await withMockSubscriptionService(async ({ service, config }) => { - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.submitSponsorshipIntents({ chainId: '0x1', @@ -632,7 +812,7 @@ describe('SubscriptionService', () => { paymentTokenSymbol: 'USDT', }); - expect(handleFetchMock).toHaveBeenCalledWith( + expect(config.fetchMock).toHaveBeenCalledWith( SUBSCRIPTION_URL(config.env, 'transaction-sponsorship/intents'), { method: 'POST', @@ -654,14 +834,16 @@ describe('SubscriptionService', () => { describe('linkRewards', () => { it('should link rewards successfully', async () => { await withMockSubscriptionService(async ({ service, config }) => { - handleFetchMock.mockResolvedValue({}); + config.fetchMock.mockResolvedValue( + createMockResponse({ jsonData: {} }), + ); await service.linkRewards({ rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890', }); - expect(handleFetchMock).toHaveBeenCalledWith( + expect(config.fetchMock).toHaveBeenCalledWith( SUBSCRIPTION_URL(config.env, 'rewards/link'), { method: 'POST', diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index 59b7806d151..89316abdabe 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -1,8 +1,14 @@ -import { handleFetch } from '@metamask/controller-utils'; - -import { getEnvUrls, SubscriptionControllerErrorMessage } from './constants'; +import { + getEnvUrls, + SubscriptionControllerErrorMessage, + SubscriptionServiceErrorMessage, +} from './constants'; import type { Env } from './constants'; -import { SubscriptionServiceError } from './errors'; +import { + createSentryError, + getErrorFromResponse, + SubscriptionServiceError, +} from './errors'; import type { AssignCohortRequest, AuthUtils, @@ -30,6 +36,8 @@ import type { export type SubscriptionServiceConfig = { env: Env; auth: AuthUtils; + fetchFunction: typeof fetch; + captureException?: (error: Error) => void; }; export const SUBSCRIPTION_URL = (env: Env, path: string): string => @@ -38,24 +46,38 @@ export const SUBSCRIPTION_URL = (env: Env, path: string): string => export class SubscriptionService implements ISubscriptionService { readonly #env: Env; + readonly #fetch: typeof fetch; + + readonly #captureException?: (error: Error) => void; + public authUtils: AuthUtils; constructor(config: SubscriptionServiceConfig) { this.#env = config.env; this.authUtils = config.auth; + this.#fetch = config.fetchFunction; + this.#captureException = config.captureException; } async getSubscriptions(): Promise { const path = 'subscriptions'; - return await this.#makeRequest(path); + return await this.#makeRequest({ + path, + errorMessage: SubscriptionServiceErrorMessage.FailedToGetSubscriptions, + }); } async cancelSubscription( params: CancelSubscriptionRequest, ): Promise { const path = `subscriptions/${params.subscriptionId}/cancel`; - return await this.#makeRequest(path, 'POST', { - cancelAtPeriodEnd: params.cancelAtPeriodEnd, + return await this.#makeRequest({ + path, + method: 'POST', + body: { + cancelAtPeriodEnd: params.cancelAtPeriodEnd, + }, + errorMessage: SubscriptionServiceErrorMessage.FailedToCancelSubscription, }); } @@ -63,7 +85,13 @@ export class SubscriptionService implements ISubscriptionService { subscriptionId: string; }): Promise { const path = `subscriptions/${params.subscriptionId}/uncancel`; - return await this.#makeRequest(path, 'POST', {}); + return await this.#makeRequest({ + path, + method: 'POST', + body: {}, + errorMessage: + SubscriptionServiceErrorMessage.FailedToUncancelSubscription, + }); } async startSubscriptionWithCard( @@ -76,37 +104,57 @@ export class SubscriptionService implements ISubscriptionService { } const path = 'subscriptions/card'; - return await this.#makeRequest(path, 'POST', request); + return await this.#makeRequest({ + path, + method: 'POST', + body: request, + errorMessage: + SubscriptionServiceErrorMessage.FailedToStartSubscriptionWithCard, + }); } async startSubscriptionWithCrypto( request: StartCryptoSubscriptionRequest, ): Promise { const path = 'subscriptions/crypto'; - return await this.#makeRequest(path, 'POST', request); + return await this.#makeRequest({ + path, + method: 'POST', + body: request, + errorMessage: + SubscriptionServiceErrorMessage.FailedToStartSubscriptionWithCrypto, + }); } async updatePaymentMethodCard( request: UpdatePaymentMethodCardRequest, ): Promise { const path = `subscriptions/${request.subscriptionId}/payment-method/card`; - return await this.#makeRequest( + return await this.#makeRequest({ path, - 'PATCH', - { + method: 'PATCH', + body: { ...request, subscriptionId: undefined, }, - ); + errorMessage: + SubscriptionServiceErrorMessage.FailedToUpdatePaymentMethodCard, + }); } async updatePaymentMethodCrypto( request: UpdatePaymentMethodCryptoRequest, ): Promise { const path = `subscriptions/${request.subscriptionId}/payment-method/crypto`; - await this.#makeRequest(path, 'PATCH', { - ...request, - subscriptionId: undefined, + await this.#makeRequest({ + path, + method: 'PATCH', + body: { + ...request, + subscriptionId: undefined, + }, + errorMessage: + SubscriptionServiceErrorMessage.FailedToUpdatePaymentMethodCrypto, }); } @@ -124,12 +172,12 @@ export class SubscriptionService implements ISubscriptionService { if (request?.balanceCategory !== undefined) { query = { balanceCategory: request.balanceCategory }; } - const results = await this.#makeRequest( + const results = await this.#makeRequest({ path, - 'GET', - undefined, - query, - ); + queryParams: query, + errorMessage: + SubscriptionServiceErrorMessage.FailedToGetSubscriptionsEligibilities, + }); return results.map((result) => ({ ...result, @@ -149,7 +197,12 @@ export class SubscriptionService implements ISubscriptionService { */ async submitUserEvent(request: SubmitUserEventRequest): Promise { const path = 'user-events'; - await this.#makeRequest(path, 'POST', request); + await this.#makeRequest({ + path, + method: 'POST', + body: request, + errorMessage: SubscriptionServiceErrorMessage.FailedToSubmitUserEvent, + }); } /** @@ -160,7 +213,12 @@ export class SubscriptionService implements ISubscriptionService { */ async assignUserToCohort(request: AssignCohortRequest): Promise { const path = 'cohorts/assign'; - await this.#makeRequest(path, 'POST', request); + await this.#makeRequest({ + path, + method: 'POST', + body: request, + errorMessage: SubscriptionServiceErrorMessage.FailedToAssignUserToCohort, + }); } /** @@ -176,7 +234,13 @@ export class SubscriptionService implements ISubscriptionService { request: SubmitSponsorshipIntentsRequest, ): Promise { const path = 'transaction-sponsorship/intents'; - await this.#makeRequest(path, 'POST', request); + await this.#makeRequest({ + path, + method: 'POST', + body: request, + errorMessage: + SubscriptionServiceErrorMessage.FailedToSubmitSponsorshipIntents, + }); } /** @@ -190,30 +254,65 @@ export class SubscriptionService implements ISubscriptionService { request: LinkRewardsRequest, ): Promise { const path = 'rewards/link'; - return await this.#makeRequest( + return await this.#makeRequest({ path, - 'POST', - request, - ); + method: 'POST', + body: request, + errorMessage: SubscriptionServiceErrorMessage.FailedToLinkRewards, + }); } - async #makeRequest( - path: string, - method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH' = 'GET', - body?: Record, - queryParams?: Record, - ): Promise { - try { - const headers = await this.#getAuthorizationHeader(); - const url = new URL(SUBSCRIPTION_URL(this.#env, path)); + async getPricing(): Promise { + const path = 'pricing'; + return await this.#makeRequest({ + path, + errorMessage: SubscriptionServiceErrorMessage.FailedToGetPricing, + }); + } + async getBillingPortalUrl(): Promise { + const path = 'billing-portal'; + return await this.#makeRequest({ + path, + errorMessage: SubscriptionServiceErrorMessage.FailedToGetBillingPortalUrl, + }); + } + + /** + * Makes a request to the Subscription Service backend. + * + * @param params - The request object containing the path, method, body, query parameters, and error message. + * @param params.path - The path of the request. + * @param params.method - The method of the request. + * @param params.body - The body of the request. + * @param params.queryParams - The query parameters of the request. + * @param params.errorMessage - The error message to throw if the request fails. + * @returns The result of the request. + */ + async #makeRequest({ + path, + method = 'GET', + body, + queryParams, + errorMessage, + }: { + path: string; + method?: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'; + body?: Record; + queryParams?: Record; + errorMessage: string; + }): Promise { + const url = this.#getSubscriptionApiUrl(path); + const headers = await this.#getAuthorizationHeader(); + + try { if (queryParams) { Object.entries(queryParams).forEach(([key, value]) => { url.searchParams.append(key, value); }); } - const response = await handleFetch(url.toString(), { + const response = await this.#fetch(url.toString(), { method, headers: { 'Content-Type': 'application/json', @@ -222,30 +321,76 @@ export class SubscriptionService implements ISubscriptionService { body: body ? JSON.stringify(body) : undefined, }); - return response; + if (!response.ok) { + const error = await getErrorFromResponse(response); + throw error; + } + + const data = await response.json(); + return data; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : JSON.stringify(error); + console.error(errorMessage, error); + + const errorMessageWithUrl = `${errorMessage} (url: ${url.toString()})`; + const errorToCapture = + error instanceof Error ? error : new Error(errorMessage); + this.#captureException?.( + createSentryError(errorMessageWithUrl, errorToCapture), + ); throw new SubscriptionServiceError( - `failed to make request. ${errorMessage}`, + `Failed to make request. ${errorMessageWithUrl}`, + { + cause: errorToCapture, + }, ); } } // eslint-disable-next-line @typescript-eslint/naming-convention async #getAuthorizationHeader(): Promise<{ Authorization: string }> { - const accessToken = await this.authUtils.getAccessToken(); - return { Authorization: `Bearer ${accessToken}` }; - } + try { + const accessToken = await this.authUtils.getAccessToken(); + return { Authorization: `Bearer ${accessToken}` }; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error when getting authorization header'; - async getPricing(): Promise { - const path = 'pricing'; - return await this.#makeRequest(path); + this.#captureException?.( + createSentryError( + `Failed to get authorization header. ${errorMessage}`, + error instanceof Error ? error : new Error(errorMessage), + ), + ); + + throw new SubscriptionServiceError( + `Failed to get authorization header. ${errorMessage}`, + { + cause: error instanceof Error ? error : new Error(errorMessage), + }, + ); + } } - async getBillingPortalUrl(): Promise { - const path = 'billing-portal'; - return await this.#makeRequest(path); + #getSubscriptionApiUrl(path: string): URL { + try { + const url = new URL(SUBSCRIPTION_URL(this.#env, path)); + return url; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error when getting subscription API URL'; + this.#captureException?.( + createSentryError( + `Failed to get subscription API URL. ${errorMessage}`, + error instanceof Error ? error : new Error(errorMessage), + ), + ); + + throw error; + } } } diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index e3a5a2c1fa4..c7fa0ecc456 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -49,6 +49,23 @@ export enum SubscriptionControllerErrorMessage { LinkRewardsFailed = `${controllerName} - Failed to link rewards`, } +export enum SubscriptionServiceErrorMessage { + FailedToGetSubscriptions = 'Failed to get subscriptions', + FailedToCancelSubscription = 'Failed to cancel subscription', + FailedToUncancelSubscription = 'Failed to uncancel subscription', + FailedToStartSubscriptionWithCard = 'Failed to start subscription with card', + FailedToStartSubscriptionWithCrypto = 'Failed to start subscription with crypto', + FailedToUpdatePaymentMethodCard = 'Failed to update payment method card', + FailedToUpdatePaymentMethodCrypto = 'Failed to update payment method crypto', + FailedToGetSubscriptionsEligibilities = 'Failed to get subscriptions eligibilities', + FailedToSubmitUserEvent = 'Failed to submit user event', + FailedToAssignUserToCohort = 'Failed to assign user to cohort', + FailedToSubmitSponsorshipIntents = 'Failed to submit sponsorship intents', + FailedToLinkRewards = 'Failed to link rewards', + FailedToGetPricing = 'Failed to get pricing', + FailedToGetBillingPortalUrl = 'Failed to get billing portal url', +} + export const DEFAULT_POLLING_INTERVAL = 5 * 60 * 1_000; // 5 minutes export const ACTIVE_SUBSCRIPTION_STATUSES = [ diff --git a/packages/subscription-controller/src/errors.test.ts b/packages/subscription-controller/src/errors.test.ts new file mode 100644 index 00000000000..93c0f63140a --- /dev/null +++ b/packages/subscription-controller/src/errors.test.ts @@ -0,0 +1,151 @@ +import { + createSentryError, + getErrorFromResponse, + SubscriptionServiceError, +} from './errors'; + +type MockResponseOptions = { + status?: number; + contentType?: string | null; + jsonData?: unknown; + textData?: string; + jsonThrows?: boolean; + data?: string; +}; + +function createMockResponse({ + status = 500, + contentType = 'application/json', + jsonData = {}, + textData = 'plain error', + jsonThrows = false, + data, +}: MockResponseOptions): Response { + return { + status, + headers: { + get: (key: string) => + key.toLowerCase() === 'content-type' ? contentType : null, + }, + json: jsonThrows + ? jest.fn().mockRejectedValue(new Error('bad json')) + : jest.fn().mockResolvedValue(jsonData), + text: jest.fn().mockResolvedValue(textData), + data, + } as unknown as Response; +} + +describe('errors', () => { + describe('SubscriptionServiceError', () => { + it('sets name and cause', () => { + const cause = new Error('root cause'); + const error = new SubscriptionServiceError('message', { cause }); + + expect(error.name).toBe('SubscriptionServiceError'); + expect(error.message).toBe('message'); + expect(error.cause).toBe(cause); + }); + }); + + describe('createSentryError', () => { + it('wraps the cause on the error', () => { + const cause = new Error('inner'); + const error = createSentryError('outer', cause); + + expect(error.message).toBe('outer'); + expect((error as Error & { cause: Error }).cause).toBe(cause); + }); + }); + + describe('getErrorFromResponse', () => { + it('uses JSON error message when content-type is json', async () => { + const response = createMockResponse({ + contentType: 'application/json', + status: 400, + jsonData: { error: 'Bad request' }, + }); + + const error = await getErrorFromResponse(response); + + expect(error.message).toContain('error: Bad request'); + expect(error.message).toContain('statusCode: 400'); + }); + + it('uses JSON message when error field is missing', async () => { + const response = createMockResponse({ + contentType: 'application/json', + status: 422, + jsonData: { message: 'Unprocessable' }, + }); + + const error = await getErrorFromResponse(response); + + expect(error.message).toContain('error: Unprocessable'); + expect(error.message).toContain('statusCode: 422'); + }); + + it('uses Unknown error when JSON has no message', async () => { + const response = createMockResponse({ + contentType: 'application/json', + status: 418, + jsonData: { detail: 'teapot' }, + }); + + const error = await getErrorFromResponse(response); + + expect(error.message).toContain('error: Unknown error'); + expect(error.message).toContain('statusCode: 418'); + }); + + it('uses text body when content-type is text/plain', async () => { + const response = createMockResponse({ + contentType: 'text/plain', + status: 503, + textData: 'Service unavailable', + }); + + const error = await getErrorFromResponse(response); + + expect(error.message).toContain('error: Service unavailable'); + expect(error.message).toContain('statusCode: 503'); + }); + + it('uses response data when content-type is missing', async () => { + const response = createMockResponse({ + contentType: null, + status: 500, + data: 'fallback data', + }); + + const error = await getErrorFromResponse(response); + + expect(error.message).toContain('error: fallback data'); + expect(error.message).toContain('statusCode: 500'); + }); + + it('uses Unknown error when response data is not a string', async () => { + const response = createMockResponse({ + contentType: null, + status: 500, + data: { code: 'UNKNOWN' } as unknown as string, + }); + + const error = await getErrorFromResponse(response); + + expect(error.message).toContain('error: Unknown error'); + expect(error.message).toContain('statusCode: 500'); + }); + + it('returns generic HTTP error when parsing fails', async () => { + const response = createMockResponse({ + contentType: 'application/json', + status: 502, + jsonThrows: true, + }); + + const error = await getErrorFromResponse(response); + + expect(error.message).toBe('HTTP 502 error'); + }); + }); +}); diff --git a/packages/subscription-controller/src/errors.ts b/packages/subscription-controller/src/errors.ts index 0e4efbcbbb9..cab13c85d12 100644 --- a/packages/subscription-controller/src/errors.ts +++ b/packages/subscription-controller/src/errors.ts @@ -1,6 +1,65 @@ export class SubscriptionServiceError extends Error { - constructor(message: string) { + /** + * The underlying error that caused this error. + */ + cause?: Error; + + constructor( + message: string, + options?: { + cause?: Error; + }, + ) { super(message); this.name = 'SubscriptionServiceError'; + this.cause = options?.cause; } } + +/** + * Get an error from a response. + * + * @param response - The response to get an error from. + * @returns An error. + */ +export async function getErrorFromResponse(response: Response): Promise { + const contentType = response.headers?.get('content-type'); + const statusCode = response.status; + try { + if (contentType?.includes('application/json')) { + const json = await response.json(); + const errorMessage = json?.error ?? json?.message ?? 'Unknown error'; + const networkError = `error: ${errorMessage}, statusCode: ${statusCode}`; + return new Error(networkError); + } else if (contentType?.includes('text/plain')) { + const text = await response.text(); + const networkError = `error: ${text}, statusCode: ${statusCode}`; + return new Error(networkError); + } + + const error = + 'data' in response && typeof response.data === 'string' + ? response.data + : 'Unknown error'; + const networkError = `error: ${error}, statusCode: ${statusCode}`; + return new Error(networkError); + } catch { + return new Error(`HTTP ${statusCode} error`); + } +} + +/** + * Creates an error instance with a readable message and the root cause. + * + * @param message - The error message to create a Sentry error from. + * @param cause - The inner error to create a Sentry error from. + * @returns A Sentry error. + */ +export function createSentryError(message: string, cause: Error): Error { + const sentryError = new Error(message) as Error & { + cause: Error; + }; + sentryError.cause = cause; + + return sentryError; +} diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index f219651b544..01e76893efc 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -86,6 +86,10 @@ export { MODAL_TYPE, } from './types'; export { SubscriptionServiceError } from './errors'; -export { Env, SubscriptionControllerErrorMessage } from './constants'; +export { + Env, + SubscriptionControllerErrorMessage, + SubscriptionServiceErrorMessage, +} from './constants'; export type { SubscriptionServiceConfig } from './SubscriptionService'; export { SubscriptionService } from './SubscriptionService';