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
2 changes: 2 additions & 0 deletions apps/payments/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -117,6 +118,7 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat
PaypalCustomerManager,
StrapiClient,
CmsContentValidationManager,
MeteringConfigurationManager,
CmsWebhookService,
FxaWebhookService,
NimbusManager,
Expand Down
1 change: 1 addition & 0 deletions libs/shared/cms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
191 changes: 191 additions & 0 deletions libs/shared/cms/src/lib/metering-configuration.manager.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<StrapiClient, 'query' | 'on'>>;
let mockStatsd: jest.Mocked<Pick<StatsD, 'timing'>>;

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'
);
});
});
});
52 changes: 52 additions & 0 deletions libs/shared/cms/src/lib/metering-configuration.manager.ts
Original file line number Diff line number Diff line change
@@ -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<StrapiMeter | null> {
const queryResult = (await this.strapiClient.query(meterBySlugQuery, {
slug,
})) as DeepNonNullable<MeterBySlugQuery>;

return queryResult.meters.at(0) ?? null;
}
}
Loading