diff --git a/frontend/src/lib/services/accounts.services.ts b/frontend/src/lib/services/accounts.services.ts index fce62bda28f..25622dda558 100644 --- a/frontend/src/lib/services/accounts.services.ts +++ b/frontend/src/lib/services/accounts.services.ts @@ -106,34 +106,65 @@ export const loadAccounts = async ({ }; }; +type SyncAccontsErrorHandler = (params: { + err: unknown; + certified: boolean; +}) => void; + +/** + * Default error handler for syncAccounts. + * + * Ignores non-certified errors. + * Resets accountsStore and shows toast for certified errors. + */ +const defaultErrorHandlerAccounts: SyncAccontsErrorHandler = ({ + err, + certified, +}: { + err: unknown; + certified: boolean; +}) => { + if (!certified) { + return; + } + + accountsStore.reset(); + + toastsError( + toToastError({ + err, + fallbackErrorLabelKey: "error.accounts_not_found", + }) + ); +}; + /** - * - sync: load the account data using the ledger and the nns dapp canister itself + * Loads the account data using the ledger and the nns dapp canister. */ -export const syncAccounts = (): Promise => { +export const syncAccounts = ( + errorHandler: SyncAccontsErrorHandler = defaultErrorHandlerAccounts +): Promise => { return queryAndUpdate({ request: (options) => loadAccounts(options), onLoad: ({ response: accounts }) => accountsStore.set(accounts), onError: ({ error: err, certified }) => { console.error(err); - if (certified !== true) { - return; - } - - // Explicitly handle only UPDATE errors - accountsStore.reset(); - - toastsError( - toToastError({ - err, - fallbackErrorLabelKey: "error.accounts_not_found", - }) - ); + errorHandler({ err, certified }); }, logMessage: "Syncing Accounts", }); }; +const ignoreErrors: SyncAccontsErrorHandler = () => undefined; + +/** + * This function is called on app load to sync the accounts. + * + * It ignores errors and does not show any toasts. Accounts will be synced again. + */ +export const initAccounts = () => syncAccounts(ignoreErrors); + export const addSubAccount = async ({ name, }: { diff --git a/frontend/src/lib/services/app.services.ts b/frontend/src/lib/services/app.services.ts index 9290cd08840..763d7535a47 100644 --- a/frontend/src/lib/services/app.services.ts +++ b/frontend/src/lib/services/app.services.ts @@ -1,9 +1,9 @@ -import { syncAccounts } from "./accounts.services"; +import { initAccounts } from "./accounts.services"; export const initAppPrivateData = (): Promise< [PromiseSettledResult] > => { - const initNns: Promise[] = [syncAccounts()]; + const initNns: Promise[] = [initAccounts()]; /** * If Nns load but Sns load fails it is "fine" to go on because Nns are core features. diff --git a/frontend/src/tests/lib/services/accounts.services.spec.ts b/frontend/src/tests/lib/services/accounts.services.spec.ts index 4dd210472bd..be34e1cb5fc 100644 --- a/frontend/src/tests/lib/services/accounts.services.spec.ts +++ b/frontend/src/tests/lib/services/accounts.services.spec.ts @@ -14,6 +14,7 @@ import { getAccountIdentityByPrincipal, getAccountTransactions, getOrCreateAccount, + initAccounts, loadAccounts, renameSubAccount, syncAccounts, @@ -22,6 +23,7 @@ import { import { accountsStore } from "$lib/stores/accounts.store"; import * as toastsFunctions from "$lib/stores/toasts.store"; import type { NewTransaction } from "$lib/types/transaction"; +import { toastsStore } from "@dfinity/gix-components"; import { ICPToken, TokenAmount } from "@dfinity/nns"; import { get } from "svelte/store"; import { @@ -168,6 +170,140 @@ describe("accounts-services", () => { }); }); + describe("initAccounts", () => { + beforeEach(() => { + toastsStore.reset(); + }); + + it("should sync accounts", async () => { + const mainBalanceE8s = BigInt(10_000_000); + const queryAccountBalanceSpy = jest + .spyOn(ledgerApi, "queryAccountBalance") + .mockResolvedValue(mainBalanceE8s); + const queryAccountSpy = jest + .spyOn(nnsdappApi, "queryAccount") + .mockResolvedValue(mockAccountDetails); + const mockAccounts = { + main: { + ...mockMainAccount, + balance: TokenAmount.fromE8s({ + amount: mainBalanceE8s, + token: ICPToken, + }), + }, + subAccounts: [], + hardwareWallets: [], + certified: true, + }; + await initAccounts(); + + expect(queryAccountSpy).toHaveBeenCalledTimes(2); + expect(queryAccountBalanceSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + accountIdentifier: mockAccountDetails.account_identifier, + certified: true, + }); + expect(queryAccountBalanceSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + accountIdentifier: mockAccountDetails.account_identifier, + certified: false, + }); + expect(queryAccountBalanceSpy).toBeCalledTimes(2); + + const accounts = get(accountsStore); + expect(accounts).toEqual(mockAccounts); + }); + + it("should not show toast errors", async () => { + jest.spyOn(ledgerApi, "queryAccountBalance"); + jest + .spyOn(nnsdappApi, "queryAccount") + .mockRejectedValue(new Error("test")); + + await initAccounts(); + + const toastsData = get(toastsStore); + expect(toastsData).toEqual([]); + }); + }); + + describe("syncAccounts", () => { + it("should sync accounts", async () => { + const mainBalanceE8s = BigInt(10_000_000); + const queryAccountBalanceSpy = jest + .spyOn(ledgerApi, "queryAccountBalance") + .mockResolvedValue(mainBalanceE8s); + const queryAccountSpy = jest + .spyOn(nnsdappApi, "queryAccount") + .mockResolvedValue(mockAccountDetails); + const mockAccounts = { + main: { + ...mockMainAccount, + balance: TokenAmount.fromE8s({ + amount: mainBalanceE8s, + token: ICPToken, + }), + }, + subAccounts: [], + hardwareWallets: [], + certified: true, + }; + await syncAccounts(); + + expect(queryAccountSpy).toHaveBeenCalledTimes(2); + expect(queryAccountBalanceSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + accountIdentifier: mockAccountDetails.account_identifier, + certified: true, + }); + expect(queryAccountBalanceSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + accountIdentifier: mockAccountDetails.account_identifier, + certified: false, + }); + expect(queryAccountBalanceSpy).toBeCalledTimes(2); + + const accounts = get(accountsStore); + expect(accounts).toEqual(mockAccounts); + }); + + it("should show toast on error if no handler is passed", async () => { + const errorTest = "test"; + jest.spyOn(ledgerApi, "queryAccountBalance"); + jest + .spyOn(nnsdappApi, "queryAccount") + .mockRejectedValue(new Error(errorTest)); + + await syncAccounts(); + + expect(get(toastsStore)).toMatchObject([ + { + level: "error", + text: `${en.error.accounts_not_found} ${errorTest}`, + }, + ]); + }); + + it("should use handler passed", async () => { + const errorTest = new Error("test"); + jest.spyOn(ledgerApi, "queryAccountBalance"); + jest.spyOn(nnsdappApi, "queryAccount").mockRejectedValue(errorTest); + + const handler = jest.fn(); + await syncAccounts(handler); + + expect(handler).toBeCalledTimes(2); + expect(handler).toBeCalledWith({ + err: errorTest, + certified: false, + }); + expect(handler).toBeCalledWith({ + err: errorTest, + certified: true, + }); + }); + }); + describe("services", () => { const mainBalanceE8s = BigInt(10_000_000); diff --git a/frontend/src/tests/lib/services/app.services.spec.ts b/frontend/src/tests/lib/services/app.services.spec.ts index 9b6b2b453c3..291acfda0e7 100644 --- a/frontend/src/tests/lib/services/app.services.spec.ts +++ b/frontend/src/tests/lib/services/app.services.spec.ts @@ -3,17 +3,19 @@ */ import { NNSDappCanister } from "$lib/canisters/nns-dapp/nns-dapp.canister"; import { initAppPrivateData } from "$lib/services/app.services"; -import { GovernanceCanister, LedgerCanister } from "@dfinity/nns"; +import { toastsStore } from "@dfinity/gix-components"; +import { LedgerCanister } from "@dfinity/nns"; import { mock } from "jest-mock-extended"; +import { get } from "svelte/store"; import { mockAccountDetails } from "../../mocks/accounts.store.mock"; -import { mockNeuron } from "../../mocks/neurons.mock"; describe("app-services", () => { const mockLedgerCanister = mock(); const mockNNSDappCanister = mock(); - const mockGovernanceCanister = mock(); beforeEach(() => { + toastsStore.reset(); + jest.clearAllMocks(); jest .spyOn(LedgerCanister, "create") .mockImplementation((): LedgerCanister => mockLedgerCanister); @@ -22,24 +24,12 @@ describe("app-services", () => { .spyOn(NNSDappCanister, "create") .mockImplementation((): NNSDappCanister => mockNNSDappCanister); - jest - .spyOn(GovernanceCanister, "create") - .mockImplementation((): GovernanceCanister => mockGovernanceCanister); - - mockCanisters(); + jest.spyOn(console, "error").mockImplementation(() => undefined); }); - const mockCanisters = () => { + it("should init Nns", async () => { mockNNSDappCanister.getAccount.mockResolvedValue(mockAccountDetails); mockLedgerCanister.accountBalance.mockResolvedValue(BigInt(100_000_000)); - mockGovernanceCanister.listNeurons.mockResolvedValue([mockNeuron]); - }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - it("should init Nns", async () => { await initAppPrivateData(); // query + update calls @@ -53,4 +43,22 @@ describe("app-services", () => { numberOfCalls ); }); + + it("should not show errors if loading accounts fails", async () => { + mockNNSDappCanister.getAccount.mockRejectedValue(new Error("test")); + mockLedgerCanister.accountBalance.mockResolvedValue(BigInt(100_000_000)); + await initAppPrivateData(); + + // query + update calls + const numberOfCalls = 2; + + await expect(mockNNSDappCanister.getAccount).toHaveBeenCalledTimes( + numberOfCalls + ); + + await expect(mockLedgerCanister.accountBalance).not.toBeCalled(); + + const toastData = get(toastsStore); + expect(toastData).toHaveLength(0); + }); });