@mojaloop/sdk-scheme-adapter 12.3.0 → 13.0.2

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 (60) hide show
  1. package/.env.example +3 -0
  2. package/CHANGELOG.md +25 -0
  3. package/docker/ml-testing-toolkit/spec_files/api_definitions/fspiop_1.1/trigger_templates/transaction_request_followup.json +2 -2
  4. package/docker/ml-testing-toolkit/spec_files/rules_callback/default.json +7 -7
  5. package/docker/ml-testing-toolkit/spec_files/rules_response/default.json +16 -16
  6. package/docker/ml-testing-toolkit/spec_files/rules_response/default_pisp_rules.json +5 -5
  7. package/docker/ml-testing-toolkit/spec_files/rules_validation/default.json +10 -10
  8. package/package.json +3 -3
  9. package/src/InboundServer/handlers.js +114 -52
  10. package/src/OutboundServer/api.yaml +105 -32
  11. package/src/OutboundServer/api_interfaces/openapi.d.ts +46 -16
  12. package/src/OutboundServer/api_template/components/schemas/accountsResponse.yaml +9 -0
  13. package/src/OutboundServer/api_template/components/schemas/partiesByIdResponse.yaml +10 -3
  14. package/src/OutboundServer/api_template/components/schemas/quotesPostResponse.yaml +42 -34
  15. package/src/OutboundServer/api_template/components/schemas/simpleTransfersPostResponse.yaml +9 -2
  16. package/src/OutboundServer/api_template/components/schemas/transferRequest.yaml +3 -0
  17. package/src/OutboundServer/api_template/components/schemas/transferResponse.yaml +28 -2
  18. package/src/OutboundServer/api_template/components/schemas/transferStatusResponse.yaml +8 -1
  19. package/src/OutboundServer/handlers.js +1 -1
  20. package/src/config.js +1 -1
  21. package/src/lib/model/AccountsModel.js +13 -11
  22. package/src/lib/model/InboundTransfersModel.js +166 -24
  23. package/src/lib/model/OutboundRequestToPayModel.js +5 -6
  24. package/src/lib/model/OutboundRequestToPayTransferModel.js +2 -2
  25. package/src/lib/model/OutboundTransfersModel.js +261 -56
  26. package/src/lib/model/PartiesModel.js +15 -2
  27. package/src/lib/model/common/BackendError.js +28 -4
  28. package/src/lib/model/common/index.js +2 -1
  29. package/test/__mocks__/@mojaloop/sdk-standard-components.js +3 -2
  30. package/test/integration/lib/Outbound/parties.test.js +2 -0
  31. package/test/integration/lib/Outbound/quotes.test.js +2 -0
  32. package/test/integration/lib/Outbound/simpleTransfers.test.js +2 -0
  33. package/test/unit/InboundServer.test.js +9 -9
  34. package/test/unit/TestServer.test.js +11 -13
  35. package/test/unit/api/accounts/data/postAccountsErrorMojaloopResponse.json +11 -3
  36. package/test/unit/api/accounts/data/postAccountsSuccessResponse.json +14 -0
  37. package/test/unit/api/accounts/data/postAccountsSuccessResponseWithError1.json +13 -0
  38. package/test/unit/api/accounts/data/postAccountsSuccessResponseWithError2.json +18 -0
  39. package/test/unit/api/accounts/utils.js +15 -1
  40. package/test/unit/api/transfers/data/getTransfersCommittedResponse.json +18 -15
  41. package/test/unit/api/transfers/data/getTransfersErrorNotFound.json +1 -0
  42. package/test/unit/api/transfers/data/postTransfersErrorMojaloopResponse.json +9 -0
  43. package/test/unit/api/transfers/data/postTransfersErrorTimeoutResponse.json +1 -0
  44. package/test/unit/api/transfers/data/postTransfersSuccessResponse.json +74 -47
  45. package/test/unit/api/transfers/utils.js +85 -4
  46. package/test/unit/data/commonHttpHeaders.json +1 -0
  47. package/test/unit/inboundApi/handlers.test.js +45 -14
  48. package/test/unit/lib/model/AccountsModel.test.js +9 -6
  49. package/test/unit/lib/model/InboundTransfersModel.test.js +210 -30
  50. package/test/unit/lib/model/OutboundRequestToPayModel.test.js +1 -1
  51. package/test/unit/lib/model/OutboundRequestToPayTransferModel.test.js +3 -3
  52. package/test/unit/lib/model/OutboundTransfersModel.test.js +826 -157
  53. package/test/unit/lib/model/PartiesModel.test.js +13 -7
  54. package/test/unit/lib/model/QuotesModel.test.js +8 -2
  55. package/test/unit/lib/model/TransfersModel.test.js +8 -2
  56. package/test/unit/lib/model/data/defaultConfig.json +9 -9
  57. package/test/unit/lib/model/data/mockArguments.json +97 -40
  58. package/test/unit/lib/model/data/payeeParty.json +13 -11
  59. package/test/unit/lib/model/data/quoteResponse.json +36 -25
  60. package/test/unit/lib/model/data/transferFulfil.json +5 -3
