diff --git a/apps/payments/api/src/app/app.module.ts b/apps/payments/api/src/app/app.module.ts index 8f7ff1a477f..9c742da6719 100644 --- a/apps/payments/api/src/app/app.module.ts +++ b/apps/payments/api/src/app/app.module.ts @@ -48,6 +48,7 @@ import { AccountManager } from '@fxa/shared/account/account'; import { CartManager } from '@fxa/payments/cart'; import { CmsContentValidationManager, + MeteringConfigurationManager, ProductConfigurationManager, StrapiClient, } from '@fxa/shared/cms'; @@ -117,6 +118,7 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat PaypalCustomerManager, StrapiClient, CmsContentValidationManager, + MeteringConfigurationManager, CmsWebhookService, FxaWebhookService, NimbusManager, diff --git a/libs/shared/cms/src/index.ts b/libs/shared/cms/src/index.ts index 097d2f41f23..9663b141ca3 100644 --- a/libs/shared/cms/src/index.ts +++ b/libs/shared/cms/src/index.ts @@ -9,6 +9,7 @@ export * from './lib/legal-terms-configuration.manager'; export * from './lib/product-configuration.manager'; export * from './lib/relying-party-configuration.manager'; export * from './lib/default-configuration.manager'; +export * from './lib/metering-configuration.manager'; export * from './lib/queries/cancel-interstitial-offer'; export * from './lib/queries/capability-service-by-plan-ids'; export * from './lib/queries/churn-intervention-by-product-id'; diff --git a/libs/shared/cms/src/lib/metering-configuration.manager.spec.ts b/libs/shared/cms/src/lib/metering-configuration.manager.spec.ts new file mode 100644 index 00000000000..60f96323c73 --- /dev/null +++ b/libs/shared/cms/src/lib/metering-configuration.manager.spec.ts @@ -0,0 +1,191 @@ +/* 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 } from '@nestjs/testing'; +import { DocumentNode } from 'graphql'; +import { StatsD } from 'hot-shots'; + +import { + StrapiClient, + StrapiClientEventResponse, + meterBySlugQuery, + MeterBySlugResultFactory, + StrapiMeterFactory, +} from '@fxa/shared/cms'; +import { StatsDService } from '@fxa/shared/metrics/statsd'; +import { MeteringConfigurationManager } from './metering-configuration.manager'; + +jest.mock('@type-cacheable/core', () => { + const noopDecorator = + () => + ( + target: any, + propertyKey: string | symbol, + descriptor: PropertyDescriptor + ) => + descriptor; + + const Cacheable = jest.fn(() => noopDecorator); + const CacheClear = jest.fn(() => noopDecorator); + + const defaultExport = { + setOptions: jest.fn(), + }; + + return { + __esModule: true, + default: defaultExport, + Cacheable, + CacheClear, + }; +}); + +jest.mock('@fxa/shared/db/type-cacheable', () => ({ + MemoryAdapter: jest.fn().mockImplementation(() => ({})), + FirestoreAdapter: jest.fn().mockImplementation(() => ({})), + CacheFirstStrategy: jest.fn().mockImplementation(() => ({})), + AsyncLocalStorageAdapter: jest.fn().mockImplementation(() => ({})), + StaleWhileRevalidateWithFallbackStrategy: jest + .fn() + .mockImplementation(() => ({})), +})); + +jest.mock('@apollo/client/utilities', () => ({ + getOperationName: jest.fn().mockReturnValue('MockOperation'), +})); + +describe('MeteringConfigurationManager', () => { + let manager: MeteringConfigurationManager; + let mockStrapiClient: jest.Mocked>; + let mockStatsd: jest.Mocked>; + + beforeEach(async () => { + mockStatsd = { + timing: jest.fn(), + }; + + mockStrapiClient = { + query: jest.fn(), + on: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + MeteringConfigurationManager, + { provide: StatsDService, useValue: mockStatsd }, + { provide: StrapiClient, useValue: mockStrapiClient }, + ], + }).compile(); + + manager = module.get(MeteringConfigurationManager); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('registers a response event listener on the Strapi client', () => { + expect(mockStrapiClient.on).toHaveBeenCalledWith( + 'response', + expect.any(Function) + ); + }); + }); + + describe('onStrapiClientResponse', () => { + it('records timing with operation name when query is provided', () => { + const response: StrapiClientEventResponse = { + method: 'query', + requestStartTime: 0, + requestEndTime: 1, + elapsed: 1, + cache: false, + cacheType: 'method', + query: {} as DocumentNode, + error: undefined, + }; + + manager.onStrapiClientResponse(response); + + expect(mockStatsd.timing).toHaveBeenCalledWith( + 'cms_metering_request', + 1, + undefined, + { + method: 'query', + error: 'false', + cache: 'false', + cacheType: 'method', + operationName: 'MockOperation', + } + ); + }); + + it('records timing without operation name when query is not provided', () => { + const response: StrapiClientEventResponse = { + method: 'query', + requestStartTime: 0, + requestEndTime: 1, + elapsed: 1, + cache: true, + cacheType: 'method', + query: undefined, + error: new Error('Test error'), + }; + + manager.onStrapiClientResponse(response); + + expect(mockStatsd.timing).toHaveBeenCalledWith( + 'cms_metering_request', + 1, + undefined, + { + method: 'query', + error: 'true', + cache: 'true', + cacheType: 'method', + } + ); + }); + }); + + describe('getMeterBySlug', () => { + it('returns the first meter when a match exists', async () => { + const mockMeter = StrapiMeterFactory(); + const mockResult = MeterBySlugResultFactory({ + meters: [mockMeter], + }); + mockStrapiClient.query.mockResolvedValue(mockResult); + + const result = await manager.getMeterBySlug('test-slug'); + + expect(mockStrapiClient.query).toHaveBeenCalledWith(meterBySlugQuery, { + slug: 'test-slug', + }); + expect(result).toEqual(mockMeter); + }); + + it('returns null when no meters match', async () => { + const mockResult = MeterBySlugResultFactory({ meters: [] }); + mockStrapiClient.query.mockResolvedValue(mockResult); + + const result = await manager.getMeterBySlug('nonexistent-slug'); + + expect(mockStrapiClient.query).toHaveBeenCalledWith(meterBySlugQuery, { + slug: 'nonexistent-slug', + }); + expect(result).toBeNull(); + }); + + it('propagates errors from the Strapi client', async () => { + const error = new Error('Query failed'); + mockStrapiClient.query.mockRejectedValue(error); + + await expect(manager.getMeterBySlug('test-slug')).rejects.toThrow( + 'Query failed' + ); + }); + }); +}); diff --git a/libs/shared/cms/src/lib/metering-configuration.manager.ts b/libs/shared/cms/src/lib/metering-configuration.manager.ts new file mode 100644 index 00000000000..16c4acc582c --- /dev/null +++ b/libs/shared/cms/src/lib/metering-configuration.manager.ts @@ -0,0 +1,52 @@ +/* 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 { getOperationName } from '@apollo/client/utilities'; +import { Inject, Injectable } from '@nestjs/common'; +import { StatsD } from 'hot-shots'; + +import { StatsDService } from '@fxa/shared/metrics/statsd'; +import { meterBySlugQuery } from './queries/meter'; +import type { StrapiMeter } from './queries/meter'; +import { StrapiClient, StrapiClientEventResponse } from './strapi.client'; +import type { DeepNonNullable } from './types'; +import type { MeterBySlugQuery } from '../__generated__/graphql'; + +@Injectable() +export class MeteringConfigurationManager { + constructor( + private strapiClient: StrapiClient, + @Inject(StatsDService) + private statsd: StatsD + ) { + this.strapiClient.on('response', this.onStrapiClientResponse.bind(this)); + } + + onStrapiClientResponse(response: StrapiClientEventResponse) { + const defaultTags = { + method: response.method, + error: response.error ? 'true' : 'false', + cache: `${response.cache}`, + cacheType: `${response.cacheType}`, + }; + const operationName = response.query && getOperationName(response.query); + const tags = operationName + ? { ...defaultTags, operationName } + : defaultTags; + this.statsd.timing( + 'cms_metering_request', + response.elapsed, + undefined, + tags + ); + } + + async getMeterBySlug(slug: string): Promise { + const queryResult = (await this.strapiClient.query(meterBySlugQuery, { + slug, + })) as DeepNonNullable; + + return queryResult.meters.at(0) ?? null; + } +}