зеркало из https://github.com/mozilla/fxa.git
chore(libs): Create getClients method in CapabilityManager
This commit is contained in:
Родитель
c5ffcb0b99
Коммит
3403f3350a
|
@ -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: {
|
||||
|
|
Загрузка…
Ссылка в новой задаче