@@ -29,7 +29,7 @@ const quoteResponseTemplate = require('./data/quoteResponse');
29
29
  const transferFulfil = require('./data/transferFulfil');
30
30
 
31
31
  const genPartyId = (party) => {
32
- const { partyIdType, partyIdentifier, partySubIdOrType } = party.party.partyIdInfo;
32
+ const { partyIdType, partyIdentifier, partySubIdOrType } = party.body.party.partyIdInfo;
33
33
  return PartiesModel.channelName({
34
34
  type: partyIdType,
35
35
  id: partyIdentifier,
@@ -39,6 +39,7 @@ const genPartyId = (party) => {
39
39
 
40
40
  // util function to simulate a party resolution subscription message on a cache client
41
41
  const emitPartyCacheMessage = (cache, party) => cache.publish(genPartyId(party), JSON.stringify(party));
42
+ const emitMultiPartiesCacheMessage = (cache, party) => cache.add(genPartyId(party), JSON.stringify(party));
42
43
 
43
44
  // util function to simulate a quote response subscription message on a cache client
44
45
  const emitQuoteResponseCacheMessage = (cache, quoteId, quoteResponse) => cache.publish(`qt_${quoteId}`, JSON.stringify(quoteResponse));
@@ -46,6 +47,10 @@ const emitQuoteResponseCacheMessage = (cache, quoteId, quoteResponse) => cache.p
46
47
  // util function to simulate a transfer fulfilment subscription message on a cache client
47
48
  const emitTransferFulfilCacheMessage = (cache, transferId, fulfil) => cache.publish(`tf_${transferId}`, JSON.stringify(fulfil));
48
49
 
50
+ const dummyRequestsModuleResponse = {
51
+ originalRequest: {}
52
+ };
53
+
49
54
  describe('outboundModel', () => {
50
55
  let quoteResponse;
51
56
  let config;
@@ -73,13 +78,27 @@ describe('outboundModel', () => {
73
78
  config.rejectExpiredTransferFulfils = rejects.transferFulfils;
74
79
 
75
80
  // simulate a callback with the resolved party
76
- 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
+ });
77
90
 
78
91
  // simulate a delayed callback with the quote response
79
92
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
80
93
  setTimeout(() => {
81
94
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
82
95
  }, delays.requestQuotes ? delays.requestQuotes * 1000 : 0);
96
+ return {
97
+ originalRequest: {
98
+ headers: [],
99
+ body: postQuotesBody,
100
+ }
101
+ };
83
102
  });
84
103
 
85
104
  // simulate a delayed callback with the transfer fulfilment
@@ -87,6 +106,12 @@ describe('outboundModel', () => {
87
106
  setTimeout(() => {
88
107
  emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
89
108
  }, delays.prepareTransfer ? delays.prepareTransfer * 1000 : 0);
109
+ return {
110
+ originalRequest: {
111
+ headers: [],
112
+ body: postTransfersBody,
113
+ }
114
+ };
90
115
  });
91
116
 
92
117
  const model = new Model({
@@ -121,13 +146,22 @@ describe('outboundModel', () => {
121
146
 
122
147
  beforeEach(async () => {
123
148
  config = JSON.parse(JSON.stringify(defaultConfig));
124
- MojaloopRequests.__postParticipants = jest.fn(() => Promise.resolve());
125
- MojaloopRequests.__getParties = jest.fn(() => Promise.resolve());
126
- MojaloopRequests.__postQuotes = jest.fn(() => Promise.resolve());
127
- MojaloopRequests.__putQuotes = jest.fn(() => Promise.resolve());
128
- MojaloopRequests.__putQuotesError = jest.fn(() => Promise.resolve());
129
- MojaloopRequests.__postTransfers = jest.fn(() => Promise.resolve());
130
-
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
+ }));
131
165
  cache = new Cache({
132
166
  host: 'dummycachehost',
133
167
  port: 1234,
@@ -152,14 +186,13 @@ describe('outboundModel', () => {
152
186
  expect(StateMachine.__instance.state).toBe('start');
153
187
  });
154
188
 
155
-
156
189
  test('executes all three transfer stages without halting when AUTO_ACCEPT_PARTY and AUTO_ACCEPT_QUOTES are true', async () => {
157
190
  config.autoAcceptParty = true;
158
191
  config.autoAcceptQuotes = true;
159
192
 
160
193
  MojaloopRequests.__getParties = jest.fn(() => {
161
194
  emitPartyCacheMessage(cache, payeeParty);
162
- return Promise.resolve();
195
+ return Promise.resolve(dummyRequestsModuleResponse);
163
196
  });
164
197
 
165
198
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
@@ -173,13 +206,13 @@ describe('outboundModel', () => {
173
206
 
174
207
  // simulate a callback with the quote response
175
208
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
176
- return Promise.resolve();
209
+ return Promise.resolve(dummyRequestsModuleResponse);
177
210
  });
178
211
 
179
212
  MojaloopRequests.__postTransfers = jest.fn((postTransfersBody, destFspId) => {
180
213
  //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
181
214
  // set as the destination FSPID, picked up from the header's value `fspiop-source`
182
- expect(model.data.quoteResponseSource).toBe(quoteResponse.headers['fspiop-source']);
215
+ expect(model.data.quoteResponseSource).toBe(quoteResponse.data.headers['fspiop-source']);
183
216
 
184
217
  const extensionList = postTransfersBody.extensionList.extension;
185
218
  expect(extensionList).toBeTruthy();
@@ -187,12 +220,190 @@ describe('outboundModel', () => {
187
220
  expect(extensionList[0]).toEqual({ key: 'tkey1', value: 'tvalue1' });
188
221
  expect(extensionList[1]).toEqual({ key: 'tkey2', value: 'tvalue2' });
189
222
 
190
- expect(destFspId).toBe(quoteResponse.headers['fspiop-source']);
191
- expect(model.data.to.fspId).toBe(payeeParty.party.partyIdInfo.fspId);
192
- 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);
193
226
 
194
227
  // simulate a callback with the transfer fulfilment
195
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);
196
407
  return Promise.resolve();
197
408
  });
198
409
 
@@ -203,81 +414,470 @@ describe('outboundModel', () => {
203
414
  ...config,
204
415
  });
205
416
 
206
- await model.initialize(JSON.parse(JSON.stringify(transferRequest)));
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,
801
+ ...config,
802
+ });
803
+
804
+ await model.load(transferId);
207
805
 
208
- expect(StateMachine.__instance.state).toBe('start');
806
+ // check the model loaded to the correct state
807
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
209
808
 
210
- // start the model running
211
- const result = await model.run();
809
+ const resume = {
810
+ amount: 999,
811
+ acceptParty: true,
812
+ someRandomKey: 'this key name is not permitted',
813
+ };
212
814
 
213
- expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1);
214
- expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1);
215
- expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
815
+ // now run the model again. this should trigger transition to quote request
816
+ resultPromise = model.run(resume);
216
817
 
217
- // check we stopped at payeeResolved state
218
- expect(result.currentState).toBe('COMPLETED');
219
- expect(StateMachine.__instance.state).toBe('succeeded');
220
- });
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));
221
820
 
821
+ // wait for the model to reach a terminal state
822
+ result = await resultPromise;
222
823
 
223
- test('uses quote response transfer amount for transfer prepare', async () => {
224
- config.autoAcceptParty = true;
225
- 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');
226
827
 
227
- MojaloopRequests.__getParties = jest.fn(() => {
228
- emitPartyCacheMessage(cache, payeeParty);
229
- return Promise.resolve();
230
- });
828
+ // check the accept party key got merged to the state
829
+ expect(result.acceptParty).toEqual(true);
231
830
 
232
- // change the the transfer amount and currency in the quote response
233
- // so it is different to the initial request
234
- quoteResponse.data.transferAmount = {
235
- currency: 'XYZ',
236
- amount: '9876543210'
237
- };
831
+ // check the amount key got changed
832
+ expect(result.amount).toEqual(resume.amount);
238
833
 
239
- expect(quoteResponse.data.transferAmount).not.toEqual({
240
- amount: transferRequest.amount,
241
- currency: transferRequest.currency
242
- });
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);
243
837
 
244
- MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
245
- // ensure that the `MojaloopRequests.postQuotes` method has been called with correct arguments
246
- // including extension list
247
- const extensionList = postQuotesBody.extensionList.extension;
248
- expect(extensionList).toBeTruthy();
249
- expect(extensionList.length).toBe(2);
250
- expect(extensionList[0]).toEqual({ key: 'qkey1', value: 'qvalue1' });
251
- 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
+ });
252
841
 
253
- // simulate a callback with the quote response
254
- emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
255
- 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,
256
851
  });
