@mojaloop/sdk-scheme-adapter 12.2.2 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.env.example +3 -0
  2. package/CHANGELOG.md +26 -0
  3. package/audit-resolve.json +71 -1
  4. package/docker/ml-testing-toolkit/spec_files/api_definitions/fspiop_1.1/trigger_templates/transaction_request_followup.json +2 -2
  5. package/docker/ml-testing-toolkit/spec_files/rules_callback/default.json +7 -7
  6. package/docker/ml-testing-toolkit/spec_files/rules_response/default.json +16 -16
  7. package/docker/ml-testing-toolkit/spec_files/rules_response/default_pisp_rules.json +5 -5
  8. package/docker/ml-testing-toolkit/spec_files/rules_validation/default.json +10 -10
  9. package/package.json +4 -1
  10. package/src/ControlAgent/index.js +2 -3
  11. package/src/ControlServer/index.js +2 -2
  12. package/src/InboundServer/handlers.js +114 -52
  13. package/src/InboundServer/index.js +7 -7
  14. package/src/InboundServer/middlewares.js +2 -2
  15. package/src/OutboundServer/api.yaml +54 -3
  16. package/src/OutboundServer/api_interfaces/openapi.d.ts +24 -3
  17. package/src/OutboundServer/api_template/components/schemas/accountsResponse.yaml +9 -0
  18. package/src/OutboundServer/api_template/components/schemas/transferRequest.yaml +3 -0
  19. package/src/OutboundServer/api_template/components/schemas/transferResponse.yaml +28 -2
  20. package/src/OutboundServer/api_template/components/schemas/transferStatusResponse.yaml +8 -1
  21. package/src/OutboundServer/handlers.js +4 -1
  22. package/src/OutboundServer/index.js +10 -11
  23. package/src/config.js +29 -12
  24. package/src/index.js +198 -10
  25. package/src/lib/cache.js +110 -52
  26. package/src/lib/metrics.js +148 -0
  27. package/src/lib/model/AccountsModel.js +17 -12
  28. package/src/lib/model/Async2SyncModel.js +4 -1
  29. package/src/lib/model/InboundTransfersModel.js +170 -25
  30. package/src/lib/model/OutboundBulkQuotesModel.js +4 -1
  31. package/src/lib/model/OutboundBulkTransfersModel.js +4 -1
  32. package/src/lib/model/OutboundRequestToPayModel.js +9 -7
  33. package/src/lib/model/OutboundRequestToPayTransferModel.js +6 -3
  34. package/src/lib/model/OutboundTransfersModel.js +318 -53
  35. package/src/lib/model/PartiesModel.js +1 -1
  36. package/src/lib/model/ProxyModel/index.js +4 -2
  37. package/src/lib/model/common/BackendError.js +28 -4
  38. package/src/lib/model/common/index.js +2 -1
  39. package/src/lib/validate.js +2 -2
  40. package/test/__mocks__/@mojaloop/sdk-standard-components.js +3 -2
  41. package/test/__mocks__/redis.js +4 -0
  42. package/test/config/integration.env +5 -0
  43. package/test/integration/lib/Outbound/parties.test.js +1 -1
  44. package/test/unit/ControlServer/index.js +3 -3
  45. package/test/unit/InboundServer.test.js +10 -10
  46. package/test/unit/TestServer.test.js +11 -13
  47. package/test/unit/api/accounts/data/postAccountsErrorMojaloopResponse.json +11 -3
  48. package/test/unit/api/accounts/data/postAccountsSuccessResponse.json +14 -0
  49. package/test/unit/api/accounts/data/postAccountsSuccessResponseWithError1.json +13 -0
  50. package/test/unit/api/accounts/data/postAccountsSuccessResponseWithError2.json +18 -0
  51. package/test/unit/api/accounts/utils.js +15 -1
  52. package/test/unit/api/transfers/data/getTransfersCommittedResponse.json +18 -15
  53. package/test/unit/api/transfers/data/getTransfersErrorNotFound.json +1 -0
  54. package/test/unit/api/transfers/data/postTransfersErrorMojaloopResponse.json +9 -0
  55. package/test/unit/api/transfers/data/postTransfersErrorTimeoutResponse.json +1 -0
  56. package/test/unit/api/transfers/data/postTransfersSuccessResponse.json +74 -47
  57. package/test/unit/api/transfers/utils.js +85 -4
  58. package/test/unit/api/utils.js +4 -1
  59. package/test/unit/config.test.js +2 -2
  60. package/test/unit/data/commonHttpHeaders.json +1 -0
  61. package/test/unit/data/defaultConfig.json +23 -7
  62. package/test/unit/inboundApi/handlers.test.js +45 -14
  63. package/test/unit/index.test.js +95 -4
  64. package/test/unit/lib/model/AccountsModel.test.js +9 -6
  65. package/test/unit/lib/model/InboundTransfersModel.test.js +210 -30
  66. package/test/unit/lib/model/OutboundRequestToPayModel.test.js +1 -1
  67. package/test/unit/lib/model/OutboundRequestToPayTransferModel.test.js +3 -3
  68. package/test/unit/lib/model/OutboundTransfersModel.test.js +863 -158
  69. package/test/unit/lib/model/data/defaultConfig.json +25 -10
  70. package/test/unit/lib/model/data/mockArguments.json +97 -40
  71. package/test/unit/lib/model/data/payeeParty.json +13 -11
  72. package/test/unit/lib/model/data/quoteResponse.json +36 -25
  73. package/test/unit/lib/model/data/transferFulfil.json +5 -3
  74. package/src/lib/api/index.js +0 -12
  75. package/src/lib/randomphrase/index.js +0 -21
  76. package/src/lib/randomphrase/words.json +0 -3397
@@ -15,6 +15,7 @@ jest.mock('@mojaloop/sdk-standard-components');
15
15
  jest.mock('redis');
16
16
 
17
17
  const Cache = require('~/lib/cache');
18
+ const { MetricsClient } = require('~/lib/metrics');
18
19
  const Model = require('~/lib/model').OutboundTransfersModel;
19
20
  const PartiesModel = require('~/lib/model').PartiesModel;
20
21
 
@@ -28,7 +29,7 @@ const quoteResponseTemplate = require('./data/quoteResponse');
28
29
  const transferFulfil = require('./data/transferFulfil');
29
30
 
