Skip to content

Commit 742e7c7

Browse files
committed
Add quote refresh helper
1 parent a1280b7 commit 742e7c7

File tree

5 files changed

+298
-53
lines changed

5 files changed

+298
-53
lines changed

packages/transaction-pay-controller/src/TransactionPayController.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { noop } from 'lodash';
66

77
import { updatePaymentToken } from './actions/update-payment-token';
88
import { CONTROLLER_NAME, TransactionPayStrategy } from './constants';
9+
import { QuoteRefresher } from './helpers/QuoteRefresher';
910
import type {
1011
TransactionData,
1112
TransactionPayControllerMessenger,
1213
TransactionPayControllerOptions,
1314
TransactionPayControllerState,
1415
UpdatePaymentTokenRequest,
1516
} from './types';
16-
import { queueRefreshQuotes, updateQuotes } from './utils/quotes';
17+
import { updateQuotes } from './utils/quotes';
1718
import { updateSourceAmounts } from './utils/source-amounts';
1819
import { pollTransactionChanges } from './utils/transaction';
1920

@@ -61,7 +62,10 @@ export class TransactionPayController extends BaseController<
6162
this.#removeTransactionData.bind(this),
6263
);
6364

64-
queueRefreshQuotes(messenger, this.#updateTransactionData.bind(this));
65+
new QuoteRefresher({
66+
messenger,
67+
updateTransactionData: this.#updateTransactionData.bind(this),
68+
});
6569
}
6670