257
852
 
258
- MojaloopRequests.__postTransfers = jest.fn((postTransfersBody, destFspId) => {
259
- //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
260
- // set as the destination FSPID, picked up from the header's value `fspiop-source`
261
- 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;
262
857
 
263
- const extensionList = postTransfersBody.extensionList.extension;
264
- expect(extensionList).toBeTruthy();
265
- expect(extensionList.length).toBe(2);
266
- expect(extensionList[0]).toEqual({ key: 'tkey1', value: 'tvalue1' });
267
- expect(extensionList[1]).toEqual({ key: 'tkey2', value: 'tvalue2' });
858
+ await model.initialize(req);
268
859
 
269
- expect(destFspId).toBe(quoteResponse.headers['fspiop-source']);
270
- expect(model.data.to.fspId).toBe(payeeParty.party.partyIdInfo.fspId);
271
- expect(quoteResponse.headers['fspiop-source']).not.toBe(model.data.to.fspId);
860
+ expect(StateMachine.__instance.state).toBe('start');
272
861
 
273
- expect(postTransfersBody.amount).toEqual(quoteResponse.data.transferAmount);
862
+ // start the model running
863
+ let resultPromise = model.run();
274
864
 
275
- // simulate a callback with the transfer fulfilment
276
- emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
277
- return Promise.resolve();
278
- });
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));
279
867
 
