chore(libs): Create getClients method in CapabilityManager

This commit is contained in:
Lisa Chan 2023-10-02 10:33:30 -04:00
Родитель c5ffcb0b99
Коммит 3403f3350a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 9052E177BBC5E764
20 изменённых файлов: 597 добавлений и 141 удалений

Просмотреть файл

@ -0,0 +1,77 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { Test, TestingModule } from '@nestjs/testing';
import {
ContentfulManager,
ServiceResultFactory,
ServicesWithCapabilitiesResultUtil,
} from '@fxa/shared/contentful';
import { CapabilityManager } from './capability.manager';
describe('CapabilityManager', () => {
let manager: CapabilityManager;
let mockContentfulManager: ContentfulManager;
let mockResult: ServicesWithCapabilitiesResultUtil;
beforeEach(async () => {
mockResult = {} as any;
mockContentfulManager = {
getServicesWithCapabilities: jest.fn().mockResolvedValueOnce(mockResult),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
{ provide: ContentfulManager, useValue: mockContentfulManager },
CapabilityManager,
],
}).compile();
manager = module.get<CapabilityManager>(CapabilityManager);
});
it('should be defined', async () => {
expect(manager).toBeDefined();
expect(manager).toBeInstanceOf(CapabilityManager);
});
describe('getClients', () => {
it('should return empty results', async () => {
mockResult.getServices = jest.fn().mockReturnValueOnce(undefined);
const result = await manager.getClients();
expect(result.length).toBe(0);
});
it('should return services with capabilities', async () => {
const clientResults = ServiceResultFactory({
oauthClientId: 'client1',
capabilitiesCollection: {
items: [
{ slug: 'exampleCap0' },
{ slug: 'exampleCap2' },
{ slug: 'exampleCap4' },
{ slug: 'exampleCap5' },
{ slug: 'exampleCap6' },
{ slug: 'exampleCap8' },
],
},
});
mockResult.getServices = jest.fn().mockReturnValue(clientResults);
const result = await manager.getClients();
expect(result.length).toBe(1);
expect(result[0].clientId).toBe('client1');
const actualCapabilities = [
'exampleCap0',
'exampleCap2',
'exampleCap4',
'exampleCap5',
'exampleCap6',
'exampleCap8',
];
expect(result[0].capabilities).toHaveLength(6);
expect(result[0].capabilities).toStrictEqual(actualCapabilities);
});
});
});

Просмотреть файл

@ -2,9 +2,29 @@
* 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 {
CapabilitiesResult,
ContentfulManager,
ServiceResult,
} from '@fxa/shared/contentful';
import { Injectable } from '@nestjs/common';
@Injectable()
export class CapabilityManager {
constructor() {}
constructor(private contentfulManager: ContentfulManager) {}
async getClients() {
const clients: ServiceResult[] = (
await this.contentfulManager.getServicesWithCapabilities()
).getServices();
if (!clients) return [];
return clients.map((client: ServiceResult) => ({
clientId: client.oauthClientId,
capabilities: client.capabilitiesCollection.items.map(
(capability: CapabilitiesResult) => capability.slug
),
}));
}
}

Просмотреть файл

@ -8,3 +8,4 @@ export * from './lib/contentful.error';
export * from './lib/contentful.manager';
export * from './lib/factories';
export * from './lib/queries/eligibility-content-by-plan-ids';
export * from './lib/queries/services-with-capabilities';

Просмотреть файл

@ -1,9 +0,0 @@
export const mockQuery = jest
.fn()
.mockResolvedValue({ data: { purchaseCollection: { items: [] } } });
const mock = jest.fn().mockImplementation(() => {
return {
query: mockQuery,
};
});
export const ContentfulClient = mock;

Просмотреть файл

