Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions libs/payments/eligibility/src/lib/eligibility.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,37 @@ import {
import { LocationConfig, MockLocationConfigProvider } from './location.config';
import {
AppleIapClient,
AppleIapClientBundleError,
AppleIapPurchaseManager,
AppleIapServiceUnavailableError,
GoogleIapClient,
GoogleIapPurchaseManager,
MockAppleIapClientConfigProvider,
MockGoogleIapClientConfigProvider,
} from '@fxa/payments/iap';
import { AppStoreError } from 'app-store-server-api';
import { faker } from '@faker-js/faker';
import { Logger } from '@nestjs/common';

// Apple reports a transient internal failure as errorCode 5000001, which
// app-store-server-api flags as retryable.
const APPLE_RETRYABLE_ERROR = new AppleIapServiceUnavailableError(
new AppStoreError(5000001, 'An unknown error occurred. Please try again.')
);
// A 401 from the App Store Server API surfaces as a plain Error, meaning our
// JWT is invalid — never that the customer has no Apple subscriptions.
const APPLE_INVALID_JWT_ERROR = new AppleIapClientBundleError(
new Error('The request is unauthorized; the JSON Web Token (JWT) is invalid.')
);

describe('EligibilityService', () => {
let productConfigurationManager: ProductConfigurationManager;
let eligibilityManager: EligibilityManager;
let eligibilityService: EligibilityService;
let subscriptionManager: SubscriptionManager;
let appleIapPurchaseManager: AppleIapPurchaseManager;
let googleIapPurchaseManager: GoogleIapPurchaseManager;
let logger: Logger;

beforeEach(async () => {
const module = await Test.createTestingModule({
Expand Down Expand Up @@ -97,6 +112,7 @@ describe('EligibilityService', () => {
subscriptionManager = module.get(SubscriptionManager);
appleIapPurchaseManager = module.get(AppleIapPurchaseManager);
googleIapPurchaseManager = module.get(GoogleIapPurchaseManager);
logger = module.get(Logger);
});

describe('checkEligibility', () => {
Expand Down Expand Up @@ -310,6 +326,109 @@ describe('EligibilityService', () => {
});
});

it('returns BLOCKED_IAP from the cached Apple purchases when the App Store Server API is unavailable', async () => {
const mockCustomer = StripeCustomerFactory();
const interval = SubplatInterval.Monthly;
const offeringApiIdentifier = faker.string.uuid();

jest
.spyOn(appleIapPurchaseManager, 'getForUser')
.mockRejectedValue(APPLE_RETRYABLE_ERROR);
jest
.spyOn(appleIapPurchaseManager, 'getStaleCachedForUser')
.mockResolvedValue([{ productId: 'apple_product_1' } as any]);
jest.spyOn(googleIapPurchaseManager, 'getForUser').mockResolvedValue([]);
const warn = jest.spyOn(logger, 'warn').mockImplementation();
jest
.spyOn(productConfigurationManager, 'getEligibilityContentByOffering')
.mockResolvedValue(
new EligibilityContentByOfferingResultUtil(
EligibilityContentByOfferingResultFactory({
offerings: [
EligibilityContentOfferingResultFactory({
apiIdentifier: offeringApiIdentifier,
}),
],
})
)
);
jest
.spyOn(productConfigurationManager, 'getIapOfferings')
.mockResolvedValue(
new IapOfferingsByStoreIDsResultUtil(
IapOfferingByStoreIDResultFactory({
iaps: [
IapWithOfferingResultFactory({
offering: IapOfferingResultFactory({
apiIdentifier: offeringApiIdentifier,
subGroups: [
IapOfferingSubGroupResultFactory({
offerings: [
IapOfferingSubGroupOfferingResultFactory({
apiIdentifier: offeringApiIdentifier,
}),
],
}),
],
}),
}),
],
})
)
);
const uid = faker.string.uuid();

const result = await eligibilityService.checkEligibility(
interval,
offeringApiIdentifier,
uid,
mockCustomer.id
);

expect(result).toEqual({
subscriptionEligibilityResult: EligibilityStatus.BLOCKED_IAP,
});
expect(appleIapPurchaseManager.getStaleCachedForUser).toHaveBeenCalledWith(uid);
expect(warn).toHaveBeenCalledWith('checkEligibility.appleIapUnavailable', {
uid,
});
});

it('rethrows Apple IAP errors Apple did not ask us to retry', async () => {
const mockCustomer = StripeCustomerFactory();
const interval = SubplatInterval.Monthly;
const offeringApiIdentifier = faker.string.uuid();

jest
.spyOn(appleIapPurchaseManager, 'getForUser')
.mockRejectedValue(APPLE_INVALID_JWT_ERROR);
jest.spyOn(appleIapPurchaseManager, 'getStaleCachedForUser');
jest.spyOn(googleIapPurchaseManager, 'getForUser').mockResolvedValue([]);
jest
.spyOn(productConfigurationManager, 'getEligibilityContentByOffering')
.mockResolvedValue(
new EligibilityContentByOfferingResultUtil(
EligibilityContentByOfferingResultFactory({
offerings: [
EligibilityContentOfferingResultFactory({
apiIdentifier: offeringApiIdentifier,
}),
],
})
)
);

await expect(
eligibilityService.checkEligibility(
interval,
offeringApiIdentifier,
faker.string.uuid(),
mockCustomer.id
)
).rejects.toBe(APPLE_INVALID_JWT_ERROR);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: rejects.toThrow(...)?

expect(appleIapPurchaseManager.getStaleCachedForUser).not.toHaveBeenCalled();
});

it('returns CREATE when Google IAP subscription is for a different product', async () => {
const interval = SubplatInterval.Monthly;
const offeringApiIdentifier = faker.string.uuid();
Expand Down
21 changes: 18 additions & 3 deletions libs/payments/eligibility/src/lib/eligibility.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { Injectable } from '@nestjs/common';
import {
Inject,
Injectable,
Logger,
type LoggerService,
} from '@nestjs/common';
import { SubscriptionManager, SubplatInterval } from '@fxa/payments/customer';
import { ProductConfigurationManager } from '@fxa/shared/cms';
import {
GoogleIapPurchaseManager,
AppleIapPurchaseManager,
AppleIapServiceUnavailableError,
} from '@fxa/payments/iap';
import { EligibilityManager } from './eligibility.manager';
import {
Expand All @@ -26,7 +32,8 @@ export class EligibilityService {
private eligibilityManager: EligibilityManager,
private subscriptionManager: SubscriptionManager,
private googleIapPurchaseManager: GoogleIapPurchaseManager,
private appleIapPurchaseManager: AppleIapPurchaseManager
private appleIapPurchaseManager: AppleIapPurchaseManager,
@Inject(Logger) private log: LoggerService
) {}

/**
Expand Down Expand Up @@ -56,7 +63,15 @@ export class EligibilityService {
const targetOffering = targetOfferingResult.getOffering();

const [appleIapPurchases, googleIapPurchases] = await Promise.all([
this.appleIapPurchaseManager.getForUser(uid),
this.appleIapPurchaseManager.getForUser(uid).catch((error) => {
if (error instanceof AppleIapServiceUnavailableError) {
// Unconfirmed must not read as absent, or an overlapping purchase
// slips through — including a stale cache that reads inactive.
this.log.warn('checkEligibility.appleIapUnavailable', { uid });
return this.appleIapPurchaseManager.getStaleCachedForUser(uid);
}
throw error;
}),
this.googleIapPurchaseManager.getForUser(uid),
]);
if (appleIapPurchases.length || googleIapPurchases.length) {
Expand Down
1 change: 1 addition & 0 deletions libs/payments/iap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './lib/google/subscription-purchase';
export * from './lib/apple/apple-iap.client';
export * from './lib/google/google-iap.client.config';
export * from './lib/apple/apple-iap-purchase.manager';
export * from './lib/apple/apple-iap.error';
export * from './lib/apple/subscription-purchase';
export * from './lib/google/google-iap.client';
export * from './lib/apple/apple-iap.client.config';
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ import { MockAppleIapClientConfigProvider } from './apple-iap.client.config';
import * as repository from './apple-iap-purchase.repository';
import { AppStoreSubscriptionPurchase } from './subscription-purchase';
import {
AppStoreError,
decodeRenewalInfo,
decodeTransaction,
SubscriptionStatus,
type StatusResponse,
} from 'app-store-server-api';
import { FirestoreAppleIapPurchaseRecordFactory } from '../factories';
import { AppleIapNotFoundError } from './apple-iap.error';
import {
AppleIapNotFoundError,
AppleIapServiceUnavailableError,
} from './apple-iap.error';

jest.mock('./apple-iap-purchase.repository', () => ({
getActivePurchasesForUserId: jest.fn(),
Expand All @@ -33,6 +37,7 @@ jest.mock('app-store-server-api', () => {
return {
Environment: actual.Environment,
SubscriptionStatus: actual.SubscriptionStatus,
AppStoreError: actual.AppStoreError,
decodeTransaction: jest.fn(),
decodeRenewalInfo: jest.fn(),
AppStoreServerAPI: jest.fn().mockImplementation(() => ({})),
Expand Down Expand Up @@ -214,6 +219,33 @@ describe('AppleIapPurchaseManager', () => {
await expect(manager.getForUser(userId)).resolves.not.toThrow();
});

// Callers decide how to degrade — the read paths skip the purchases, while
// EligibilityService falls back to the cached records — so this has to keep
// propagating rather than being swallowed here.
it('propagates AppleIapServiceUnavailableError', async () => {
const userId = faker.string.uuid();

jest.spyOn(repository, 'getActivePurchasesForUserId').mockResolvedValue([
FirestoreAppleIapPurchaseRecordFactory({
status: SubscriptionStatus.Expired,
}),
]);
jest
.spyOn(appleIapClient, 'getSubscriptionStatuses')
.mockRejectedValue(
new AppleIapServiceUnavailableError(
new AppStoreError(
5000001,
'An unknown error occurred. Please try again.'
)
)
);

await expect(manager.getForUser(userId)).rejects.toThrow(
AppleIapServiceUnavailableError
);
});

it('throws for unknown errors', async () => {
const userId = faker.string.uuid();

Expand Down Expand Up @@ -252,6 +284,44 @@ describe('AppleIapPurchaseManager', () => {
});
});

describe('getStaleCachedForUser', () => {
it('returns the cached purchases without querying Apple', async () => {
const userId = faker.string.uuid();
const originalTransactionId = faker.string.uuid();

jest.spyOn(repository, 'getActivePurchasesForUserId').mockResolvedValue([
FirestoreAppleIapPurchaseRecordFactory({
status: SubscriptionStatus.Expired,
originalTransactionId,
}),
]);
jest.spyOn(appleIapClient, 'getSubscriptionStatuses');

const result = await manager.getStaleCachedForUser(userId);

expect(repository.getActivePurchasesForUserId).toHaveBeenCalledWith(
expect.anything(),
userId
);
expect(appleIapClient.getSubscriptionStatuses).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(AppStoreSubscriptionPurchase);
expect(result[0].originalTransactionId).toBe(originalTransactionId);
});

it('returns an empty list when the user has no cached purchases', async () => {
const userId = faker.string.uuid();

jest
.spyOn(repository, 'getActivePurchasesForUserId')
.mockResolvedValue([]);

const result = await manager.getStaleCachedForUser(userId);

expect(result).toEqual([]);
});
});

describe('getStaleCached', () => {
it('returns converted AppStoreSubscriptionPurchase', async () => {
const record = { id: faker.string.uuid() };
Expand Down
21 changes: 21 additions & 0 deletions libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,27 @@ export class AppleIapPurchaseManager {
});
}

/**
* Return the user's purchases as they are cached in Firestore, without
* refreshing them against the App Store Server API.
*
* Intended for callers that still have to make a decision when Apple is
* unreachable. Since the records can be stale, an inactive entitlement here
* is not proof that the subscription has lapsed.
*/
async getStaleCachedForUser(
userId: string
): Promise<AppStoreSubscriptionPurchase[]> {
const firestorePurchaseRecords = await getActivePurchasesForUserId(
this.collectionRef,
userId
);

return firestorePurchaseRecords.map((firestorePurchaseRecord) =>
AppStoreSubscriptionPurchase.fromFirestoreObject(firestorePurchaseRecord)
);
}

async getStaleCached(
originalTransactionId: string
): Promise<AppStoreSubscriptionPurchase | undefined> {
Expand Down
Loading