Skip to content

Commit 675e518

Browse files
authored
initiate shield subscriptions polling (#6770)
## Explanation <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> Implemented Polling mechanism for SubscriptionController. This PR includes the following changes - **BREAKING**: The `SubscriptionController` now extends `StaticIntervalPollingController`, and the new polling API `startPolling` must be used to initiate polling (`startPolling`, `stopPollingByPollingToken`). ([#6770](#6770)) - **BREAKING**: The `SubscriptionController` now accepts an optional `pollingInterval` property in the constructor argument, to enable the configurable polling interval. ([#6770](#6770)) - Prevent unnecessary state updates to avoid emitting `:stateChange` in `getSubscriptions` method. ([#6770](#6770)) ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds polling to `SubscriptionController` with configurable interval and `getSubscriptionByProduct`, while optimizing state updates; BREAKING: controller now extends polling controller and requires `startPolling`. > > - **SubscriptionController** > - **BREAKING**: Extends `StaticIntervalPollingController`; use `startPolling`/`stopPollingByPollingToken` to manage polling. > - Adds optional `pollingInterval` (defaults to `DEFAULT_POLLING_INTERVAL`). > - Implements `_executePoll` to fetch subscriptions and trigger `triggerAccessTokenRefresh` on state changes. > - Adds `getSubscriptionByProduct(product)` and corresponding action export. > - Optimizes `getSubscriptions` to avoid unnecessary state updates via equality checks (subscriptions, trialed products, customerId). > - **Dependencies/Config** > - Adds `@metamask/polling-controller`; updates TS project references. > - Test updates incl. polling behavior and order-insensitive state checks; adds `sinon` for fake timers. > - **Docs** > - Changelog updated with new method and breaking changes. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit eee1d7a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b26a2e1 commit 675e518

File tree

9 files changed

+357
-11
lines changed

9 files changed

+357
-11
lines changed

packages/subscription-controller/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added new public method, `getSubscriptionByProduct` which accepts `product` name as parameter and return the relevant subscription. ([#6770](https://github.com/MetaMask/core/pull/6770))
13+
14+
### Changed
15+
16+
- **BREAKING**: The `SubscriptionController` now extends `StaticIntervalPollingController`, and the new polling API `startPolling` must be used to initiate polling (`startPolling`, `stopPollingByPollingToken`). ([#6770](https://github.com/MetaMask/core/pull/6770))
17+
- **BREAKING**: The `SubscriptionController` now accepts an optional `pollingInterval` property in the constructor argument, to enable the configurable polling interval. ([#6770](https://github.com/MetaMask/core/pull/6770))
18+
- Prevent unnecessary state updates to avoid emitting `:stateChange` in `getSubscriptions` method. ([#6770](https://github.com/MetaMask/core/pull/6770))
19+
1020
## [0.5.0]
1121

1222
### Changed

packages/subscription-controller/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"dependencies": {
5050
"@metamask/base-controller": "^8.4.0",
5151
"@metamask/controller-utils": "^11.14.0",
52+
"@metamask/polling-controller": "^14.0.0",
5253
"@metamask/utils": "^11.8.1"
5354
},
5455
"devDependencies": {
@@ -57,6 +58,7 @@
5758
"@types/jest": "^27.4.1",
5859
"deepmerge": "^4.2.2",
5960
"jest": "^27.5.1",
61+
"sinon": "^9.2.4",
6062
"ts-jest": "^27.1.4",
6163
"typedoc": "^0.24.8",
6264
"typedoc-plugin-missing-exports": "^2.0.0",

packages/subscription-controller/src/SubscriptionController.test.ts

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { deriveStateFromMetadata, Messenger } from '@metamask/base-controller';
2+
import * as sinon from 'sinon';
23

34
import {
45
controllerName,
@@ -22,13 +23,15 @@ import type {
2223
StartCryptoSubscriptionRequest,
2324
StartCryptoSubscriptionResponse,
2425
UpdatePaymentMethodOpts,
26+
Product,
2527
} from './types';
2628
import {
2729
PAYMENT_TYPES,
2830
PRODUCT_TYPES,
2931
RECURRING_INTERVALS,
3032
SUBSCRIPTION_STATUSES,
3133
} from './types';
34+
import { advanceTime } from '../../../tests/helpers';
3235

3336
// Mock data
3437
const MOCK_SUBSCRIPTION: Subscription = {
@@ -269,6 +272,7 @@ describe('SubscriptionController', () => {
269272
messenger,
270273
state: initialState,
271274
subscriptionService: mockService,
275+
pollingInterval: 10_000,
272276
});
273277

274278
expect(controller).toBeDefined();
@@ -291,7 +295,7 @@ describe('SubscriptionController', () => {
291295
});
292296
});
293297

294-
describe('getSubscription', () => {
298+
describe('getSubscriptions', () => {
295299
it('should fetch and store subscription successfully', async () => {
296300
await withController(async ({ controller, mockService }) => {
297301
mockService.getSubscriptions.mockResolvedValue(
@@ -371,6 +375,156 @@ describe('SubscriptionController', () => {
371375
},
372376
);
373377
});
378+
379+
it('should not update state when multiple subscriptions are the same but in different order', async () => {
380+
const mockSubscription1 = { ...MOCK_SUBSCRIPTION, id: 'sub_1' };
381+
const mockSubscription2 = { ...MOCK_SUBSCRIPTION, id: 'sub_2' };
382+
const mockSubscription3 = { ...MOCK_SUBSCRIPTION, id: 'sub_3' };
383+
384+
await withController(
385+
{
386+
state: {
387+
customerId: 'cus_1',
388+
subscriptions: [
389+
mockSubscription1,
390+
mockSubscription2,
391+
mockSubscription3,
392+
],
393+
},
394+
},
395+
async ({ controller, mockService }) => {
396+
// Return the same subscriptions but in different order
397+
mockService.getSubscriptions.mockResolvedValue({
398+
customerId: 'cus_1',
399+
subscriptions: [
400+
mockSubscription3,
401+
mockSubscription1,
402+
mockSubscription2,
403+
], // Different order
404+
trialedProducts: [],
405+
});
406+
407+
const initialState = [...controller.state.subscriptions];
408+
await controller.getSubscriptions();
409+
410+
// Should not update state since subscriptions are the same (just different order)
411+
expect(controller.state.subscriptions).toStrictEqual(initialState);
412+
},
413+
);
414+
});
415+
416+
it('should not update state when subscriptions are the same but the products are in different order', async () => {
417+
const mockProduct1: Product = {
418+
// @ts-expect-error - mock data
419+
name: 'Product 1',
420+
currency: 'usd',
421+
unitAmount: 900,
422+
unitDecimals: 2,
423+
};
424+
const mockProduct2: Product = {
425+
// @ts-expect-error - mock data
426+
name: 'Product 2',
427+
currency: 'usd',
428+
unitAmount: 900,
429+
unitDecimals: 2,
430+
};
431+
const mockSubscription = {
432+
...MOCK_SUBSCRIPTION,
433+
products: [mockProduct1, mockProduct2],
434+
};
435+
436+
await withController(
437+
{
438+
state: {
439+
subscriptions: [mockSubscription],
440+
trialedProducts: [PRODUCT_TYPES.SHIELD],
441+
},
442+
},
443+
async ({ controller, mockService }) => {
444+
mockService.getSubscriptions.mockResolvedValue({
445+
...MOCK_SUBSCRIPTION,
446+
subscriptions: [
447+
{ ...MOCK_SUBSCRIPTION, products: [mockProduct2, mockProduct1] },
448+
],
449+
trialedProducts: [PRODUCT_TYPES.SHIELD],
450+
});
451+
await controller.getSubscriptions();
452+
expect(controller.state.subscriptions).toStrictEqual([
453+
mockSubscription,
454+
]);
455+
},
456+
);
457+
});
458+
459+
it('should update state when subscriptions are the same but the trialed products are different', async () => {
460+
const mockProduct1: Product = {
461+
// @ts-expect-error - mock data
462+
name: 'Product 1',
463+
currency: 'usd',
464+
unitAmount: 900,
465+
unitDecimals: 2,
466+
};
467+
const mockProduct2: Product = {
468+
// @ts-expect-error - mock data
469+
name: 'Product 2',
470+
currency: 'usd',
471+
unitAmount: 900,
472+
unitDecimals: 2,
473+
};
474+
const mockSubscription = {
475+
...MOCK_SUBSCRIPTION,
476+
products: [mockProduct1, mockProduct2],
477+
};
478+
479+
await withController(
480+
{
481+
state: {
482+
subscriptions: [mockSubscription],
483+
},
484+
},
485+
async ({ controller, mockService }) => {
486+
mockService.getSubscriptions.mockResolvedValue({
487+
...MOCK_SUBSCRIPTION,
488+
subscriptions: [
489+
{ ...MOCK_SUBSCRIPTION, products: [mockProduct1, mockProduct2] },
490+
],
491+
trialedProducts: [PRODUCT_TYPES.SHIELD],
492+
});
493+
await controller.getSubscriptions();
494+
expect(controller.state.subscriptions).toStrictEqual([
495+
mockSubscription,
496+
]);
497+
expect(controller.state.trialedProducts).toStrictEqual([
498+
PRODUCT_TYPES.SHIELD,
499+
]);
500+
},
501+
);
502+
});
503+
});
504+
505+
describe('getSubscriptionByProduct', () => {
506+
it('should get subscription by product successfully', async () => {
507+
await withController(
508+
{
509+
state: {
510+
subscriptions: [MOCK_SUBSCRIPTION],
511+
},
512+
},
513+
async ({ controller }) => {
514+
expect(
515+
controller.getSubscriptionByProduct(PRODUCT_TYPES.SHIELD),
516+
).toStrictEqual(MOCK_SUBSCRIPTION);
517+
},
518+
);
519+
});
520+
521+
it('should return undefined if no subscription is found', async () => {
522+
await withController(async ({ controller }) => {
523+
expect(
524+
controller.getSubscriptionByProduct(PRODUCT_TYPES.SHIELD),
525+
).toBeUndefined();
526+
});
527+
});
374528
});
375529

376530
describe('cancelSubscription', () => {
@@ -699,6 +853,42 @@ describe('SubscriptionController', () => {
699853
});
700854
});
701855

856+
describe('startPolling', () => {
857+
let clock: sinon.SinonFakeTimers;
858+
beforeEach(() => {
859+
// eslint-disable-next-line import-x/namespace
860+
clock = sinon.useFakeTimers();
861+
});
862+
863+
afterEach(() => {
864+
clock.restore();
865+
});
866+
867+
it('should call getSubscriptions with the correct interval', async () => {
868+
await withController(async ({ controller }) => {
869+
const getSubscriptionsSpy = jest.spyOn(controller, 'getSubscriptions');
870+
controller.startPolling({});
871+
await advanceTime({ clock, duration: 0 });
872+
expect(getSubscriptionsSpy).toHaveBeenCalledTimes(1);
873+
});
874+
});
875+
876+
it('should call `triggerAccessTokenRefresh` when the state changes', async () => {
877+
await withController(async ({ controller, mockService }) => {
878+
mockService.getSubscriptions.mockResolvedValue(
879+
MOCK_GET_SUBSCRIPTIONS_RESPONSE,
880+
);
881+
const triggerAccessTokenRefreshSpy = jest.spyOn(
882+
controller,
883+
'triggerAccessTokenRefresh',
884+
);
885+
controller.startPolling({});
886+
await advanceTime({ clock, duration: 0 });
887+
expect(triggerAccessTokenRefreshSpy).toHaveBeenCalledTimes(1);
888+
});
889+
});
890+
});
891+
702892
describe('integration scenarios', () => {
703893
it('should handle complete subscription lifecycle with updated logic', async () => {
704894
await withController(async ({ controller, mockService }) => {

0 commit comments

Comments
 (0)