@ -5,23 +5,20 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ContentfulClient } from './contentful.client';
import { ContentfulManager } from './contentful.manager';
import { EligibilityContentByPlanIdsQueryFactory } from './factories';
jest.mock('./contentful.client');
import {
EligibilityContentByPlanIdsQueryFactory,
ServicesWithCapabilitiesQueryFactory,
} from './factories';
import {
EligibilityContentByPlanIdsResultUtil,
ServicesWithCapabilitiesResultUtil,
} from '../../src';
describe('ContentfulManager', () => {
let manager: ContentfulManager;
let mockClient: ContentfulClient;
beforeEach(async () => {
(ContentfulClient as jest.Mock).mockClear();
mockClient = new ContentfulClient({
cdnApiUri: 'test',
graphqlApiKey: 'test',
graphqlApiUri: 'test',
graphqlEnvironment: 'test',
graphqlSpaceId: 'test',
});
mockClient = {} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
{ provide: ContentfulClient, useValue: mockClient },
@ -38,8 +35,13 @@ describe('ContentfulManager', () => {
describe('getPurchaseDetailsForEligibility', () => {
it('should return empty result', async () => {
mockClient.query = jest.fn().mockReturnValue({
data: {
purchaseCollection: { items: [] },
},
});
const result = await manager.getPurchaseDetailsForEligibility(['test']);
expect(result).toBeDefined();
expect(result).toBeInstanceOf(EligibilityContentByPlanIdsResultUtil);
expect(result.purchaseCollection.items).toHaveLength(0);
});
@ -48,11 +50,32 @@ describe('ContentfulManager', () => {
mockClient.query = jest.fn().mockResolvedValueOnce({ data: queryData });
const result = await manager.getPurchaseDetailsForEligibility(['test']);
const planId = result.purchaseCollection.items[0].stripePlanChoices[0];
expect(result).toBeDefined();
expect(result).toBeInstanceOf(EligibilityContentByPlanIdsResultUtil);
expect(
result.offeringForPlanId(planId)?.linkedFrom.subGroupCollection.items
).toHaveLength(1);
expect(result.offeringForPlanId(planId)).toBeDefined();
});
});
describe('getServicesWithCapabilities', () => {
it('should return results', async () => {
mockClient.query = jest.fn().mockReturnValue({
data: {
serviceCollection: { items: [] },
},
});
const result = await manager.getServicesWithCapabilities();
expect(result).toBeInstanceOf(ServicesWithCapabilitiesResultUtil);
expect(result.serviceCollection.items).toHaveLength(0);
});
it('should return successfully with services and capabilities', async () => {
const queryData = ServicesWithCapabilitiesQueryFactory();
mockClient.query = jest.fn().mockResolvedValueOnce({ data: queryData });
const result = await manager.getServicesWithCapabilities();
expect(result).toBeInstanceOf(ServicesWithCapabilitiesResultUtil);
expect(result.serviceCollection.items).toHaveLength(1);
});
});
});

Просмотреть файл

@ -4,12 +4,19 @@
import { Injectable } from '@nestjs/common';
import { EligibilityContentByPlanIdsQuery } from '../__generated__/graphql';
import {
EligibilityContentByPlanIdsQuery,
ServicesWithCapabilitiesQuery,
} from '../__generated__/graphql';
import { ContentfulClient } from './contentful.client';
import {
eligibilityContentByPlanIdsQuery,
EligibilityContentByPlanIdsResultUtil,
} from './queries/eligibility-content-by-plan-ids';
import {
servicesWithCapabilitiesQuery,
ServicesWithCapabilitiesResultUtil,
} from './queries/services-with-capabilities';
import { DeepNonNullable } from './types';
@Injectable()
@ -24,7 +31,7 @@ export class ContentfulManager {
{
skip: 0,
limit: 100,
locale: 'en-US',
locale: 'en',
stripePlanIds,
}
);
@ -33,4 +40,16 @@ export class ContentfulManager {
queryResult.data as DeepNonNullable<EligibilityContentByPlanIdsQuery>
);
}
async getServicesWithCapabilities(): Promise<ServicesWithCapabilitiesResultUtil> {
const queryResult = await this.client.query(servicesWithCapabilitiesQuery, {
skip: 0,
limit: 100,
locale: 'en',
});
return new ServicesWithCapabilitiesResultUtil(
queryResult.data as DeepNonNullable<ServicesWithCapabilitiesQuery>
);
}
}

Просмотреть файл

@ -10,12 +10,17 @@ import {
EligibilityContentByPlanIdsQuery,
OfferingQuery,
PurchaseWithDetailsQuery,
ServicesWithCapabilitiesQuery,
} from '../__generated__/graphql';
import {
EligibilityOfferingResult,
EligibilitySubgroupOfferingResult,
EligibilitySubgroupResult,
} from './queries/eligibility-content-by-plan-ids';
import {
CapabilitiesResult,
ServiceResult,
} from './queries/services-with-capabilities';
import { ContentfulErrorResponse } from './types';
export const EligibilityContentByPlanIdsQueryFactory = (
@ -136,6 +141,39 @@ export const PurchaseWithDetailsQueryFactory = (
...override,
});
export const ServicesWithCapabilitiesQueryFactory = (
override?: Partial<ServicesWithCapabilitiesQuery>
): ServicesWithCapabilitiesQuery => ({
serviceCollection: {
items: [
{
oauthClientId: faker.string.sample(),
capabilitiesCollection: {
items: [
{
slug: faker.string.sample(),
},
],
},
},
],
},
...override,
});
export const ServiceResultFactory = (
override?: Partial<ServiceResult>,
capabilitiesCollection?: CapabilitiesResult[]
): ServiceResult[] => [
{
oauthClientId: faker.string.sample(),
capabilitiesCollection: {
items: [...(capabilitiesCollection ?? [])],
},
...override,
},
];
/**
* Generates a graphql response from the contentful client based on the passed query.
* Use one of the query factories to provide data for the factory result.

Просмотреть файл

@ -0,0 +1,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */
export * from './query';
export * from './types';
export * from './util';

Просмотреть файл

@ -2,7 +2,7 @@
* 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 { graphql } from '../../__generated__/gql';
import { graphql } from '../../../__generated__/gql';
export const servicesWithCapabilitiesQuery = graphql(`
query ServicesWithCapabilities($skip: Int!, $limit: Int!, $locale: String!) {

Просмотреть файл

@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */
export interface CapabilitiesResult {
slug: string;
}
export interface ServiceResult {
oauthClientId: string;
capabilitiesCollection: {
items: CapabilitiesResult[];
};
}
export interface ServicesWithCapabilitiesResult {
serviceCollection: {
items: ServiceResult[];
};
}

Просмотреть файл

@ -0,0 +1,33 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { ServicesWithCapabilitiesQueryFactory } from '../../factories';
import { ServicesWithCapabilitiesResult } from './types';
import { ServicesWithCapabilitiesResultUtil } from './util';
describe('ServicesWithCapabilitiesResultUtil', () => {
it('should create a util from response', () => {
const result = ServicesWithCapabilitiesQueryFactory();
const util = new ServicesWithCapabilitiesResultUtil(
result as ServicesWithCapabilitiesResult
);
expect(util).toBeDefined();
expect(util.serviceCollection.items.length).toBe(1);
});
it('getServices - should return services and capabilities', () => {
const result = ServicesWithCapabilitiesQueryFactory();
const util = new ServicesWithCapabilitiesResultUtil(
result as ServicesWithCapabilitiesResult
);
expect(util.getServices()[0].oauthClientId).toBeDefined();
expect(util.getServices()[0].oauthClientId).toEqual(
result.serviceCollection?.items[0]?.oauthClientId
);
expect(util.getServices()[0].capabilitiesCollection).toBeDefined();
expect(util.getServices()[0].capabilitiesCollection.items[0].slug).toEqual(
result.serviceCollection?.items[0]?.capabilitiesCollection?.items[0]?.slug
);
});
});

Просмотреть файл

@ -0,0 +1,16 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 { ServiceResult, ServicesWithCapabilitiesResult } from './types';
export class ServicesWithCapabilitiesResultUtil {
constructor(private rawResult: ServicesWithCapabilitiesResult) {}
getServices(): ServiceResult[] {
return this.serviceCollection.items;
}
get serviceCollection() {
return this.rawResult.serviceCollection;
}
}

Просмотреть файл

@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["jest", "node"]
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]

Просмотреть файл

@ -9,7 +9,6 @@
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts",
"src/**/__mocks__/*.ts"
"src/**/*.d.ts"
]
}

Просмотреть файл

@ -6,6 +6,12 @@
const { config } = require('../config');
const { CapabilityManager } = require('@fxa/payments/capability');
const {
ContentfulClient,
ContentfulManager,
} = require('@fxa/shared/contentful');
// Must be required and initialized right away
const TracingProvider = require('fxa-shared/tracing/node-tracing');
TracingProvider.init(
@ -103,6 +109,26 @@ async function run(config) {
/** @type {undefined | import('../lib/payments/stripe').StripeHelper} */
let stripeHelper = undefined;
if (config.subscriptions && config.subscriptions.stripeApiKey) {
if (
config.contentful &&
config.contentful.cdnApiUri &&
config.contentful.graphqlUrl &&
config.contentful.apiKey &&
config.contentful.spaceId &&
config.contentful.environment
) {
const contentfulClient = new ContentfulClient({
cdnApiUri: config.contentful.cdnApiUri,
graphqlApiUri: config.contentful.graphqlUrl,
graphqlApiKey: config.contentful.apiKey,
graphqlSpaceId: config.contentful.spaceId,
graphqlEnvironment: config.contentful.environment,
});
const contentfulManager = new ContentfulManager(contentfulClient);
const capabilityManager = new CapabilityManager(contentfulManager);
Container.set(CapabilityManager, capabilityManager);
}
const { createStripeHelper } = require('../lib/payments/stripe');
stripeHelper = createStripeHelper(log, config, statsd);
Container.set(StripeHelper, stripeHelper);

Просмотреть файл

@ -16,7 +16,6 @@ import {
import Stripe from 'stripe';
import Container from 'typedi';
import { commaSeparatedListToArray } from 'fxa-shared/lib/utils';
import error from '../error';
import { AppleIAP } from './iap/apple-app-store/apple-iap';
import { authEvents } from '../events';
@ -27,6 +26,7 @@ import { PlayStoreSubscriptionPurchase } from './iap/google-play/subscription-pu
import { PurchaseQueryError } from './iap/google-play/types';
import { StripeHelper } from './stripe';
import { PaymentConfigManager } from './configuration/manager';
import { clientIdCapabilityMapFromMetadata } from './utils';
import { ALL_RPS_CAPABILITIES_KEY } from 'fxa-shared/subscriptions/configuration/base';
import { productUpgradeFromProductConfig } from 'fxa-shared/subscriptions/configuration/utils';
@ -594,24 +594,3 @@ export class CapabilityService {
return result;
}
}
function clientIdFromMetadataKey(key: string): string {
return key === 'capabilities'
? ALL_RPS_CAPABILITIES_KEY
: key.split(':')[1].trim();
}
function capabilitiesFromMetadataValue(value: string): string[] {
return commaSeparatedListToArray(value);
}
function clientIdCapabilityMapFromMetadata(
metadata?: Record<string, string>
): ClientIdCapabilityMap {
return Object.entries(metadata || {})
.filter(([key]) => key.startsWith('capabilities'))
.reduce((acc, [key, value]) => {
acc[clientIdFromMetadataKey(key)] = capabilitiesFromMetadataValue(value);
return acc;
}, {} as ClientIdCapabilityMap);
}

Просмотреть файл

@ -2,6 +2,34 @@
* 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 { createHash } from 'crypto';
import { commaSeparatedListToArray } from 'fxa-shared/lib/utils';
import { ALL_RPS_CAPABILITIES_KEY } from 'fxa-shared/subscriptions/configuration/base';
import {
ClientIdCapabilityMap,
ProductMetadata,
} from 'fxa-shared/subscriptions/types';
export function clientIdFromMetadataKey(key: string): string {
return key === 'capabilities'
? ALL_RPS_CAPABILITIES_KEY
: key.split(':')[1].trim();
}
export function capabilitiesFromMetadataValue(value: string): string[] {
return commaSeparatedListToArray(value);
}
export function clientIdCapabilityMapFromMetadata(
metadata?: Record<string, string> | ProductMetadata,
filterBy?: string
): ClientIdCapabilityMap {
return Object.entries(metadata || {})
.filter(([key]) => key.startsWith(filterBy || 'capabilities'))
.reduce((acc, [key, value]) => {
acc[clientIdFromMetadataKey(key)] = capabilitiesFromMetadataValue(value);
return acc;
}, {} as ClientIdCapabilityMap);
}
export function generateIdempotencyKey(params: string[]) {
const sha = createHash('sha256');

Просмотреть файл

@ -1,6 +1,9 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 _ from 'lodash';
import { CapabilityManager } from '@fxa/payments/capability';
import { ServerRoute } from '@hapi/hapi';
import isA from 'joi';
import * as Sentry from '@sentry/node';
@ -8,6 +11,7 @@ import { SeverityLevel } from '@sentry/types';
import { getAccountCustomerByUid } from 'fxa-shared/db/models/auth';
import {
AbbrevPlan,
ClientIdCapabilityMap,
SubscriptionEligibilityResult,
SubscriptionUpdateEligibility,
} from 'fxa-shared/subscriptions/types';
@ -39,7 +43,10 @@ import { AuthLogger, AuthRequest, TaxAddress } from '../../types';
import { sendFinishSetupEmailForStubAccount } from '../subscriptions/account';
import validators from '../validators';
import { handleAuth } from './utils';
import { generateIdempotencyKey } from '../../payments/utils';
import {
clientIdCapabilityMapFromMetadata,
generateIdempotencyKey,
} from '../../payments/utils';
import { COUNTRIES_LONG_NAME_TO_SHORT_NAME_MAP } from '../../payments/stripe';
import { deleteAccountIfUnverified } from '../utils/account';
import SUBSCRIPTIONS_DOCS from '../../../docs/swagger/subscriptions-api';
@ -80,6 +87,7 @@ export function sanitizePlans(plans: AbbrevPlan[]) {
export class StripeHandler {
subscriptionAccountReminders: any;
capabilityManager?: CapabilityManager;
capabilityService: CapabilityService;
constructor(
@ -96,7 +104,9 @@ export class StripeHandler {
) {
this.subscriptionAccountReminders =
require('../../subscription-account-reminders')(log, config);
if (Container.has(CapabilityManager)) {
this.capabilityManager = Container.get(CapabilityManager);
}
this.capabilityService = Container.get(CapabilityService);
}
@ -141,15 +151,12 @@ export class StripeHandler {
/**
* Retrieve the client capabilities
*/
async getClients(request: AuthRequest) {
this.log.begin('subscriptions.getClients', request);
const capabilitiesByClientId: { [clientId: string]: string[] } = {};
async getClientsFromStripe() {
let result: ClientIdCapabilityMap = {};
const plans = await this.stripeHelper.allAbbrevPlans();
const planConfigs = await this.stripeHelper.allMergedPlanConfigs();
const capabilitiesForAll: string[] = [];
for (const plan of plans) {
for (const plan of await this.stripeHelper.allAbbrevPlans()) {
const metadata = metadataFromPlan(plan);
const pConfig = planConfigs?.[plan.plan_id] || {};
@ -158,38 +165,75 @@ export class StripeHandler {
...(pConfig.capabilities?.[ALL_RPS_CAPABILITIES_KEY] || [])
);
const capabilityKeys = Object.keys(metadata).filter((key) =>
key.startsWith('capabilities:')
result = ClientIdCapabilityMap.merge(
result,
clientIdCapabilityMapFromMetadata(metadata || {}, 'capabilities:')
);
for (const key of capabilityKeys) {
const clientId = key.split(':')[1];
const capabilities = commaSeparatedListToArray((metadata as any)[key]);
capabilitiesByClientId[clientId] = (
capabilitiesByClientId[clientId] || []
).concat(capabilities);
}
if (pConfig.capabilities) {
Object.keys(pConfig.capabilities)
.filter((x) => x !== ALL_RPS_CAPABILITIES_KEY)
.forEach(
(clientId) =>
(capabilitiesByClientId[clientId] = (
capabilitiesByClientId[clientId] || []
).concat(pConfig.capabilities?.[clientId]))
(result[clientId] = (result[clientId] || []).concat(
pConfig.capabilities?.[clientId]
))
);
}
}
return Object.entries(capabilitiesByClientId).map(
([clientId, capabilities]) => {
// Merge dupes with Set
const capabilitySet = new Set([...capabilitiesForAll, ...capabilities]);
return {
clientId,
capabilities: [...capabilitySet],
};
return Object.entries(result).map(([clientId, capabilities]) => {
// Merge dupes with Set
const capabilitySet = new Set([...capabilitiesForAll, ...capabilities]);
return {
clientId,
capabilities: [...capabilitySet],
};
});
}
async getClients(request: AuthRequest) {
this.log.begin('subscriptions.getClients', request);
const clientsFromStripe = await this.getClientsFromStripe();
if (!this.capabilityManager) {
Sentry.withScope((scope) => {
scope.setContext('getClients', {
msg: `CapabilityManager not found.`,
});
Sentry.captureMessage(
`CapabilityManager not found.`,
'error' as SeverityLevel
);
});
return clientsFromStripe;
}
try {
const clientsFromContentful = await this.capabilityManager.getClients();
if (_.isEqual(clientsFromContentful, clientsFromStripe)) {
return clientsFromContentful;
}
);
Sentry.withScope((scope) => {
scope.setContext('getClients', {
contentful: clientsFromContentful,
stripe: clientsFromStripe,
});
Sentry.captureMessage(
`Returned Stripe as clients did not match.`,
'error' as SeverityLevel
);
});
return clientsFromStripe;
} catch (error) {
this.log.error('subscriptions.getClients', { error: error });
throw error;
}
}
async deleteSubscription(request: AuthRequest) {

Просмотреть файл

@ -38,6 +38,7 @@ const { StripeHandler: DirectStripeRoutes } = proxyquire(
const accountUtils = require('../../../../lib/routes/utils/account.ts');
const { AuthLogger, AppConfig } = require('../../../../lib/types');
const { CapabilityManager } = require('@fxa/payments/capability');
const { CapabilityService } = require('../../../../lib/payments/capability');
const { PlayBilling } = require('../../../../lib/payments/iap/google-play');
const {
@ -66,6 +67,7 @@ const currencyHelper = new CurrencyHelper({
currenciesToCountries: { USD: ['US', 'GB', 'CA'] },
});
const mockCapabilityService = {};
const mockCapabilityManager = {};
let config, log, db, customs, push, mailer, profile;
@ -139,6 +141,24 @@ const MOCK_CLIENT_ID = '3c49430b43dfba77';
const MOCK_TTL = 3600;
const MOCK_SCOPES = ['profile:email', OAUTH_SCOPE_SUBSCRIPTIONS];
const mockContentfulClients = [
{
capabilities: ['exampleCap0', 'exampleCap1', 'exampleCap3'],
clientId: 'client1',
},
{
capabilities: [
'exampleCap0',
'exampleCap2',
'exampleCap4',
'exampleCap5',
'exampleCap6',
'exampleCap7',
],
clientId: 'client2',
},
];
/**
* To prevent the modification of the test objects loaded, which can impact other tests referencing the object,
* a deep copy of the object can be created which uses the test object as a template
@ -218,6 +238,10 @@ describe('subscriptions stripeRoutes', () => {
Container.set(CurrencyHelper, currencyHelper);
Container.set(CapabilityService, mockCapabilityService);
mockCapabilityManager.getClients = sinon.stub();
mockCapabilityManager.getClients.resolves(mockContentfulClients);
Container.set(CapabilityManager, mockCapabilityManager);
log = mocks.mockLog();
customs = mocks.mockCustoms();
@ -463,6 +487,10 @@ describe('DirectStripeRoutes', () => {
]);
Container.set(CapabilityService, mockCapabilityService);
mockCapabilityManager.getClients = sinon.stub();
mockCapabilityManager.getClients.resolves(mockContentfulClients);
Container.set(CapabilityManager, mockCapabilityManager);
directStripeRoutesInstance = new DirectStripeRoutes(
log,
db,
@ -541,71 +569,173 @@ describe('DirectStripeRoutes', () => {
});
describe('getClients', () => {
it('returns the clients and their capabilities', async () => {
directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS);
describe('getClientsFromStripe', () => {
it('returns the clients and their capabilities', async () => {
directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS);
const expected = [
{
clientId: 'client1',
capabilities: ['exampleCap0', 'exampleCap1', 'exampleCap3'],
},
{
clientId: 'client2',
capabilities: [
'exampleCap0',
'exampleCap2',
'exampleCap4',
'exampleCap5',
'exampleCap6',
'exampleCap7',
],
},
];
const expected = [
{
capabilities: ['exampleCap0', 'exampleCap1', 'exampleCap3'],
clientId: 'client1',
},
{
capabilities: [
'exampleCap0',
'exampleCap2',
'exampleCap4',
'exampleCap5',
'exampleCap6',
'exampleCap7',
],
clientId: 'client2',
},
];
const actual = await directStripeRoutesInstance.getClients();
assert.deepEqual(actual, expected, 'Clients were not returned correctly');
const actual = await directStripeRoutesInstance.getClientsFromStripe();
assert.deepEqual(
actual,
expected,
'Clients were not returned correctly'
);
});
it('adds the capabilities from the Firestore config document when available', async () => {
directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS);
const mockPlanConfigs = {
firefox_pro_basic_999: {
capabilities: {
[ALL_RPS_CAPABILITIES_KEY]: ['goodnewseveryone'],
client2: ['wibble', 'quux'],
},
},
};
directStripeRoutesInstance.stripeHelper.allMergedPlanConfigs.resolves(
mockPlanConfigs
);
const expected = [
{
capabilities: [
'exampleCap0',
'goodnewseveryone',
'exampleCap1',
'exampleCap3',
],
clientId: 'client1',
},
{
capabilities: [
'exampleCap0',
'goodnewseveryone',
'exampleCap2',
'exampleCap4',
'wibble',
'quux',
'exampleCap5',
'exampleCap6',
'exampleCap7',
],
clientId: 'client2',
},
];
const actual = await directStripeRoutesInstance.getClientsFromStripe();
assert.deepEqual(actual, expected);
});
});
it('adds the capabilities from the Firestore config document when available', async () => {
directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS);
const mockPlanConfigs = {
firefox_pro_basic_999: {
capabilities: {
[ALL_RPS_CAPABILITIES_KEY]: ['goodnewseveryone'],
client2: ['wibble', 'quux'],
},
},
};
directStripeRoutesInstance.stripeHelper.allMergedPlanConfigs.resolves(
mockPlanConfigs
it('returns results from Stripe when CapabilityManager is not found and logs to Sentry', async () => {
const sentryScope = { setContext: sandbox.stub() };
sandbox.stub(Sentry, 'withScope').callsFake((cb) => cb(sentryScope));
sandbox.stub(Sentry, 'captureMessage');
Container.remove(CapabilityManager);
const stripeHelperMock = sandbox.createStubInstance(StripeHelper);
const directStripeRoutes = new DirectStripeRoutes(
log,
db,
config,
customs,
push,
mailer,
profile,
stripeHelperMock
);
directStripeRoutes.stripeHelper.allAbbrevPlans.resolves(PLANS);
const mockClientsFromStripe =
await directStripeRoutes.getClientsFromStripe();
const clients = await directStripeRoutes.getClients();
assert.deepEqual(clients, mockClientsFromStripe);
sinon.assert.calledOnceWithExactly(sentryScope.setContext, 'getClients', {
msg: `CapabilityManager not found.`,
});
sinon.assert.calledOnceWithExactly(
Sentry.captureMessage,
`CapabilityManager not found.`,
'error'
);
});
it('returns results from Contentful when it matches Stripe', async () => {
const sentryScope = { setContext: sandbox.stub() };
sandbox.stub(Sentry, 'withScope').callsFake((cb) => cb(sentryScope));
sandbox.stub(Sentry, 'captureMessage');
directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS);
const mockClientsFromContentful =
await mockCapabilityManager.getClients();
const mockClientsFromStripe =
await directStripeRoutesInstance.getClientsFromStripe();
assert.deepEqual(mockClientsFromContentful, mockClientsFromStripe);
const clients = await directStripeRoutesInstance.getClients();
assert.deepEqual(clients, mockClientsFromContentful);
sinon.assert.notCalled(Sentry.withScope);
sinon.assert.notCalled(sentryScope.setContext);
sinon.assert.notCalled(Sentry.captureMessage);
});
it('returns results from Stripe and logs to Sentry when results do not match', async () => {
const sentryScope = { setContext: sandbox.stub() };
sandbox.stub(Sentry, 'withScope').callsFake((cb) => cb(sentryScope));
sandbox.stub(Sentry, 'captureMessage');
directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS);
mockCapabilityManager.getClients.resolves({
capabilities: ['exampleCap0', 'exampleCap1', 'exampleCap3'],
clientId: 'client1',
});
const mockClientsFromContentful =
await mockCapabilityManager.getClients();
const mockClientsFromStripe =
await directStripeRoutesInstance.getClientsFromStripe();
assert.notDeepEqual(mockClientsFromContentful, mockClientsFromStripe);
const clients = await directStripeRoutesInstance.getClients();
assert.deepEqual(clients, mockClientsFromStripe);
sinon.assert.calledOnceWithExactly(sentryScope.setContext, 'getClients', {
contentful: mockClientsFromContentful,
stripe: mockClientsFromStripe,
});
sinon.assert.calledOnceWithExactly(
Sentry.captureMessage,
`Returned Stripe as clients did not match.`,
'error'
);
const expected = [
{
clientId: 'client1',
capabilities: [
'exampleCap0',
'goodnewseveryone',
'exampleCap1',
'exampleCap3',
],
},
{
clientId: 'client2',
capabilities: [
'exampleCap0',
'goodnewseveryone',
'exampleCap2',
'exampleCap4',
'wibble',
'quux',
'exampleCap5',
'exampleCap6',
'exampleCap7',
],
},
];
const actual = await directStripeRoutesInstance.getClients();
assert.deepEqual(actual, expected);
});
});

Просмотреть файл

@ -23,6 +23,8 @@ const {
AppStoreSubscriptions,
} = require('../../lib/payments/iap/apple-app-store/subscriptions');
const { CapabilityManager } = require('@fxa/payments/capability');
const validClients = config.oauthServer.clients.filter(
(client) => client.trusted && client.canGrant && client.publicClient
);
@ -50,6 +52,7 @@ describe('#integration - remote subscriptions:', function () {
const mockStripeHelper = {};
const mockPlaySubscriptions = {};
const mockAppStoreSubscriptions = {};
const mockCapabilityManager = {};
const mockProfileClient = {};
before(async () => {
@ -104,6 +107,8 @@ describe('#integration - remote subscriptions:', function () {
Container.set(ProfileClient, mockProfileClient);
Container.remove(CapabilityService);
Container.set(CapabilityService, new CapabilityService());
mockCapabilityManager.getClients = () => {};
Container.set(CapabilityManager, mockCapabilityManager);
server = await testServerFactory.start(config, false, {
authServerMockDependencies: {