30
31
  const genPartyId = (party) => {
31
- const { partyIdType, partyIdentifier, partySubIdOrType } = party.party.partyIdInfo;
32
+ const { partyIdType, partyIdentifier, partySubIdOrType } = party.body.party.partyIdInfo;
32
33
  return PartiesModel.channelName({
33
34
  type: partyIdType,
34
35
  id: partyIdentifier,
@@ -38,6 +39,7 @@ const genPartyId = (party) => {
38
39
 
39
40
  // util function to simulate a party resolution subscription message on a cache client
40
41
  const emitPartyCacheMessage = (cache, party) => cache.publish(genPartyId(party), JSON.stringify(party));
42
+ const emitMultiPartiesCacheMessage = (cache, party) => cache.add(genPartyId(party), JSON.stringify(party));
41
43
 
42
44
  // util function to simulate a quote response subscription message on a cache client
43
45
  const emitQuoteResponseCacheMessage = (cache, quoteId, quoteResponse) => cache.publish(`qt_${quoteId}`, JSON.stringify(quoteResponse));
@@ -45,11 +47,16 @@ const emitQuoteResponseCacheMessage = (cache, quoteId, quoteResponse) => cache.p
45
47
  // util function to simulate a transfer fulfilment subscription message on a cache client
46
48
  const emitTransferFulfilCacheMessage = (cache, transferId, fulfil) => cache.publish(`tf_${transferId}`, JSON.stringify(fulfil));
47
49
 
50
+ const dummyRequestsModuleResponse = {
51
+ originalRequest: {}
52
+ };
53
+
48
54
  describe('outboundModel', () => {
49
55
  let quoteResponse;
50
56
  let config;
51
57
  let logger;
52
58
  let cache;
59
+ let metricsClient;
53
60
 
54
61
  /**
55
62
  *
@@ -71,13 +78,27 @@ describe('outboundModel', () => {
71
78
  config.rejectExpiredTransferFulfils = rejects.transferFulfils;
72
79
 
73
80
  // simulate a callback with the resolved party
74
- MojaloopRequests.__getParties = jest.fn(() => emitPartyCacheMessage(cache, payeeParty));
81
+ MojaloopRequests.__getParties = jest.fn(() => {
82
+ emitPartyCacheMessage(cache, payeeParty);
83
+ return {
84
+ originalRequest: {
85
+ headers: [],
86
+ body: {},
87
+ }
88
+ };
89
+ });
75
90
 
76
91
  // simulate a delayed callback with the quote response
77
92
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
78
93
  setTimeout(() => {
79
94
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
80
95
  }, delays.requestQuotes ? delays.requestQuotes * 1000 : 0);
96
+ return {
97
+ originalRequest: {
98
+ headers: [],
99
+ body: postQuotesBody,
100
+ }
101
+ };
81
102
  });
82
103
 
83
104
  // simulate a delayed callback with the transfer fulfilment
@@ -85,12 +106,19 @@ describe('outboundModel', () => {
85
106
  setTimeout(() => {
86
107
  emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
87
108
  }, delays.prepareTransfer ? delays.prepareTransfer * 1000 : 0);
109
+ return {
110
+ originalRequest: {
111
+ headers: [],
112
+ body: postTransfersBody,
113
+ }
114
+ };
88
115
  });
89
116
 
90
117
  const model = new Model({
91
118
  ...config,
92
119
  cache,
93
120
  logger,
121
+ metricsClient,
94
122
  });
95
123
 
96
124
  await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
@@ -113,17 +141,27 @@ describe('outboundModel', () => {
113
141
  beforeAll(async () => {
114
142
  logger = new Logger.Logger({ context: { app: 'outbound-model-unit-tests-cache' }, stringify: () => '' });
115
143
  quoteResponse = JSON.parse(JSON.stringify(quoteResponseTemplate));
144
+ metricsClient = new MetricsClient();
116
145
  });
117
146
 
118
147
  beforeEach(async () => {
119
148
  config = JSON.parse(JSON.stringify(defaultConfig));
120
- MojaloopRequests.__postParticipants = jest.fn(() => Promise.resolve());
121
- MojaloopRequests.__getParties = jest.fn(() => Promise.resolve());
122
- MojaloopRequests.__postQuotes = jest.fn(() => Promise.resolve());
123
- MojaloopRequests.__putQuotes = jest.fn(() => Promise.resolve());
124
- MojaloopRequests.__putQuotesError = jest.fn(() => Promise.resolve());
125
- MojaloopRequests.__postTransfers = jest.fn(() => Promise.resolve());
126
-
149
+ MojaloopRequests.__postParticipants = jest.fn(() => Promise.resolve(dummyRequestsModuleResponse));
150
+ MojaloopRequests.__getParties = jest.fn(() => Promise.resolve(dummyRequestsModuleResponse));
151
+ MojaloopRequests.__putQuotes = jest.fn(() => Promise.resolve(dummyRequestsModuleResponse));
152
+ MojaloopRequests.__putQuotesError = jest.fn(() => Promise.resolve(dummyRequestsModuleResponse));
153
+ MojaloopRequests.__postQuotes = jest.fn((body) => Promise.resolve({
154
+ originalRequest: {
155
+ headers: [],
156
+ body: body,
157
+ }
158
+ }));
159
+ MojaloopRequests.__postTransfers = jest.fn((body) => Promise.resolve({
160
+ originalRequest: {
161
+ headers: [],
162
+ body: body,
163
+ }
164
+ }));
127
165
  cache = new Cache({
128
166
  host: 'dummycachehost',
129
167
  port: 1234,
@@ -140,6 +178,7 @@ describe('outboundModel', () => {
140
178
  const model = new Model({
141
179
  cache,
142
180
  logger,
181
+ metricsClient,
143
182
  ...config,
144
183
  });
145
184
 
@@ -147,14 +186,13 @@ describe('outboundModel', () => {
147
186
  expect(StateMachine.__instance.state).toBe('start');
148
187
  });
149
188
 
150
-
151
189
  test('executes all three transfer stages without halting when AUTO_ACCEPT_PARTY and AUTO_ACCEPT_QUOTES are true', async () => {
152
190
  config.autoAcceptParty = true;
153
191
  config.autoAcceptQuotes = true;
154
192
 
155
193
  MojaloopRequests.__getParties = jest.fn(() => {
156
194
  emitPartyCacheMessage(cache, payeeParty);
157
- return Promise.resolve();
195
+ return Promise.resolve(dummyRequestsModuleResponse);
158
196
  });
159
197
 
160
198
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
@@ -168,13 +206,13 @@ describe('outboundModel', () => {
168
206
 
169
207
  // simulate a callback with the quote response
170
208
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
171
- return Promise.resolve();
209
+ return Promise.resolve(dummyRequestsModuleResponse);
172
210
  });
173
211
 
174
212
  MojaloopRequests.__postTransfers = jest.fn((postTransfersBody, destFspId) => {
175
213
  //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
176
214
  // set as the destination FSPID, picked up from the header's value `fspiop-source`
177
- expect(model.data.quoteResponseSource).toBe(quoteResponse.headers['fspiop-source']);
215
+ expect(model.data.quoteResponseSource).toBe(quoteResponse.data.headers['fspiop-source']);
178
216
 
179
217
  const extensionList = postTransfersBody.extensionList.extension;
180
218
  expect(extensionList).toBeTruthy();
@@ -182,98 +220,667 @@ describe('outboundModel', () => {
182
220
  expect(extensionList[0]).toEqual({ key: 'tkey1', value: 'tvalue1' });
183
221
  expect(extensionList[1]).toEqual({ key: 'tkey2', value: 'tvalue2' });
184
222
 
185
- expect(destFspId).toBe(quoteResponse.headers['fspiop-source']);
186
- expect(model.data.to.fspId).toBe(payeeParty.party.partyIdInfo.fspId);
187
- expect(quoteResponse.headers['fspiop-source']).not.toBe(model.data.to.fspId);
223
+ expect(destFspId).toBe(quoteResponse.data.headers['fspiop-source']);
224
+ expect(model.data.to.fspId).toBe(payeeParty.body.party.partyIdInfo.fspId);
225
+ expect(quoteResponse.data.headers['fspiop-source']).not.toBe(model.data.to.fspId);
188
226
 
189
227
  // simulate a callback with the transfer fulfilment
190
228
  emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
229
+ return Promise.resolve(dummyRequestsModuleResponse);
230
+ });
231
+
232
+ const model = new Model({
233
+ cache,
234
+ logger,
235
+ metricsClient,
236
+ ...config,
237
+ });
238
+
239
+ await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
240
+
241
+ expect(StateMachine.__instance.state).toBe('start');
242
+
243
+ // start the model running
244
+ const result = await model.run();
245
+
246
+ expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1);
247
+ expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1);
248
+ expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
249
+
250
+ // make sure no PATCH was sent as we did not set config or receive a RESERVED state
251
+ expect(MojaloopRequests.__patchTransfers).toHaveBeenCalledTimes(0);
252
+
253
+ // check we stopped at payeeResolved state
254
+ expect(result.currentState).toBe('COMPLETED');
255
+ expect(StateMachine.__instance.state).toBe('succeeded');
256
+ });
257
+
258
+ test('sends a PATCH /transfers/{transferId} request to payee DFSP when SEND_FINAL_NOTIFICATION_IF_REQUESTED is true', async () => {
259
+ config.autoAcceptParty = true;
260
+ config.autoAcceptQuotes = true;
261
+ config.sendFinalNotificationIfRequested = true;
262
+ MojaloopRequests.__getParties = jest.fn(() => {
263
+ emitPartyCacheMessage(cache, payeeParty);
264
+ return Promise.resolve(dummyRequestsModuleResponse);
265
+ });
266
+ MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
267
+ // ensure that the `MojaloopRequests.postQuotes` method has been called with correct arguments
268
+ // including extension list
269
+ const extensionList = postQuotesBody.extensionList.extension;
270
+ expect(extensionList).toBeTruthy();
271
+ expect(extensionList.length).toBe(2);
272
+ expect(extensionList[0]).toEqual({ key: 'qkey1', value: 'qvalue1' });
273
+ expect(extensionList[1]).toEqual({ key: 'qkey2', value: 'qvalue2' });
274
+ // simulate a callback with the quote response
275
+ emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
276
+ return Promise.resolve(dummyRequestsModuleResponse);
277
+ });
278
+ const pb = JSON.parse(JSON.stringify(transferFulfil));
279
+ pb.data.body.transferState = 'RESERVED';
280
+ MojaloopRequests.__postTransfers = jest.fn((postTransfersBody, destFspId) => {
281
+ //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
282
+ // set as the destination FSPID, picked up from the header's value `fspiop-source`
283
+ expect(model.data.quoteResponseSource).toBe(quoteResponse.data.headers['fspiop-source']);
284
+ const extensionList = postTransfersBody.extensionList.extension;
285
+ expect(extensionList).toBeTruthy();
286
+ expect(extensionList.length).toBe(2);
287
+ expect(extensionList[0]).toEqual({ key: 'tkey1', value: 'tvalue1' });
288
+ expect(extensionList[1]).toEqual({ key: 'tkey2', value: 'tvalue2' });
289
+ expect(destFspId).toBe(quoteResponse.data.headers['fspiop-source']);
290
+ expect(model.data.to.fspId).toBe(payeeParty.body.party.partyIdInfo.fspId);
291
+ expect(quoteResponse.data.headers['fspiop-source']).not.toBe(model.data.to.fspId);
292
+ // simulate a callback with the transfer fulfilment
293
+ emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, pb);
294
+ return Promise.resolve(dummyRequestsModuleResponse);
295
+ });
296
+ const model = new Model({
297
+ cache,
298
+ logger,
299
+ metricsClient,
300
+ ...config,
301
+ });
302
+ await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
303
+ expect(StateMachine.__instance.state).toBe('start');
304
+ // start the model running
305
+ const result = await model.run();
306
+ expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1);
307
+ expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1);
308
+ expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
309
+ expect(MojaloopRequests.__patchTransfers).toHaveBeenCalledTimes(1);
310
+ expect(MojaloopRequests.__patchTransfers.mock.calls[0][0]).toEqual(model.data.transferId);
311
+ expect(MojaloopRequests.__patchTransfers.mock.calls[0][1].transferState).toEqual('COMMITTED');
312
+ expect(MojaloopRequests.__patchTransfers.mock.calls[0][1].completedTimestamp).not.toBeUndefined();
313
+ expect(MojaloopRequests.__patchTransfers.mock.calls[0][2]).toEqual(quoteResponse.data.headers['fspiop-source']);
314
+
315
+
316
+ // check we stopped at payeeResolved state
317
+ expect(result.currentState).toBe('COMPLETED');
318
+ expect(StateMachine.__instance.state).toBe('succeeded');
319
+ });
320
+
321
+ test('uses quote response transfer amount for transfer prepare', async () => {
322
+ config.autoAcceptParty = true;
323
+ config.autoAcceptQuotes = true;
324
+
325
+ MojaloopRequests.__getParties = jest.fn(() => {
326
+ emitPartyCacheMessage(cache, payeeParty);
327
+ return Promise.resolve(dummyRequestsModuleResponse);
328
+ });
329
+
330
+ // change the the transfer amount and currency in the quote response
331
+ // so it is different to the initial request
332
+ quoteResponse.data.body.transferAmount = {
333
+ currency: 'XYZ',
334
+ amount: '9876543210'
335
+ };
336
+
337
+ expect(quoteResponse.data.body.transferAmount).not.toEqual({
338
+ amount: transferRequest.amount,
339
+ currency: transferRequest.currency
340
+ });
341
+
342
+ MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
343
+ // ensure that the `MojaloopRequests.postQuotes` method has been called with correct arguments
344
+ // including extension list
345
+ const extensionList = postQuotesBody.extensionList.extension;
346
+ expect(extensionList).toBeTruthy();
347
+ expect(extensionList.length).toBe(2);
348
+ expect(extensionList[0]).toEqual({ key: 'qkey1', value: 'qvalue1' });
349
+ expect(extensionList[1]).toEqual({ key: 'qkey2', value: 'qvalue2' });
350
+
351
+ // simulate a callback with the quote response
352
+ emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
353
+ return Promise.resolve(dummyRequestsModuleResponse);
354
+ });
355
+
356
+ MojaloopRequests.__postTransfers = jest.fn((postTransfersBody, destFspId) => {
357
+ //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
358
+ // set as the destination FSPID, picked up from the header's value `fspiop-source`
359
+ expect(model.data.quoteResponseSource).toBe(quoteResponse.data.headers['fspiop-source']);
360
+
361
+ const extensionList = postTransfersBody.extensionList.extension;
362
+ expect(extensionList).toBeTruthy();
363
+ expect(extensionList.length).toBe(2);
364
+ expect(extensionList[0]).toEqual({ key: 'tkey1', value: 'tvalue1' });
365
+ expect(extensionList[1]).toEqual({ key: 'tkey2', value: 'tvalue2' });
366
+
367
+ expect(destFspId).toBe(quoteResponse.data.headers['fspiop-source']);
368
+ expect(model.data.to.fspId).toBe(payeeParty.body.party.partyIdInfo.fspId);
369
+ expect(quoteResponse.data.headers['fspiop-source']).not.toBe(model.data.to.fspId);
370
+
371
+ expect(postTransfersBody.amount).toEqual(quoteResponse.data.body.transferAmount);
372
+
373
+ // simulate a callback with the transfer fulfilment
374
+ emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
375
+ return Promise.resolve(dummyRequestsModuleResponse);
376
+ });
377
+
378
+ const model = new Model({
379
+ cache,
380
+ logger,
381
+ metricsClient,
382
+ ...config,
383
+ });
384
+
385
+ await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
386
+
387
+ expect(StateMachine.__instance.state).toBe('start');
388
+
389
+ // start the model running
390
+ const result = await model.run();
391
+
392
+ expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1);
393
+ expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1);
394
+ expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
395
+
396
+ // make sure no PATCH was sent as we did not set config or receive a RESERVED state
397
+ expect(MojaloopRequests.__patchTransfers).toHaveBeenCalledTimes(0);
398
+
399
+ // check we stopped at payeeResolved state
400
+ expect(result.currentState).toBe('COMPLETED');
401
+ expect(StateMachine.__instance.state).toBe('succeeded');
402
+ });
403
+
404
+ test('test get transfer', async () => {
405
+ MojaloopRequests.__getTransfers = jest.fn((transferId) => {
406
+ emitTransferFulfilCacheMessage(cache, transferId, transferFulfil);
191
407
  return Promise.resolve();
192
408
  });
193
409
 
194
410
  const model = new Model({
195
411
  cache,
196
412
  logger,
413
+ metricsClient,
414
+ ...config,
415
+ });
416
+
417
+ const TRANSFER_ID = 'tx-id000011';
418
+
419
+ await model.initialize(JSON.parse(JSON.stringify({
420
+ ...transferRequest,
421
+ currentState: 'getTransfer',
422
+ transferId: TRANSFER_ID,
423
+ })));
424
+
425
+ expect(StateMachine.__instance.state).toBe('getTransfer');
426
+
427
+ // start the model running
428
+ const result = await model.run();
429
+
430
+ expect(MojaloopRequests.__getTransfers).toHaveBeenCalledTimes(1);
431
+
432
+ // check we stopped at payeeResolved state
433
+ expect(result.currentState).toBe('COMPLETED');
434
+ expect(StateMachine.__instance.state).toBe('succeeded');
435
+ });
436
+
437
+
438
+ test('resolves payee and halts when AUTO_ACCEPT_PARTY is false', async () => {
439
+ config.autoAcceptParty = false;
440
+
441
+ const model = new Model({
442
+ cache,
443
+ logger,
444
+ metricsClient,
445
+ ...config,
446
+ });
447
+
448
+ await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
449
+
450
+ expect(StateMachine.__instance.state).toBe('start');
451
+
452
+ // start the model running
453
+ const resultPromise = model.run();
454
+
455
+ // now we started the model running we simulate a callback with the resolved party
456
+ emitPartyCacheMessage(cache, payeeParty);
457
+
458
+ // wait for the model to reach a terminal state
459
+ const result = await resultPromise;
460
+
461
+ // check we stopped at payeeResolved state
462
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
463
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
464
+ });
465
+
466
+ test('uses payee party fspid as source header when supplied - resolving payee', async () => {
467
+ config.autoAcceptParty = false;
468
+
469
+ const model = new Model({
470
+ cache,
471
+ logger,
472
+ metricsClient,
473
+ ...config,
474
+ });
475
+
476
+ let req = JSON.parse(JSON.stringify(transferRequest));
477
+ const testFspId = 'TESTDESTFSPID';
478
+ req.to.fspId = testFspId;
479
+
480
+ await model.initialize(req);
481
+
482
+ expect(StateMachine.__instance.state).toBe('start');
483
+
484
+ // start the model running
485
+ const resultPromise = model.run();
486
+
487
+ // now we started the model running we simulate a callback with the resolved party
488
+ emitPartyCacheMessage(cache, payeeParty);
489
+
490
+ // wait for the model to reach a terminal state
491
+ const result = await resultPromise;
492
+
493
+ // check we stopped at payeeResolved state
494
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
495
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
496
+
497
+ // check getParties mojaloop requests method was called with the correct arguments
498
+ expect(MojaloopRequests.__getParties).toHaveBeenCalledWith(req.to.idType, req.to.idValue, req.to.idSubValue, testFspId);
499
+ });
500
+
501
+ test('resolves multiple payees and halts', async () => {
502
+ config.autoAcceptParty = false;
503
+ config.multiplePartiesResponse = true;
504
+ config.multiplePartiesResponseSeconds = 2;
505
+ const model = new Model({
506
+ cache,
507
+ logger,
508
+ metricsClient,
509
+ ...config,
510
+ });
511
+ await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
512
+ expect(StateMachine.__instance.state).toBe('start');
513
+ // start the model running
514
+ const resultPromise = model.run();
515
+ // now we started the model running we simulate a callback with the resolved party
516
+ const payeeParty1 = JSON.parse(JSON.stringify(payeeParty));
517
+ payeeParty1.body.party.partyIdInfo.fspId = 'FirstFspId';
518
+ await emitMultiPartiesCacheMessage(cache, payeeParty1);
519
+ const payeeParty2 = JSON.parse(JSON.stringify(payeeParty));
520
+ payeeParty2.body.party.partyIdInfo.fspId = 'SecondFspId';
521
+ await emitMultiPartiesCacheMessage(cache, payeeParty2);
522
+ // wait for the model to reach a terminal state
523
+ const result = await resultPromise;
524
+ // check we stopped at payeeResolved state
525
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
526
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
527
+ expect(result.to[0].fspId).toEqual('FirstFspId');
528
+ expect(result.to[1].fspId).toEqual('SecondFspId');
529
+ });
530
+
531
+ test('halts after resolving payee, resumes and then halts after receiving quote response when AUTO_ACCEPT_PARTY is false and AUTO_ACCEPT_QUOTES is false', async () => {
532
+ config.autoAcceptParty = false;
533
+ config.autoAcceptQuotes = false;
534
+
535
+ let model = new Model({
536
+ cache,
537
+ logger,
538
+ metricsClient,
539
+ ...config,
540
+ });
541
+
542
+ await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
543
+
544
+ expect(StateMachine.__instance.state).toBe('start');
545
+
546
+ // start the model running
547
+ let resultPromise = model.run();
548
+
549
+ // now we started the model running we simulate a callback with the resolved party
550
+ emitPartyCacheMessage(cache, payeeParty);
551
+
552
+ // wait for the model to reach a terminal state
553
+ let result = await resultPromise;
554
+
555
+ // check we stopped at payeeResolved state
556
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
557
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
558
+
559
+ const transferId = result.transferId;
560
+
561
+ // load a new model from the saved state
562
+ model = new Model({
563
+ cache,
564
+ logger,
565
+ metricsClient,
566
+ ...config,
567
+ });
568
+
569
+ await model.load(transferId);
570
+
571
+ // check the model loaded to the correct state
572
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
573
+
574
+ // now run the model again. this should trigger transition to quote request
575
+ resultPromise = model.run({ acceptParty: true });
576
+ // now we started the model running we simulate a callback with the quote response
577
+ cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
578
+
579
+ // wait for the model to reach a terminal state
580
+ result = await resultPromise;
581
+
582
+ // check we stopped at payeeResolved state
583
+ expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
584
+ expect(StateMachine.__instance.state).toBe('quoteReceived');
585
+ });
586
+
587
+ test('Allows change of transferAmount at accept party phase', async () => {
588
+ config.autoAcceptParty = false;
589
+ config.autoAcceptQuotes = false;
590
+
591
+ let model = new Model({
592
+ cache,
593
+ logger,
594
+ metricsClient,
595
+ ...config,
596
+ });
597
+
598
+ const req = JSON.parse(JSON.stringify(transferRequest));
599
+
600
+ // record the initial requested transfer amount
601
+ const initialAmount = req.amount;
602
+
603
+ await model.initialize(req);
604
+
605
+ expect(StateMachine.__instance.state).toBe('start');
606
+
607
+ // start the model running
608
+ let resultPromise = model.run();
609
+
610
+ // now we started the model running we simulate a callback with the resolved party
611
+ emitPartyCacheMessage(cache, payeeParty);
612
+
613
+ // wait for the model to reach a terminal state
614
+ let result = await resultPromise;
615
+
616
+ // check we stopped at payeeResolved state
617
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
618
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
619
+
620
+ expect(result.amount).toEqual(initialAmount);
621
+
622
+ const transferId = result.transferId;
623
+
624
+ // load a new model from the saved state
625
+ model = new Model({
626
+ cache,
627
+ logger,
628
+ metricsClient,
629
+ ...config,
630
+ });
631
+
632
+ await model.load(transferId);
633
+
634
+ // check the model loaded to the correct state
635
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
636
+
637
+ const resume = {
638
+ amount: 999,
639
+ acceptParty: true,
640
+ };
641
+
642
+ // now run the model again. this should trigger transition to quote request
643
+ resultPromise = model.run(resume);
644
+
645
+ // now we started the model running we simulate a callback with the quote response
646
+ cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
647
+
648
+ // wait for the model to reach a terminal state
649
+ result = await resultPromise;
650
+
651
+ // check we stopped at quoteReceived state
652
+ expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
653
+ expect(StateMachine.__instance.state).toBe('quoteReceived');
654
+
655
+ // check the accept party key got merged to the state
656
+ expect(result.acceptParty).toEqual(true);
657
+
658
+ // check the amount key got changed
659
+ expect(result.amount).toEqual(resume.amount);
660
+
661
+ // check the quote request amount is the NEW amount, not the initial amount
662
+ expect(result.quoteRequest.body.amount.amount).toStrictEqual(resume.amount);
663
+ expect(result.quoteRequest.body.amount.amount).not.toEqual(initialAmount);
664
+ });
665
+
666
+ test('Allows change of payee party at accept party phase (round-robin support)', async () => {
667
+ config.autoAcceptParty = false;
668
+ config.autoAcceptQuotes = false;
669
+
670
+ let model = new Model({
671
+ cache,
672
+ logger,
673
+ metricsClient,
674
+ ...config,
675
+ });
676
+
677
+ const req = JSON.parse(JSON.stringify(transferRequest));
678
+
679
+ // record the initial requested transfer amount
680
+ const initialAmount = req.amount;
681
+
682
+ await model.initialize(req);
683
+
684
+ expect(StateMachine.__instance.state).toBe('start');
685
+
686
+ // start the model running
687
+ let resultPromise = model.run();
688
+
689
+ // now we started the model running we simulate a callback with the resolved party
690
+ emitPartyCacheMessage(cache, payeeParty);
691
+
692
+ // wait for the model to reach a terminal state
693
+ let result = await resultPromise;
694
+
695
+ // check we stopped at payeeResolved state
696
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
697
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
698
+
699
+ expect(result.amount).toEqual(initialAmount);
700
+
701
+ const transferId = result.transferId;
702
+
703
+ // load a new model from the saved state
704
+ model = new Model({
705
+ cache,
706
+ logger,
707
+ metricsClient,
708
+ ...config,
709
+ });
710
+
711
+ await model.load(transferId);
712
+
713
+ // check the model loaded to the correct state
714
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
715
+
716
+ const newPayee = {
717
+ partyIdInfo: {
718
+ partySubIdOrType: undefined,
719
+ partyIdType: 'PASSPORT',
720
+ partyIdentifier: 'AAABBBCCCDDDEEE',
721
+ fspId: 'TESTDFSP'
722
+ }
723
+ };
724
+
725
+ const newPayeeInternal = {
726
+ idType: newPayee.partyIdInfo.partyIdType,
727
+ idValue: newPayee.partyIdInfo.partyIdentifier,
728
+ fspId: newPayee.partyIdInfo.fspId,
729
+ };
730
+
731
+ const resume = {
732
+ acceptParty: true,
733
+ to: newPayeeInternal,
734
+ };
735
+
736
+ // now run the model again. this should trigger transition to quote request
737
+ resultPromise = model.run(resume);
738
+
739
+ // now we started the model running we simulate a callback with the quote response
740
+ cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
741
+
742
+ // wait for the model to reach a terminal state
743
+ result = await resultPromise;
744
+
745
+ // check we stopped at quoteReceived state
746
+ expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
747
+ expect(StateMachine.__instance.state).toBe('quoteReceived');
748
+
749
+ // check the accept party key got merged to the state
750
+ expect(result.acceptParty).toEqual(true);
751
+
752
+ // check the "to" passed in to model resume is merged into the model state correctly
753
+ expect(result.to).toStrictEqual(newPayeeInternal);
754
+
755
+ // check the quote request payee party is the NEW one, not the initial one.
756
+ expect(result.quoteRequest.body.payee).toStrictEqual(newPayee);
757
+ });
758
+
759
+ test('Does not merge resume data keys into state that are not permitted', async () => {
760
+ config.autoAcceptParty = false;
761
+ config.autoAcceptQuotes = false;
762
+
763
+ let model = new Model({
764
+ cache,
765
+ logger,
766
+ metricsClient,
767
+ ...config,
768
+ });
769
+
770
+ const req = JSON.parse(JSON.stringify(transferRequest));
771
+
772
+ // record the initial requested transfer amount
773
+ const initialAmount = req.amount;
774
+
775
+ await model.initialize(req);
776
+
777
+ expect(StateMachine.__instance.state).toBe('start');
778
+
779
+ // start the model running
780
+ let resultPromise = model.run();
781
+
782
+ // now we started the model running we simulate a callback with the resolved party
783
+ emitPartyCacheMessage(cache, payeeParty);
784
+
785
+ // wait for the model to reach a terminal state
786
+ let result = await resultPromise;
787
+
788
+ // check we stopped at payeeResolved state
789
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
790
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
791
+
792
+ expect(result.amount).toEqual(initialAmount);
793
+
794
+ const transferId = result.transferId;
795
+
796
+ // load a new model from the saved state
797
+ model = new Model({
798
+ cache,
799
+ logger,
800
+ metricsClient,
197
801
  ...config,
198
802
  });
199
803
 
200
- await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
804
+ await model.load(transferId);
201
805
 
202
- expect(StateMachine.__instance.state).toBe('start');
806
+ // check the model loaded to the correct state
807
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
203
808
 
204
- // start the model running
205
- const result = await model.run();
809
+ const resume = {
810
+ amount: 999,
811
+ acceptParty: true,
812
+ someRandomKey: 'this key name is not permitted',
813
+ };
206
814
 
207
- expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1);
208
- expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1);
209
- expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
815
+ // now run the model again. this should trigger transition to quote request
816
+ resultPromise = model.run(resume);
210
817
 
211
- // check we stopped at payeeResolved state
212
- expect(result.currentState).toBe('COMPLETED');
213
- expect(StateMachine.__instance.state).toBe('succeeded');
214
- });
818
+ // now we started the model running we simulate a callback with the quote response
819
+ cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
215
820
 
821
+ // wait for the model to reach a terminal state
822
+ result = await resultPromise;
216
823
 
217
- test('uses quote response transfer amount for transfer prepare', async () => {
218
- config.autoAcceptParty = true;
219
- config.autoAcceptQuotes = true;
824
+ // check we stopped at quoteReceived state
825
+ expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
826
+ expect(StateMachine.__instance.state).toBe('quoteReceived');
220
827
 
221
- MojaloopRequests.__getParties = jest.fn(() => {
222
- emitPartyCacheMessage(cache, payeeParty);
223
- return Promise.resolve();
224
- });
828
+ // check the accept party key got merged to the state
829
+ expect(result.acceptParty).toEqual(true);
225
830
 
226
- // change the the transfer amount and currency in the quote response
227
- // so it is different to the initial request
228
- quoteResponse.data.transferAmount = {
229
- currency: 'XYZ',
230
- amount: '9876543210'
231
- };
831
+ // check the amount key got changed
832
+ expect(result.amount).toEqual(resume.amount);
232
833
 
233
- expect(quoteResponse.data.transferAmount).not.toEqual({
234
- amount: transferRequest.amount,
235
- currency: transferRequest.currency
236
- });
834
+ // check the quote request amount is the NEW amount, not the initial amount
835
+ expect(result.quoteRequest.body.amount.amount).toStrictEqual(resume.amount);
836
+ expect(result.quoteRequest.body.amount.amount).not.toEqual(initialAmount);
237
837
 
238
- MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
239
- // ensure that the `MojaloopRequests.postQuotes` method has been called with correct arguments
240
- // including extension list
241
- const extensionList = postQuotesBody.extensionList.extension;
242
- expect(extensionList).toBeTruthy();
243
- expect(extensionList.length).toBe(2);
244
- expect(extensionList[0]).toEqual({ key: 'qkey1', value: 'qvalue1' });
245
- expect(extensionList[1]).toEqual({ key: 'qkey2', value: 'qvalue2' });
838
+ // check that our disallowed key is not merged to the transfer state
839
+ expect(result.someRandomKey).toBeUndefined();
840
+ });
246
841
 
247
- // simulate a callback with the quote response
248
- emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
249
- return Promise.resolve();
842
+ test('skips resolving party when to.fspid is specified and skipPartyLookup is truthy', async () => {
843
+ config.autoAcceptParty = false;
844
+ config.autoAcceptQuotes = false;
845
+
846
+ let model = new Model({
847
+ cache,
848
+ logger,
849
+ metricsClient,
850
+ ...config,
250
851
  });
251
852
 
252
- MojaloopRequests.__postTransfers = jest.fn((postTransfersBody, destFspId) => {
253
- //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
254
- // set as the destination FSPID, picked up from the header's value `fspiop-source`
255
- expect(model.data.quoteResponseSource).toBe(quoteResponse.headers['fspiop-source']);
853
+ let req = JSON.parse(JSON.stringify(transferRequest));
854
+ const testFspId = 'TESTDESTFSPID';
855
+ req.to.fspId = testFspId;
856
+ req.skipPartyLookup = true;
256
857
 
257
- const extensionList = postTransfersBody.extensionList.extension;
258
- expect(extensionList).toBeTruthy();
259
- expect(extensionList.length).toBe(2);
260
- expect(extensionList[0]).toEqual({ key: 'tkey1', value: 'tvalue1' });
261
- expect(extensionList[1]).toEqual({ key: 'tkey2', value: 'tvalue2' });
858
+ await model.initialize(req);
262
859
 
263
- expect(destFspId).toBe(quoteResponse.headers['fspiop-source']);
264
- expect(model.data.to.fspId).toBe(payeeParty.party.partyIdInfo.fspId);
265
- expect(quoteResponse.headers['fspiop-source']).not.toBe(model.data.to.fspId);
860
+ expect(StateMachine.__instance.state).toBe('start');
266
861
 
267
- expect(postTransfersBody.amount).toEqual(quoteResponse.data.transferAmount);
862
+ // start the model running
863
+ let resultPromise = model.run();
268
864
 
269
- // simulate a callback with the transfer fulfilment
270
- emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
271
- return Promise.resolve();
272
- });
865
+ // now we started the model running we simulate a callback with the quote response
866
+ cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
273
867
 
274
- const model = new Model({
868
+ // wait for the model to reach a terminal state
869
+ let result = await resultPromise;
870
+
871
+ // check we stopped at quoteReceived state
872
+ expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
873
+ expect(StateMachine.__instance.state).toBe('quoteReceived');
874
+ });
875
+
876
+ test('aborts after party rejected by backend', async () => {
877
+ config.autoAcceptParty = false;
878
+ config.autoAcceptQuotes = false;
879
+
880
+ let model = new Model({
275
881
  cache,
276
882
  logger,
883
+ metricsClient,
277
884
  ...config,
278
885
  });
279
886
 
@@ -282,57 +889,50 @@ describe('outboundModel', () => {
282
889
  expect(StateMachine.__instance.state).toBe('start');
283
890
 
284
891
  // start the model running
285
- const result = await model.run();
892
+ let resultPromise = model.run();
286
893
 
287
- expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1);
288
- expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1);
289
- expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
894
+ // now we started the model running we simulate a callback with the resolved party
895
+ emitPartyCacheMessage(cache, payeeParty);
290
896
 
291
- // check we stopped at payeeResolved state
292
- expect(result.currentState).toBe('COMPLETED');
293
- expect(StateMachine.__instance.state).toBe('succeeded');
294
- });
897
+ // wait for the model to reach a terminal state
898
+ let result = await resultPromise;
295
899
 
900
+ // check we stopped at payeeResolved state
901
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
902
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
296
903
 
297
- test('test get transfer', async () => {
298
- MojaloopRequests.__getTransfers = jest.fn((transferId) => {
299
- emitTransferFulfilCacheMessage(cache, transferId, transferFulfil);
300
- return Promise.resolve();
301
- });
904
+ const transferId = result.transferId;
302
905
 
303
- const model = new Model({
906
+ // load a new model from the saved state
907
+ model = new Model({
304
908
  cache,
305
909
  logger,
910
+ metricsClient,
306
911
  ...config,
307
912
  });
308
913
 
309
- const TRANSFER_ID = 'tx-id000011';
310
-
311
- await model.initialize(JSON.parse(JSON.stringify({
312
- ...transferRequest,
313
- currentState: 'getTransfer',
314
- transferId: TRANSFER_ID,
315
- })));
316
-
317
- expect(StateMachine.__instance.state).toBe('getTransfer');
914
+ await model.load(transferId);
318
915
 
319
- // start the model running
320
- const result = await model.run();
916
+ // check the model loaded to the correct state
917
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
321
918
 
322
- expect(MojaloopRequests.__getTransfers).toHaveBeenCalledTimes(1);
919
+ // now run the model again with a party rejection. this should trigger transition to quote request
920
+ result = await model.run({ resume: { acceptParty: false } });
323
921
 
324
- // check we stopped at payeeResolved state
325
- expect(result.currentState).toBe('COMPLETED');
326
- expect(StateMachine.__instance.state).toBe('succeeded');
922
+ // check we stopped at quoteReceived state
923
+ expect(result.currentState).toBe('ABORTED');
924
+ expect(result.abortedReason).toBe('Payee rejected by backend');
925
+ expect(StateMachine.__instance.state).toBe('aborted');
327
926
  });
328
927
 
329
-
330
- test('resolves payee and halts when AUTO_ACCEPT_PARTY is false', async () => {
928
+ test('aborts after quote rejected by backend', async () => {
331
929
  config.autoAcceptParty = false;
930
+ config.autoAcceptQuotes = false;
332
931
 
333
- const model = new Model({
932
+ let model = new Model({
334
933
  cache,
335
934
  logger,
935
+ metricsClient,
336
936
  ...config,
337
937
  });
338
938
 
@@ -341,60 +941,88 @@ describe('outboundModel', () => {
341
941
  expect(StateMachine.__instance.state).toBe('start');
342
942
 
343
943
  // start the model running
344
- const resultPromise = model.run();
944
+ let resultPromise = model.run();
345
945
 
346
946
  // now we started the model running we simulate a callback with the resolved party
347
947
  emitPartyCacheMessage(cache, payeeParty);
348
948
 
349
949
  // wait for the model to reach a terminal state
350
- const result = await resultPromise;
950
+ let result = await resultPromise;
351
951
 
352
952
  // check we stopped at payeeResolved state
353
953
  expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
354
954
  expect(StateMachine.__instance.state).toBe('payeeResolved');
355
- });
356
955
 
357
- test('uses payee party fspid as source header when supplied - resolving payee', async () => {
358
- config.autoAcceptParty = false;
956
+ const transferId = result.transferId;
359
957
 
360
- const model = new Model({
958
+ // load a new model from the saved state
959
+ model = new Model({
361
960
  cache,
362
961
  logger,
962
+ metricsClient,
363
963
  ...config,
364
964
  });
365
965
 
366
- let req = JSON.parse(JSON.stringify(transferRequest));
367
- const testFspId = 'TESTDESTFSPID';
368
- req.to.fspId = testFspId;
369
-
370
- await model.initialize(req);
966
+ await model.load(transferId);
371
967
 
372
- expect(StateMachine.__instance.state).toBe('start');
968
+ // check the model loaded to the correct state
969
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
373
970
 
374
- // start the model running
375
- const resultPromise = model.run();
971
+ // now run the model again. this should trigger transition to quote request
972
+ resultPromise = model.run({ acceptParty: true });
376
973
 
377
- // now we started the model running we simulate a callback with the resolved party
378
- emitPartyCacheMessage(cache, payeeParty);
974
+ // now we started the model running we simulate a callback with the quote response
975
+ cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
379
976
 
380
- // wait for the model to reach a terminal state
381
- const result = await resultPromise;
977
+ // wait for the model to reach quote received
978
+ result = await resultPromise;
382
979
 
383
980
  // check we stopped at payeeResolved state
384
- expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
385
- expect(StateMachine.__instance.state).toBe('payeeResolved');
981
+ expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
982
+ expect(StateMachine.__instance.state).toBe('quoteReceived');
386
983
 
387
- // check getParties mojaloop requests method was called with the correct arguments
388
- expect(MojaloopRequests.__getParties).toHaveBeenCalledWith(req.to.idType, req.to.idValue, req.to.idSubValue, testFspId);
984
+ // now run the model again. this should trigger abort as the quote was not accepted
985
+ result = await model.run({ acceptQuote: false });
986
+
987
+ expect(result.currentState).toBe('ABORTED');
988
+ expect(result.abortedReason).toBe('Quote rejected by backend');
989
+ expect(StateMachine.__instance.state).toBe('aborted');
389
990
  });
390
991
 
391
- test('halts after resolving payee, resumes and then halts after receiving quote response when AUTO_ACCEPT_PARTY is false and AUTO_ACCEPT_QUOTES is false', async () => {
992
+ test('should handle unknown state with a meaningful error message', async () => {
993
+ config.autoAcceptParty = false;
994
+ config.autoAcceptQuotes = false;
995
+
996
+ let model = new Model({
997
+ cache,
998
+ logger,
999
+ metricsClient,
1000
+ ...config,
1001
+ });
1002
+
1003
+ await model.initialize(JSON.parse(JSON.stringify({
1004
+ ...transferRequest,
1005
+ currentState: 'abc'
1006
+ })));
1007
+
1008
+ expect(StateMachine.__instance.state).toBe('abc');
1009
+
1010
+ // start the model running
1011
+ let resultPromise = model.run();
1012
+
1013
+ // wait for the model to reach a terminal state
1014
+ let result = await resultPromise;
1015
+ expect(result).toBe(undefined);
1016
+ });
1017
+
1018
+ test('should handle subsequent put transfer calls incase of aborted transfer', async () => {
392
1019
  config.autoAcceptParty = false;
393
1020
  config.autoAcceptQuotes = false;
394
1021
 
395
1022
  let model = new Model({
396
1023
  cache,
397
1024
  logger,
1025
+ metricsClient,
398
1026
  ...config,
399
1027
  });
400
1028
 
@@ -421,6 +1049,7 @@ describe('outboundModel', () => {
421
1049
  model = new Model({
422
1050
  cache,
423
1051
  logger,
1052
+ metricsClient,
424
1053
  ...config,
425
1054
  });
426
1055
 
@@ -430,19 +1059,32 @@ describe('outboundModel', () => {
430
1059
  expect(StateMachine.__instance.state).toBe('payeeResolved');
431
1060
 
432
1061
  // now run the model again. this should trigger transition to quote request
433
- resultPromise = model.run();
1062
+ resultPromise = model.run({ acceptParty: true });
434
1063
 
435
1064
  // now we started the model running we simulate a callback with the quote response
436
1065
  cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
437
1066
 
438
- // wait for the model to reach a terminal state
1067
+ // wait for the model to reach quote received
439
1068
  result = await resultPromise;
440
1069
 
441
1070
  // check we stopped at payeeResolved state
442
1071
  expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
443
1072
  expect(StateMachine.__instance.state).toBe('quoteReceived');
444
- });
445
1073
 
1074
+ // now run the model again. this should trigger abort as the quote was not accepted
1075
+ result = await model.run({ acceptQuote: false });
1076
+
1077
+ expect(result.currentState).toBe('ABORTED');
1078
+ expect(result.abortedReason).toBe('Quote rejected by backend');
1079
+ expect(StateMachine.__instance.state).toBe('aborted');
1080
+
1081
+ // now run the model again. this should get the same result as previous one
1082
+ result = await model.run({ acceptQuote: false });
1083
+
1084
+ expect(result.currentState).toBe('ABORTED');
1085
+ expect(result.abortedReason).toBe('Quote rejected by backend');
1086
+ expect(StateMachine.__instance.state).toBe('aborted');
1087
+ });
446
1088
 
447
1089
  test('halts and resumes after parties and quotes stages when AUTO_ACCEPT_PARTY is false and AUTO_ACCEPT_QUOTES is false', async () => {
448
1090
  config.autoAcceptParty = false;
@@ -451,6 +1093,7 @@ describe('outboundModel', () => {
451
1093
  let model = new Model({
452
1094
  cache,
453
1095
  logger,
1096
+ metricsClient,
454
1097
  ...config,
455
1098
  });
456
1099
 
@@ -477,6 +1120,7 @@ describe('outboundModel', () => {
477
1120
  model = new Model({
478
1121
  cache,
479
1122
  logger,
1123
+ metricsClient,
480
1124
  ...config,
481
1125
  });
482
1126
 
@@ -486,7 +1130,7 @@ describe('outboundModel', () => {
486
1130
  expect(StateMachine.__instance.state).toBe('payeeResolved');
487
1131
 
488
1132
  // now run the model again. this should trigger transition to quote request
489
- resultPromise = model.run();
1133
+ resultPromise = model.run({ acceptParty: true });
490
1134
 
491
1135
  // now we started the model running we simulate a callback with the quote response
492
1136
  cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
@@ -502,6 +1146,7 @@ describe('outboundModel', () => {
502
1146
  model = new Model({
503
1147
  cache,
504
1148
  logger,
1149
+ metricsClient,
505
1150
  ...config,
506
1151
  });
507
1152
 
@@ -511,7 +1156,7 @@ describe('outboundModel', () => {
511
1156
  expect(StateMachine.__instance.state).toBe('quoteReceived');
512
1157
 
513
1158
  // now run the model again. this should trigger transition to quote request
514
- resultPromise = model.run();
1159
+ resultPromise = model.run({ acceptQuote: true });
515
1160
 
516
1161
  // now we started the model running we simulate a callback with the transfer fulfilment
517
1162
  cache.publish(`tf_${model.data.transferId}`, JSON.stringify(transferFulfil));
@@ -532,31 +1177,32 @@ describe('outboundModel', () => {
532
1177
  MojaloopRequests.__getParties = jest.fn(() => {
533
1178
  // simulate a callback with the resolved party
534
1179
  emitPartyCacheMessage(cache, payeeParty);
535
- return Promise.resolve();
1180
+ return Promise.resolve(dummyRequestsModuleResponse);
536
1181
  });
537
1182
 
538
1183
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
539
1184
  // simulate a callback with the quote response
540
1185
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
541
- return Promise.resolve();
1186
+ return Promise.resolve(dummyRequestsModuleResponse);
542
1187
  });
543
1188
 
544
1189
  MojaloopRequests.__postTransfers = jest.fn((postTransfersBody) => {
545
1190
  //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
546
1191
  // set as the destination FSPID, picked up from the header's value `fspiop-source`
547
- expect(model.data.quoteResponseSource).toBe(quoteResponse.headers['fspiop-source']);
1192
+ expect(model.data.quoteResponseSource).toBe(quoteResponse.data.headers['fspiop-source']);
548
1193
  expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
549
1194
  const payeeFsp = MojaloopRequests.__postTransfers.mock.calls[0][0].payeeFsp;
550
- expect(payeeFsp).toEqual(payeeParty.party.partyIdInfo.fspId);
1195
+ expect(payeeFsp).toEqual(payeeParty.body.party.partyIdInfo.fspId);
551
1196
 
552
1197
  // simulate a callback with the transfer fulfilment
553
1198
  emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
554
- return Promise.resolve();
1199
+ return Promise.resolve(dummyRequestsModuleResponse);
555
1200
  });
556
1201
 
557
1202
  const model = new Model({
558
1203
  cache,
559
1204
  logger,
1205
+ metricsClient,
560
1206
  ...config,
561
1207
  });
562
1208
 
@@ -583,31 +1229,32 @@ describe('outboundModel', () => {
583
1229
  MojaloopRequests.__getParties = jest.fn(() => {
584
1230
  // simulate a callback with the resolved party
585
1231
  emitPartyCacheMessage(cache, payeeParty);
586
- return Promise.resolve();
1232
+ return Promise.resolve(dummyRequestsModuleResponse);
587
1233
  });
588
1234
 
589
1235
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
590
1236
  // simulate a callback with the quote response
591
1237
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
592
- return Promise.resolve();
1238
+ return Promise.resolve(dummyRequestsModuleResponse);
593
1239
  });
594
1240
 
595
1241
  MojaloopRequests.__postTransfers = jest.fn((postTransfersBody) => {
596
1242
  //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
597
1243
  // set as the destination FSPID, picked up from the header's value `fspiop-source`
598
- expect(model.data.quoteResponseSource).toBe(quoteResponse.headers['fspiop-source']);
1244
+ expect(model.data.quoteResponseSource).toBe(quoteResponse.data.headers['fspiop-source']);
599
1245
  expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
600
1246
  const payeeFsp = MojaloopRequests.__postTransfers.mock.calls[0][0].payeeFsp;
601
- expect(payeeFsp).toEqual(quoteResponse.headers['fspiop-source']);
1247
+ expect(payeeFsp).toEqual(quoteResponse.data.headers['fspiop-source']);
602
1248
 
603
1249
  // simulate a callback with the transfer fulfilment
604
1250
  emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
605
- return Promise.resolve();
1251
+ return Promise.resolve(dummyRequestsModuleResponse);
606
1252
  });
607
1253
 
608
1254
  const model = new Model({
609
1255
  cache,
610
1256
  logger,
1257
+ metricsClient,
611
1258
  ...config,
612
1259
  });
613
1260
 
@@ -695,12 +1342,13 @@ describe('outboundModel', () => {
695
1342
  MojaloopRequests.__getParties = jest.fn(() => {
696
1343
  // simulate a callback with the resolved party
697
1344
  cache.publish(genPartyId(payeeParty), JSON.stringify(expectError));
698
- return Promise.resolve();
1345
+ return Promise.resolve(dummyRequestsModuleResponse);
699
1346
  });
700
1347
 
701
1348
  const model = new Model({
702
1349
  cache,
703
1350
  logger,
1351
+ metricsClient,
704
1352
  ...config,
705
1353
  });
706
1354
 
@@ -709,9 +1357,11 @@ describe('outboundModel', () => {
709
1357
  expect(StateMachine.__instance.state).toBe('start');
710
1358
 
711
1359
  const expectError = {
712
- errorInformation: {
713
- errorCode: '3204',
714
- errorDescription: 'Party not found'
1360
+ body: {
1361
+ errorInformation: {
1362
+ errorCode: '3204',
1363
+ errorDescription: 'Party not found'
1364
+ }
715
1365
  }
716
1366
  };
717
1367
 
@@ -724,7 +1374,7 @@ describe('outboundModel', () => {
724
1374
  expect(err.message.replace(/[ \n]/g,'')).toEqual(errMsg.replace(/[ \n]/g,''));
725
1375
  expect(err.transferState).toBeTruthy();
726
1376
  expect(err.transferState.lastError).toBeTruthy();
727
- expect(err.transferState.lastError.mojaloopError).toEqual(expectError);
1377
+ expect(err.transferState.lastError.mojaloopError).toEqual(expectError.body);
728
1378
  expect(err.transferState.lastError.transferState).toBe(undefined);
729
1379
  return;
730
1380
  }
@@ -751,18 +1401,19 @@ describe('outboundModel', () => {
751
1401
  MojaloopRequests.__getParties = jest.fn(() => {
752
1402
  // simulate a callback with the resolved party
753
1403
  emitPartyCacheMessage(cache, payeeParty);
754
- return Promise.resolve();
1404
+ return Promise.resolve(dummyRequestsModuleResponse);
755
1405
  });
756
1406
 
757
1407
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
758
1408
  // simulate a callback with the quote response
759
1409
  cache.publish(`qt_${postQuotesBody.quoteId}`, JSON.stringify(expectError));
760
- return Promise.resolve();
1410
+ return Promise.resolve(dummyRequestsModuleResponse);
761
1411
  });
762
1412
 
763
1413
  const model = new Model({
764
1414
  cache,
765
1415
  logger,
1416
+ metricsClient,
766
1417
  ...config,
767
1418
  });
768
1419
 
@@ -795,9 +1446,11 @@ describe('outboundModel', () => {
795
1446
  const expectError = {
796
1447
  type: 'transferError',
797
1448
  data: {
798
- errorInformation: {
799
- errorCode: '4001',
800
- errorDescription: 'Payer FSP insufficient liquidity'
1449
+ body: {
1450
+ errorInformation: {
1451
+ errorCode: '4001',
1452
+ errorDescription: 'Payer FSP insufficient liquidity'
1453
+ }
801
1454
  }
802
1455
  }
803
1456
  };
@@ -805,24 +1458,25 @@ describe('outboundModel', () => {
805
1458
  MojaloopRequests.__getParties = jest.fn(() => {
806
1459
  // simulate a callback with the resolved party
807
1460
  emitPartyCacheMessage(cache, payeeParty);
808
- return Promise.resolve();
1461
+ return Promise.resolve(dummyRequestsModuleResponse);
809
1462
  });
810
1463
 
811
1464
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
812
1465
  // simulate a callback with the quote response
813
1466
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
814
- return Promise.resolve();
1467
+ return Promise.resolve(dummyRequestsModuleResponse);
815
1468
  });
816
1469
 
817
1470
  MojaloopRequests.__postTransfers = jest.fn((postTransfersBody) => {
818
1471
  // simulate an error callback with the transfer fulfilment
819
1472
  cache.publish(`tf_${postTransfersBody.transferId}`, JSON.stringify(expectError));
820
- return Promise.resolve();
1473
+ return Promise.resolve(dummyRequestsModuleResponse);
821
1474
  });
822
1475
 
823
1476
  const model = new Model({
824
1477
  cache,
825
1478
  logger,
1479
+ metricsClient,
826
1480
  ...config,
827
1481
  });
828
1482
 
@@ -839,7 +1493,7 @@ describe('outboundModel', () => {
839
1493
  expect(err.message.replace(/[ \n]/g,'')).toEqual(errMsg.replace(/[ \n]/g,''));
840
1494
  expect(err.transferState).toBeTruthy();
841
1495
  expect(err.transferState.lastError).toBeTruthy();
842
- expect(err.transferState.lastError.mojaloopError).toEqual(expectError.data);
1496
+ expect(err.transferState.lastError.mojaloopError).toEqual(expectError.data.body);
843
1497
  expect(err.transferState.lastError.transferState).toBe(undefined);
844
1498
  return;
845
1499
  }
@@ -849,11 +1503,12 @@ describe('outboundModel', () => {
849
1503
 
850
1504
 
851
1505
  async function testTlsServer(enableTls) {
852
- config.tls.enabled = enableTls;
1506
+ config.outbound.tls.mutualTLS.enabled = enableTls;
853
1507
 
854
1508
  new Model({
855
1509
  cache,
856
1510
  logger,
1511
+ metricsClient,
857
1512
  ...config
858
1513
  });
859
1514
 
@@ -866,4 +1521,54 @@ describe('outboundModel', () => {
866
1521
 
867
1522
  test('Outbound server should use HTTP if outbound mTLS disabled', () =>
868
1523
  testTlsServer(false));
1524
+
1525
+ test('Outbound transfers model should record metrics', async () => {
1526
+ const metrics = metricsClient._prometheusRegister.metrics();
1527
+ expect(metrics).toBeTruthy();
1528
+
1529
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_party_lookup_request_count'));
1530
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_party_lookup_response_count'));
1531
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_quote_request_count'));
1532
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_quote_response_count'));
1533
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_transfer_prepare_count'));
1534
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_transfer_fulfil_response_count'));
1535
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_quote_request_latency'));
1536
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_transfer_latency'));
1537
+ expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_party_lookup_latency'));
1538
+ });
1539
+
1540
+ test('skips resolving party when to.fspid is specified and skipPartyLookup is truthy', async () => {
1541
+ config.autoAcceptParty = false;
1542
+ config.autoAcceptQuotes = false;
1543
+
1544
+ let model = new Model({
1545
+ cache,
1546
+ logger,
1547
+ metricsClient,
1548
+ ...config,
1549
+ });
1550
+
1551
+ let req = JSON.parse(JSON.stringify(transferRequest));
1552
+ const testFspId = 'TESTDESTFSPID';
1553
+ req.to.fspId = testFspId;
1554
+ req.skipPartyLookup = true;
1555
+
1556
+ await model.initialize(req);
1557
+
1558
+ expect(StateMachine.__instance.state).toBe('start');
1559
+
1560
+ // start the model running
1561
+ let resultPromise = model.run();
1562
+
1563
+ // now we started the model running we simulate a callback with the quote response
1564
+ cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
1565
+
1566
+ // wait for the model to reach a terminal state
1567
+ let result = await resultPromise;
1568
+
1569
+ // check we stopped at quoteReceived state
1570
+ expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
1571
+ expect(StateMachine.__instance.state).toBe('quoteReceived');
1572
+ });
1573
+
869
1574
  });