6771
updatePaymentToken(request: UpdatePaymentTokenRequest) {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { createDeferredPromise } from '@metamask/utils';
2+
3+
import { QuoteRefresher } from './QuoteRefresher';
4+
import { flushPromises } from '../../../../tests/helpers';
5+
import { getMessengerMock } from '../tests/messenger-mock';
6+
import type {
7+
TransactionData,
8+
TransactionPayControllerMessenger,
9+
} from '../types';
10+
import { refreshQuotes } from '../utils/quotes';
11+
12+
jest.mock('../utils/quotes');
13+
14+
jest.useFakeTimers();
15+
16+
describe('QuoteRefresher', () => {
17+
const refreshQuotesMock = jest.mocked(refreshQuotes);
18+
let messenger: TransactionPayControllerMessenger;
19+
let publish: ReturnType<typeof getMessengerMock>['publish'];
20+
21+
/**
22+
* Helper to publish state changes with or without quotes.
23+
*
24+
* @param options - Options object.
25+
* @param options.hasQuotes - Whether to include quotes in the state.
26+
*/
27+
function publishStateChange({ hasQuotes }: { hasQuotes: boolean }) {
28+
const transactionData = {
29+
'123': (hasQuotes ? { quotes: [{}] } : {}) as TransactionData,
30+
};
31+
32+
publish(
33+
'TransactionPayController:stateChange',
34+
{
35+
transactionData,
36+
},
37+
[],
38+
);
39+
}
40+
41+
beforeEach(() => {
42+
jest.resetAllMocks();
43+
jest.clearAllTimers();
44+
45+
({ messenger, publish } = getMessengerMock());
46+
47+
refreshQuotesMock.mockResolvedValue(undefined);
48+
});
49+
50+
it('polls if quotes detected in state', async () => {
51+
new QuoteRefresher({
52+
messenger,
53+
updateTransactionData: jest.fn(),
54+
});
55+
56+
publishStateChange({ hasQuotes: true });
57+
58+
jest.runAllTimers();
59+
await flushPromises();
60+
61+
expect(refreshQuotesMock).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it('does not poll if no quotes in state', async () => {
65+
new QuoteRefresher({
66+
messenger,
67+
updateTransactionData: jest.fn(),
68+
});
69+
70+
publishStateChange({ hasQuotes: false });
71+
72+
jest.runAllTimers();
73+
await flushPromises();
74+
75+
expect(refreshQuotesMock).not.toHaveBeenCalled();
76+
});
77+
78+
it('polls again after interval', async () => {
79+
new QuoteRefresher({
80+
messenger,
81+
updateTransactionData: jest.fn(),
82+
});
83+
84+
publishStateChange({ hasQuotes: true });
85+
86+
jest.runAllTimers();
87+
await flushPromises();
88+
89+
jest.runAllTimers();
90+
await flushPromises();
91+
92+
expect(refreshQuotesMock).toHaveBeenCalledTimes(2);
93+
});
94+
95+
it('stops polling if quotes removed', async () => {
96+
new QuoteRefresher({
97+
messenger,
98+
updateTransactionData: jest.fn(),
99+
});
100+
101+
publishStateChange({ hasQuotes: true });
102+
publishStateChange({ hasQuotes: false });
103+
104+
jest.runAllTimers();
105+
await flushPromises();
106+
107+
expect(refreshQuotesMock).toHaveBeenCalledTimes(0);
108+
});
109+
110+
it('does not throw if refresh fails', async () => {
111+
const updateTransactionData = jest.fn();
112+
113+
new QuoteRefresher({
114+
messenger,
115+
updateTransactionData,
116+
});
117+
118+
publishStateChange({ hasQuotes: true });
119+
120+
refreshQuotesMock.mockRejectedValueOnce(new Error('Test error'));
121+
122+
jest.runAllTimers();
123+
await flushPromises();
124+
125+
jest.runAllTimers();
126+
await flushPromises();
127+
128+
expect(refreshQuotesMock).toHaveBeenCalledTimes(2);
129+
});
130+
131+
it('does not update multiple times concurrently', async () => {
132+
const updateTransactionData = jest.fn();
133+
134+
new QuoteRefresher({
135+
messenger,
136+
updateTransactionData,
137+
});
138+
139+
publishStateChange({ hasQuotes: true });
140+
141+
const promise = createDeferredPromise();
142+
refreshQuotesMock.mockReturnValue(promise.promise);
143+
144+
jest.runAllTimers();
145+
await flushPromises();
146+
147+
publishStateChange({ hasQuotes: false });
148+
publishStateChange({ hasQuotes: true });
149+
150+
jest.runAllTimers();
151+
await flushPromises();
152+
153+
expect(refreshQuotesMock).toHaveBeenCalledTimes(1);
154+
});
155+
156+
it('does not queue if stopped while polling', async () => {
157+
const updateTransactionData = jest.fn();
158+
159+
new QuoteRefresher({
160+
messenger,
161+
updateTransactionData,
162+
});
163+
164+
publishStateChange({ hasQuotes: true });
165+
166+
const promise = createDeferredPromise();
167+
refreshQuotesMock.mockReturnValue(promise.promise);
168+
169+
jest.runAllTimers();
170+
await flushPromises();
171+
172+
publishStateChange({ hasQuotes: false });
173+
174+
promise.resolve();
175+
await flushPromises();
176+
177+
expect(refreshQuotesMock).toHaveBeenCalledTimes(1);
178+
});
179+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createModuleLogger } from '@metamask/utils';
2+
import { noop } from 'lodash';
3+
4+
import type {
5+
TransactionPayControllerMessenger,
6+
TransactionPayControllerState,
7+
} from '..';
8+
import { projectLogger } from '../logger';
9+
import type { UpdateTransactionDataCallback } from '../types';
10+
import { refreshQuotes } from '../utils/quotes';
11+
12+
const CHECK_INTERVAL = 1000; // 1 Second
13+
14+
const log = createModuleLogger(projectLogger, 'quote-refresh');
15+
16+
export class QuoteRefresher {
17+
#isRunning: boolean;
18+
19+
#isUpdating: boolean;
20+
21+
readonly #messenger: TransactionPayControllerMessenger;
22+
23+
#timeoutId: NodeJS.Timeout | undefined;
24+
25+
readonly #updateTransactionData: UpdateTransactionDataCallback;
26+
27+
constructor({
28+
messenger,
29+
updateTransactionData,
30+
}: {
31+
messenger: TransactionPayControllerMessenger;
32+
updateTransactionData: UpdateTransactionDataCallback;
33+
}) {
34+
this.#messenger = messenger;
35+
this.#isRunning = false;
36+
this.#isUpdating = false;
37+
this.#updateTransactionData = updateTransactionData;
38+
39+
messenger.subscribe(
40+
'TransactionPayController:stateChange',
41+
this.#onStateChange.bind(this),
42+
);
43+
}
44+
45+
#start() {
46+
this.#isRunning = true;
47+
48+
log('Started');
49+
50+
if (this.#isUpdating) {
51+
return;
52+
}
53+
54+
this.#queueNextInterval();
55+
}
56+
57+
#stop() {
58+
if (this.#timeoutId) {
59+
clearTimeout(this.#timeoutId);
60+
}
61+
62+
this.#isRunning = false;
63+
64+
log('Stopped');
65+
}
66+
67+
async #onInterval() {
68+
this.#isUpdating = true;
69+
70+
try {
71+
await refreshQuotes(this.#messenger, this.#updateTransactionData);
72+
} catch (error) {
73+
log('Error refreshing quotes', error);
74+
} finally {
75+
this.#isUpdating = false;
76+
77+
this.#queueNextInterval();
78+
}
79+
}
80+
81+
#queueNextInterval() {
82+
if (!this.#isRunning) {
83+
return;
84+
}
85+
86+
if (this.#timeoutId) {
87+
clearTimeout(this.#timeoutId);
88+
}
89+
90+
this.#timeoutId = setTimeout(() => {
91+
this.#onInterval().catch(noop);
92+
}, CHECK_INTERVAL);
93+
}
94+
95+
#onStateChange(state: TransactionPayControllerState) {
96+
const hasQuotes = Object.values(state.transactionData).some((transaction) =>
97+
Boolean(transaction.quotes?.length),
98+
);
99+
100+
if (hasQuotes && !this.#isRunning) {
101+
this.#start();
102+
} else if (!hasQuotes && this.#isRunning) {
103+
this.#stop();
104+
}
105+
}
106+
}