280
- 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({
281
881
  cache,
282
882
  logger,
283
883
  metricsClient,
@@ -289,56 +889,47 @@ describe('outboundModel', () => {
289
889
  expect(StateMachine.__instance.state).toBe('start');
290
890
 
291
891
  // start the model running
292
- const result = await model.run();
892
+ let resultPromise = model.run();
293
893
 
294
- expect(MojaloopRequests.__getParties).toHaveBeenCalledTimes(1);
295
- expect(MojaloopRequests.__postQuotes).toHaveBeenCalledTimes(1);
296
- expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
894
+ // now we started the model running we simulate a callback with the resolved party
895
+ emitPartyCacheMessage(cache, payeeParty);
297
896
 
298
- // check we stopped at payeeResolved state
299
- expect(result.currentState).toBe('COMPLETED');
300
- expect(StateMachine.__instance.state).toBe('succeeded');
301
- });
897
+ // wait for the model to reach a terminal state
898
+ let result = await resultPromise;
302
899
 
900
+ // check we stopped at payeeResolved state
901
+ expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
902
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
303
903
 
304
- test('test get transfer', async () => {
305
- MojaloopRequests.__getTransfers = jest.fn((transferId) => {
306
- emitTransferFulfilCacheMessage(cache, transferId, transferFulfil);
307
- return Promise.resolve();
308
- });
904
+ const transferId = result.transferId;
309
905
 
310
- const model = new Model({
906
+ // load a new model from the saved state
907
+ model = new Model({
311
908
  cache,
312
909
  logger,
313
910
  metricsClient,
314
911
  ...config,
315
912
  });
316
913
 
317
- const TRANSFER_ID = 'tx-id000011';
318
-
319
- await model.initialize(JSON.parse(JSON.stringify({
320
- ...transferRequest,
321
- currentState: 'getTransfer',
322
- transferId: TRANSFER_ID,
323
- })));
324
-
325
- expect(StateMachine.__instance.state).toBe('getTransfer');
914
+ await model.load(transferId);
326
915
 
327
- // start the model running
328
- const result = await model.run();
916
+ // check the model loaded to the correct state
917
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
329
918
 
330
- 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 } });
331
921
 
332
- // check we stopped at payeeResolved state
333
- expect(result.currentState).toBe('COMPLETED');
334
- 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');
335
926
  });
336
927
 
337
-
338
- test('resolves payee and halts when AUTO_ACCEPT_PARTY is false', async () => {
928
+ test('aborts after quote rejected by backend', async () => {
339
929
  config.autoAcceptParty = false;
930
+ config.autoAcceptQuotes = false;
340
931
 
341
- const model = new Model({
932
+ let model = new Model({
342
933
  cache,
343
934
  logger,
344
935
  metricsClient,
@@ -350,55 +941,81 @@ describe('outboundModel', () => {
350
941
  expect(StateMachine.__instance.state).toBe('start');
351
942
 
352
943
  // start the model running
353
- const resultPromise = model.run();
944
+ let resultPromise = model.run();
354
945
 
355
946
  // now we started the model running we simulate a callback with the resolved party
356
947
  emitPartyCacheMessage(cache, payeeParty);
357
948
 
358
949
  // wait for the model to reach a terminal state
359
- const result = await resultPromise;
950
+ let result = await resultPromise;
360
951
 
361
952
  // check we stopped at payeeResolved state
362
953
  expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
363
954
  expect(StateMachine.__instance.state).toBe('payeeResolved');
364
- });
365
955
 
366
- test('uses payee party fspid as source header when supplied - resolving payee', async () => {
367
- config.autoAcceptParty = false;
956
+ const transferId = result.transferId;
368
957
 
369
- const model = new Model({
958
+ // load a new model from the saved state
959
+ model = new Model({
370
960
  cache,
371
961
  logger,
372
962
  metricsClient,
373
963
  ...config,
374
964
  });
375
965
 
376
- let req = JSON.parse(JSON.stringify(transferRequest));
377
- const testFspId = 'TESTDESTFSPID';
378
- req.to.fspId = testFspId;
379
-
380
- await model.initialize(req);
966
+ await model.load(transferId);
381
967
 
382
- expect(StateMachine.__instance.state).toBe('start');
968
+ // check the model loaded to the correct state
969
+ expect(StateMachine.__instance.state).toBe('payeeResolved');
383
970
 
384
- // start the model running
385
- const resultPromise = model.run();
971
+ // now run the model again. this should trigger transition to quote request
972
+ resultPromise = model.run({ acceptParty: true });
386
973
 
387
- // now we started the model running we simulate a callback with the resolved party
388
- 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));
389
976
 
390
- // wait for the model to reach a terminal state
391
- const result = await resultPromise;
977
+ // wait for the model to reach quote received
978
+ result = await resultPromise;
392
979
 
393
980
  // check we stopped at payeeResolved state
394
- expect(result.currentState).toBe('WAITING_FOR_PARTY_ACCEPTANCE');
395
- expect(StateMachine.__instance.state).toBe('payeeResolved');
981
+ expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
982
+ expect(StateMachine.__instance.state).toBe('quoteReceived');
396
983
 
397
- // check getParties mojaloop requests method was called with the correct arguments
398
- 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');
399
990
  });
400
991
 
401
- 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 () => {
402
1019
  config.autoAcceptParty = false;
403
1020
  config.autoAcceptQuotes = false;
404
1021
 
@@ -442,19 +1059,32 @@ describe('outboundModel', () => {
442
1059
  expect(StateMachine.__instance.state).toBe('payeeResolved');
443
1060
 
444
1061
  // now run the model again. this should trigger transition to quote request
445
- resultPromise = model.run();
1062
+ resultPromise = model.run({ acceptParty: true });
446
1063
 
447
1064
  // now we started the model running we simulate a callback with the quote response
448
1065
  cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
449
1066
 
450
- // wait for the model to reach a terminal state
1067
+ // wait for the model to reach quote received
451
1068
  result = await resultPromise;
452
1069
 
453
1070
  // check we stopped at payeeResolved state
454
1071
  expect(result.currentState).toBe('WAITING_FOR_QUOTE_ACCEPTANCE');
455
1072
  expect(StateMachine.__instance.state).toBe('quoteReceived');
456
- });
457
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
+ });
458
1088
 
459
1089
  test('halts and resumes after parties and quotes stages when AUTO_ACCEPT_PARTY is false and AUTO_ACCEPT_QUOTES is false', async () => {
460
1090
  config.autoAcceptParty = false;
@@ -500,7 +1130,7 @@ describe('outboundModel', () => {
500
1130
  expect(StateMachine.__instance.state).toBe('payeeResolved');
501
1131
 
502
1132
  // now run the model again. this should trigger transition to quote request
503
- resultPromise = model.run();
1133
+ resultPromise = model.run({ acceptParty: true });
504
1134
 
505
1135
  // now we started the model running we simulate a callback with the quote response
506
1136
  cache.publish(`qt_${model.data.quoteId}`, JSON.stringify(quoteResponse));
@@ -526,7 +1156,7 @@ describe('outboundModel', () => {
526
1156
  expect(StateMachine.__instance.state).toBe('quoteReceived');
527
1157
 
528
1158
  // now run the model again. this should trigger transition to quote request
529
- resultPromise = model.run();
1159
+ resultPromise = model.run({ acceptQuote: true });
530
1160
 
531
1161
  // now we started the model running we simulate a callback with the transfer fulfilment
532
1162
  cache.publish(`tf_${model.data.transferId}`, JSON.stringify(transferFulfil));
@@ -547,26 +1177,26 @@ describe('outboundModel', () => {
547
1177
  MojaloopRequests.__getParties = jest.fn(() => {
548
1178
  // simulate a callback with the resolved party
549
1179
  emitPartyCacheMessage(cache, payeeParty);
550
- return Promise.resolve();
1180
+ return Promise.resolve(dummyRequestsModuleResponse);
551
1181
  });
552
1182
 
553
1183
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
554
1184
  // simulate a callback with the quote response
555
1185
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
556
- return Promise.resolve();
1186
+ return Promise.resolve(dummyRequestsModuleResponse);
557
1187
  });
558
1188
 
559
1189
  MojaloopRequests.__postTransfers = jest.fn((postTransfersBody) => {
560
1190
  //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
561
1191
  // set as the destination FSPID, picked up from the header's value `fspiop-source`
562
- expect(model.data.quoteResponseSource).toBe(quoteResponse.headers['fspiop-source']);
1192
+ expect(model.data.quoteResponseSource).toBe(quoteResponse.data.headers['fspiop-source']);
563
1193
  expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
564
1194
  const payeeFsp = MojaloopRequests.__postTransfers.mock.calls[0][0].payeeFsp;
565
- expect(payeeFsp).toEqual(payeeParty.party.partyIdInfo.fspId);
1195
+ expect(payeeFsp).toEqual(payeeParty.body.party.partyIdInfo.fspId);
566
1196
 
567
1197
  // simulate a callback with the transfer fulfilment
568
1198
  emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
569
- return Promise.resolve();
1199
+ return Promise.resolve(dummyRequestsModuleResponse);
570
1200
  });
571
1201
 
572
1202
  const model = new Model({
@@ -599,26 +1229,26 @@ describe('outboundModel', () => {
599
1229
  MojaloopRequests.__getParties = jest.fn(() => {
600
1230
  // simulate a callback with the resolved party
601
1231
  emitPartyCacheMessage(cache, payeeParty);
602
- return Promise.resolve();
1232
+ return Promise.resolve(dummyRequestsModuleResponse);
603
1233
  });
604
1234
 
605
1235
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
606
1236
  // simulate a callback with the quote response
607
1237
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
608
- return Promise.resolve();
1238
+ return Promise.resolve(dummyRequestsModuleResponse);
609
1239
  });
610
1240
 
611
1241
  MojaloopRequests.__postTransfers = jest.fn((postTransfersBody) => {
612
1242
  //ensure that the `MojaloopRequests.postTransfers` method has been called with the correct arguments
613
1243
  // set as the destination FSPID, picked up from the header's value `fspiop-source`
614
- expect(model.data.quoteResponseSource).toBe(quoteResponse.headers['fspiop-source']);
1244
+ expect(model.data.quoteResponseSource).toBe(quoteResponse.data.headers['fspiop-source']);
615
1245
  expect(MojaloopRequests.__postTransfers).toHaveBeenCalledTimes(1);
616
1246
  const payeeFsp = MojaloopRequests.__postTransfers.mock.calls[0][0].payeeFsp;
617
- expect(payeeFsp).toEqual(quoteResponse.headers['fspiop-source']);
1247
+ expect(payeeFsp).toEqual(quoteResponse.data.headers['fspiop-source']);
618
1248
 
619
1249
  // simulate a callback with the transfer fulfilment
620
1250
  emitTransferFulfilCacheMessage(cache, postTransfersBody.transferId, transferFulfil);
621
- return Promise.resolve();
1251
+ return Promise.resolve(dummyRequestsModuleResponse);
622
1252
  });
623
1253
 
624
1254
  const model = new Model({
@@ -712,7 +1342,7 @@ describe('outboundModel', () => {
712
1342
  MojaloopRequests.__getParties = jest.fn(() => {
713
1343
  // simulate a callback with the resolved party
714
1344
  cache.publish(genPartyId(payeeParty), JSON.stringify(expectError));
715
- return Promise.resolve();
1345
+ return Promise.resolve(dummyRequestsModuleResponse);
716
1346
  });
717
1347
 
718
1348
  const model = new Model({
@@ -727,9 +1357,11 @@ describe('outboundModel', () => {
727
1357
  expect(StateMachine.__instance.state).toBe('start');
728
1358
 
729
1359
  const expectError = {
730
- errorInformation: {
731
- errorCode: '3204',
732
- errorDescription: 'Party not found'
1360
+ body: {
1361
+ errorInformation: {
1362
+ errorCode: '3204',
1363
+ errorDescription: 'Party not found'
1364
+ }
733
1365
  }
734
1366
  };
735
1367
 
@@ -742,7 +1374,7 @@ describe('outboundModel', () => {
742
1374
  expect(err.message.replace(/[ \n]/g,'')).toEqual(errMsg.replace(/[ \n]/g,''));
743
1375
  expect(err.transferState).toBeTruthy();
744
1376
  expect(err.transferState.lastError).toBeTruthy();
745
- expect(err.transferState.lastError.mojaloopError).toEqual(expectError);
1377
+ expect(err.transferState.lastError.mojaloopError).toEqual(expectError.body);
746
1378
  expect(err.transferState.lastError.transferState).toBe(undefined);
747
1379
  return;
748
1380
  }
@@ -769,13 +1401,13 @@ describe('outboundModel', () => {
769
1401
  MojaloopRequests.__getParties = jest.fn(() => {
770
1402
  // simulate a callback with the resolved party
771
1403
  emitPartyCacheMessage(cache, payeeParty);
772
- return Promise.resolve();
1404
+ return Promise.resolve(dummyRequestsModuleResponse);
773
1405
  });
774
1406
 
775
1407
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
776
1408
  // simulate a callback with the quote response
777
1409
  cache.publish(`qt_${postQuotesBody.quoteId}`, JSON.stringify(expectError));
778
- return Promise.resolve();
1410
+ return Promise.resolve(dummyRequestsModuleResponse);
779
1411
  });
780
1412
 
781
1413
  const model = new Model({
@@ -814,9 +1446,11 @@ describe('outboundModel', () => {
814
1446
  const expectError = {
815
1447
  type: 'transferError',
816
1448
  data: {
817
- errorInformation: {
818
- errorCode: '4001',
819
- errorDescription: 'Payer FSP insufficient liquidity'
1449
+ body: {
1450
+ errorInformation: {
1451
+ errorCode: '4001',
1452
+ errorDescription: 'Payer FSP insufficient liquidity'
1453
+ }
820
1454
  }
821
1455
  }
822
1456
  };
@@ -824,19 +1458,19 @@ describe('outboundModel', () => {
824
1458
  MojaloopRequests.__getParties = jest.fn(() => {
825
1459
  // simulate a callback with the resolved party
826
1460
  emitPartyCacheMessage(cache, payeeParty);
827
- return Promise.resolve();
1461
+ return Promise.resolve(dummyRequestsModuleResponse);
828
1462
  });
829
1463
 
830
1464
  MojaloopRequests.__postQuotes = jest.fn((postQuotesBody) => {
831
1465
  // simulate a callback with the quote response
832
1466
  emitQuoteResponseCacheMessage(cache, postQuotesBody.quoteId, quoteResponse);
833
- return Promise.resolve();
1467
+ return Promise.resolve(dummyRequestsModuleResponse);
834
1468
  });
835
1469
 
836
1470
  MojaloopRequests.__postTransfers = jest.fn((postTransfersBody) => {
837
1471
  // simulate an error callback with the transfer fulfilment
838
1472
  cache.publish(`tf_${postTransfersBody.transferId}`, JSON.stringify(expectError));
839
- return Promise.resolve();
1473
+ return Promise.resolve(dummyRequestsModuleResponse);
840
1474
  });
841
1475
 
842
1476
  const model = new Model({
@@ -859,7 +1493,7 @@ describe('outboundModel', () => {
859
1493
  expect(err.message.replace(/[ \n]/g,'')).toEqual(errMsg.replace(/[ \n]/g,''));
860
1494
  expect(err.transferState).toBeTruthy();
861
1495
  expect(err.transferState.lastError).toBeTruthy();
862
- expect(err.transferState.lastError.mojaloopError).toEqual(expectError.data);
1496
+ expect(err.transferState.lastError.mojaloopError).toEqual(expectError.data.body);
863
1497
  expect(err.transferState.lastError.transferState).toBe(undefined);
864
1498
  return;
865
1499
  }
@@ -902,4 +1536,39 @@ describe('outboundModel', () => {
902
1536
  expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_transfer_latency'));
903
1537
  expect(metrics).toEqual(expect.stringContaining('mojaloop_connector_outbound_party_lookup_latency'));
904
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
+
905
1574
  });