diff --git a/.changeset/orange-balloons-poke.md b/.changeset/orange-balloons-poke.md new file mode 100644 index 000000000000..4d6423941f98 --- /dev/null +++ b/.changeset/orange-balloons-poke.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/live-wallet": patch +--- + +LIVE-WALLET - Test non imported accounts diff --git a/libs/live-wallet/src/walletsync/__tests__/modules/accounts.test.ts b/libs/live-wallet/src/walletsync/__tests__/modules/accounts.test.ts index 2728f99595a6..0b55e622f8b0 100644 --- a/libs/live-wallet/src/walletsync/__tests__/modules/accounts.test.ts +++ b/libs/live-wallet/src/walletsync/__tests__/modules/accounts.test.ts @@ -1,7 +1,7 @@ import timemachine from "timemachine"; import { of, throwError } from "rxjs"; import { Account } from "@ledgerhq/types-live"; -import manager from "../../modules/accounts"; +import manager, { NonImportedAccountInfo } from "../../modules/accounts"; import { WalletSyncDataManagerResolutionContext } from "../../types"; import { genAccount } from "@ledgerhq/coin-framework/mocks/account"; import { accountDataToAccount } from "../../../liveqr/cross"; @@ -11,9 +11,24 @@ timemachine.config({ }); const account1 = genAccount("live-wallet-1"); +const account1NonImported: NonImportedAccountInfo = { + id: account1.id, + attempts: 1, + attemptsLastTimestamp: new Date().getTime() - 1000, +}; const account2 = genAccount("live-wallet-2"); +const account2NonImported: NonImportedAccountInfo = { + id: account2.id, + attempts: 1, + attemptsLastTimestamp: new Date().getTime() - 1000, +}; const account3 = genAccount("live-wallet-3"); const account4unsupported = genAccount("live-wallet-4"); +const account4unsupportedNonImported: NonImportedAccountInfo = { + id: account4unsupported.id, + attempts: 1, + attemptsLastTimestamp: new Date().getTime() - 1000, +}; // we make this type static because the module is not supposed to change it over time! type AccountDescriptor = { @@ -335,4 +350,258 @@ describe("accountNames' WalletSyncDataManager", () => { const result = manager.applyUpdate(localData, diff.update); expect(result.list.map(l => l.id)).toEqual([account1, account2, account3].map(l => l.id)); }); + + it("should not have changes when there is no incoming state", async () => { + const localData = { list: [], nonImportedAccountInfos: [] }; + const latestState = null; + const incomingState = null; + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toEqual({ hasChanges: false }); + }); + + it("should ignore a non imported account that is now present in the localData", async () => { + const localData = { + list: [account1], + nonImportedAccountInfos: [account1NonImported], + }; + const latestState = [account1].map(convertAccountToDescriptor); + const incomingState = [account1].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: true, + update: { + added: [], + removed: [], + nonImportedAccountInfos: [], + }, + }); + }); + it("should ignore a non imported account that is not present in the incomingState anymore", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [account1NonImported], + }; + const latestState = [].map(convertAccountToDescriptor); + const incomingState = [].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: true, + update: { + added: [], + removed: [], + nonImportedAccountInfos: [], + }, + }); + }); + it("should retry importation of a non imported account if it has been long enough", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [ + { ...account1NonImported, attemptsLastTimestamp: new Date().getTime() - 60000 }, + ], + }; + const latestState = [account1].map(convertAccountToDescriptor); + const incomingState = [account1].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: true, + update: { + added: [placeholderAccountFromDescriptor(incomingState[0])], + removed: [], + nonImportedAccountInfos: [], + }, + }); + }); + it("should avoid importing duplicate of a non imported account if it has been removed from the latest state", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [ + { ...account1NonImported, attemptsLastTimestamp: new Date().getTime() - 60000 }, + ], + }; + const latestState = [].map(convertAccountToDescriptor); + const incomingState = [account1].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: true, + update: { + added: [placeholderAccountFromDescriptor(incomingState[0])], + removed: [], + nonImportedAccountInfos: [], + }, + }); + }); + it("should not retry importation of a non imported account if it has not been long enough", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [account1NonImported], + }; + const latestState = [account1].map(convertAccountToDescriptor); + const incomingState = [account1].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: false, + }); + }); + it("should not retry importation of a non imported account if it has not been long enough even if there is a new account", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [account1NonImported], + }; + const latestState = [account1].map(convertAccountToDescriptor); + const incomingState = [account1, account2].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: true, + update: { + added: [placeholderAccountFromDescriptor(incomingState[1])], + removed: [], + nonImportedAccountInfos: [account1NonImported], + }, + }); + }); + it("should retry importation of a non imported account if it has been long enough even if there is a new account", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [ + { ...account1NonImported, attemptsLastTimestamp: new Date().getTime() - 60000 }, + ], + }; + const latestState = [account1].map(convertAccountToDescriptor); + const incomingState = [account1, account2].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: true, + update: { + added: [ + placeholderAccountFromDescriptor(incomingState[1]), + placeholderAccountFromDescriptor(incomingState[0]), + ], + removed: [], + nonImportedAccountInfos: [], + }, + }); + }); + it("should retry importation of a non imported account if it has been long enough and not retry importation of another if it has not been long enough", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [ + { ...account1NonImported, attemptsLastTimestamp: new Date().getTime() - 60000 }, + account2NonImported, + ], + }; + const latestState = [account1, account2].map(convertAccountToDescriptor); + const incomingState = [account1, account2].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: true, + update: { + added: [placeholderAccountFromDescriptor(incomingState[0])], + removed: [], + nonImportedAccountInfos: [account2NonImported], + }, + }); + }); + it("should have a longer delay when we tried importing an account multiple times", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [ + { + ...account1NonImported, + attempts: 10, + attemptsLastTimestamp: new Date().getTime() - 60000, + }, + ], + }; + const latestState = [account1].map(convertAccountToDescriptor); + const incomingState = [account1].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: false, + }); + }); + it("should increment progressively the delay between importation retries when it keeps failing", async () => { + const localData = { + list: [], + nonImportedAccountInfos: [ + { + ...account4unsupportedNonImported, + attempts: 10, + attemptsLastTimestamp: new Date().getTime() - 60000 * 10, + }, + ], + }; + const latestState = [account4unsupported].map(convertAccountToDescriptor); + const incomingState = [account4unsupported].map(convertAccountToDescriptor); + const diff = await manager.resolveIncrementalUpdate( + dummyContext, + localData, + latestState, + incomingState, + ); + expect(diff).toMatchObject({ + hasChanges: true, + update: { + added: [], + removed: [], + nonImportedAccountInfos: [ + { + ...account4unsupportedNonImported, + attempts: 11, + attemptsLastTimestamp: new Date().getTime(), + }, + ], + }, + }); + }); }); diff --git a/libs/live-wallet/src/walletsync/createWalletSyncWatchLoop.test.ts b/libs/live-wallet/src/walletsync/createWalletSyncWatchLoop.test.ts index 75747a4c7589..459ab825468b 100644 --- a/libs/live-wallet/src/walletsync/createWalletSyncWatchLoop.test.ts +++ b/libs/live-wallet/src/walletsync/createWalletSyncWatchLoop.test.ts @@ -1,6 +1,7 @@ import { createWalletSyncWatchLoop } from "./createWalletSyncWatchLoop"; import { advanceTimersByTimeAsync, getWalletSyncWatchLoopConfig } from "./__mocks__/watchLoop"; import { Subject } from "rxjs"; +import { TrustchainEjected, TrustchainOutdated } from "@ledgerhq/trustchain/errors"; jest.useFakeTimers(); @@ -101,4 +102,126 @@ describe("createWalletSyncWatchLoop", () => { watchLoop.unsubscribe(); }); + + it("should execute the loop on user refresh intent", async () => { + const walletSyncWatchLoopConfig = getWalletSyncWatchLoopConfig(); + + walletSyncWatchLoopConfig.watchConfig = { + initialTimeout: 5000, + pollingInterval: 10000, + userIntentDebounce: 1000, + }; + + const watchLoop = createWalletSyncWatchLoop(walletSyncWatchLoopConfig); + + await advanceTimersByTimeAsync(15); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(2); + + await advanceTimersByTimeAsync(1); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(2); + + watchLoop.onUserRefreshIntent(); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(2); + + await advanceTimersByTimeAsync(1); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(3); + + watchLoop.unsubscribe(); + }); + + it("should stop running the loop when unsubscribed", async () => { + const walletSyncWatchLoopConfig = getWalletSyncWatchLoopConfig(); + + const watchLoop = createWalletSyncWatchLoop(walletSyncWatchLoopConfig); + + await advanceTimersByTimeAsync(15); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(2); + + watchLoop.unsubscribe(); + + await advanceTimersByTimeAsync(30); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(2); + }); + it("should wait for the initial timeout before running the loop", async () => { + const walletSyncWatchLoopConfig = getWalletSyncWatchLoopConfig(); + + walletSyncWatchLoopConfig.watchConfig = { + initialTimeout: 5000, + }; + + const watchLoop = createWalletSyncWatchLoop(walletSyncWatchLoopConfig); + + await advanceTimersByTimeAsync(3); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(0); + + await advanceTimersByTimeAsync(2); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(1); + + watchLoop.unsubscribe(); + }); + it("should call the onError function on error", async () => { + const walletSyncWatchLoopConfig = getWalletSyncWatchLoopConfig(); + + walletSyncWatchLoopConfig.onStartPolling = () => { + throw new Error("error"); + }; + walletSyncWatchLoopConfig.onError = jest.fn(); + + const watchLoop = createWalletSyncWatchLoop(walletSyncWatchLoopConfig); + + await advanceTimersByTimeAsync(5); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(0); + expect(walletSyncWatchLoopConfig.onError).toHaveBeenCalledTimes(1); + expect(walletSyncWatchLoopConfig.onError).toHaveBeenCalledWith(new Error("error")); + expect(walletSyncWatchLoopConfig.onTrustchainRefreshNeeded).toHaveBeenCalledTimes(0); + + watchLoop.unsubscribe(); + }); + it("should log the error if no onError function is given", async () => { + const walletSyncWatchLoopConfig = getWalletSyncWatchLoopConfig(); + + walletSyncWatchLoopConfig.onStartPolling = () => { + throw new Error("error"); + }; + + jest.spyOn(console, "error"); + + const watchLoop = createWalletSyncWatchLoop(walletSyncWatchLoopConfig); + + await advanceTimersByTimeAsync(5); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(0); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith(new Error("error")); + + watchLoop.unsubscribe(); + }); + it("should refresh the trustchain in case of a TrustchainEjectedError or a TrustchainOutdated", async () => { + const walletSyncWatchLoopConfig = getWalletSyncWatchLoopConfig(); + + walletSyncWatchLoopConfig.onStartPolling = () => { + throw new TrustchainEjected("error"); + }; + + const watchLoop = createWalletSyncWatchLoop(walletSyncWatchLoopConfig); + + await advanceTimersByTimeAsync(5); + expect(walletSyncWatchLoopConfig.walletSyncSdk.pull).toHaveBeenCalledTimes(0); + expect(walletSyncWatchLoopConfig.onTrustchainRefreshNeeded).toHaveBeenCalledTimes(1); + + watchLoop.unsubscribe(); + + const walletSyncWatchLoopConfig2 = getWalletSyncWatchLoopConfig(); + + walletSyncWatchLoopConfig2.onStartPolling = () => { + throw new TrustchainOutdated("error"); + }; + + const watchLoop2 = createWalletSyncWatchLoop(walletSyncWatchLoopConfig2); + + await advanceTimersByTimeAsync(5); + expect(walletSyncWatchLoopConfig2.walletSyncSdk.pull).toHaveBeenCalledTimes(0); + expect(walletSyncWatchLoopConfig2.onTrustchainRefreshNeeded).toHaveBeenCalledTimes(1); + + watchLoop2.unsubscribe(); + }); }); diff --git a/libs/live-wallet/src/walletsync/modules/accounts.ts b/libs/live-wallet/src/walletsync/modules/accounts.ts index 5d8d36b367f6..2135fd43b4ec 100644 --- a/libs/live-wallet/src/walletsync/modules/accounts.ts +++ b/libs/live-wallet/src/walletsync/modules/accounts.ts @@ -74,11 +74,12 @@ const manager: WalletSyncDataManager< } } - let nextState = latestState || []; + const latestOrEmpty = latestState || []; + let nextState = latestOrEmpty; if (hasChanges) { // we can now build the new state. we apply the diff on top of the previous state nextState = []; - for (const account of latestState || []) { + for (const account of latestOrEmpty) { if (removed.has(account.id)) { continue; } @@ -113,7 +114,7 @@ const manager: WalletSyncDataManager< for (const nonImported of localData.nonImportedAccountInfos) { nonImportedById.set(nonImported.id, nonImported); const { id, attempts, attemptsLastTimestamp } = nonImported; - if (existingIds.has(id)) { + if (existingIds.has(id) || diff.added.some(a => a.id === id)) { hasChanges = true; // at least we need to save the deletion continue; // we actually have the account. ignore. }