packages/transaction-pay-controller/src/utils/quotes.test.ts

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import type { Hex, Json } from '@metamask/utils';
44
import { cloneDeep } from 'lodash';
55

66
import type { UpdateQuotesRequest } from './quotes';
7-
import { queueRefreshQuotes, updateQuotes } from './quotes';
7+
import { refreshQuotes, updateQuotes } from './quotes';
88
import { getStrategy, getStrategyByName } from './strategy';
99
import { calculateTotals } from './totals';
1010
import { getTransaction, updateTransaction } from './transaction';
11-
import { flushPromises } from '../../../../tests/helpers';
1211
import { getMessengerMock } from '../tests/messenger-mock';
1312
import type {
1413
TransactionPaySourceAmount,
@@ -283,7 +282,7 @@ describe('Quotes Utils', () => {
283282
});
284283
});
285284

286-
describe('queueRefreshQuotes', () => {
285+
describe('refreshQuotes', () => {
287286
it('updates quotes after refresh interval', async () => {
288287
getControllerStateMock.mockReturnValue({
289288
transactionData: {
@@ -296,10 +295,7 @@ describe('Quotes Utils', () => {
296295
},
297296
});
298297

299-
queueRefreshQuotes(messenger, updateTransactionDataMock);
300-
301-
jest.runAllTimers();
302-
await flushPromises();
298+
await refreshQuotes(messenger, updateTransactionDataMock);
303299

304300
expect(updateTransactionDataMock).toHaveBeenCalledTimes(2);
305301

@@ -323,10 +319,7 @@ describe('Quotes Utils', () => {
323319
},
324320
});
325321

326-
queueRefreshQuotes(messenger, updateTransactionDataMock);
327-
328-
jest.runAllTimers();
329-
await flushPromises();
322+
await refreshQuotes(messenger, updateTransactionDataMock);
330323

331324
expect(updateTransactionDataMock).toHaveBeenCalledTimes(2);
332325

@@ -351,10 +344,7 @@ describe('Quotes Utils', () => {
351344
},
352345
});
353346

354-
queueRefreshQuotes(messenger, updateTransactionDataMock);
355-
356-
jest.advanceTimersByTime(1000);
357-
await flushPromises();
347+
await refreshQuotes(messenger, updateTransactionDataMock);
358348

359349
expect(updateTransactionDataMock).toHaveBeenCalledTimes(0);
360350
});
@@ -371,21 +361,7 @@ describe('Quotes Utils', () => {
371361
},
372362
});
373363

374-
queueRefreshQuotes(messenger, updateTransactionDataMock);
375-
376-
jest.advanceTimersByTime(1000);
377-
await flushPromises();
378-
379-
expect(updateTransactionDataMock).toHaveBeenCalledTimes(0);
380-
});
381-
382-
it('does not throw if refresh fails', async () => {
383-
getControllerStateMock.mockReturnValue(undefined as never);
384-
385-
queueRefreshQuotes(messenger, updateTransactionDataMock);
386-
387-
jest.advanceTimersByTime(1000);
388-
await flushPromises();
364+
await refreshQuotes(messenger, updateTransactionDataMock);
389365

390366
expect(updateTransactionDataMock).toHaveBeenCalledTimes(0);
391367
});

0 commit comments

Comments
 (0)