UNPKG

@ledgerhq/coin-canton

Version:
923 lines (747 loc) 28.6 kB
import network from "@ledgerhq/live-network"; import type { CryptoCurrency } from "@ledgerhq/ledger-wallet-framework/types"; import { LedgerAPI4xx } from "@ledgerhq/live-network/errors"; import coinConfig from "../config"; import { createMockCantonCurrency, createMockInstrumentBalances, createMockOnboardingPrepareResponse, createMockPendingTransferProposal, setupMockCoinConfig, } from "../test/fixtures"; import { TopologyChangeError } from "../types/errors"; import type { GetBalanceResponse } from "../types/gateway"; import { TransactionType } from "../types/gateway"; import { clearEnabledInstrumentsCache, clearIsTopologyChangeRequiredCache, getBalance, getCalTokensCached, getEnabledInstruments, getEnabledInstrumentsCached, getLedgerEnd, getOperations, getPartyById, getPartyByPubKey, getPendingTransferProposals, getTransferPreApproval, isPartyAlreadyExists, isPartyNotFound, isTopologyChangeRequired, isTopologyChangeRequiredCached, prepare, prepareOnboarding, preparePreApprovalTransaction, prepareTapRequest, prepareTransferInstruction, prepareTransferRequest, submit, submitOnboarding, submitPreApprovalTransaction, submitTapRequest, submitTransferInstruction, DEFAULT_TAP_REQUEST_AMOUNT, type InstrumentsResponse, } from "./gateway"; jest.mock("@ledgerhq/live-network", () => ({ __esModule: true, default: jest.fn() })); jest.mock("@ledgerhq/live-env", () => ({ getEnv: jest.fn((key: string) => { if (key === "CAL_SERVICE_URL") return "https://cal.example.com"; return undefined; }), })); const mockBalances = createMockInstrumentBalances(2, [ { admin_id: "AmuletAdmin", instrument_id: "Amulet", amount: "10000000000000000000000000000000000000000", locked: false, utxo_count: 1, }, { admin_id: "LockedAmuletAdmin", instrument_id: "Amulet", amount: "5000000000000000000000000000000000000000", locked: true, utxo_count: 1, }, ]); describe("isPartyNotFound", () => { it("returns true for party not found message (spaces)", () => { expect(isPartyNotFound(new Error("Party not found"))).toBe(true); }); it("returns true for party_not_found (underscores normalized)", () => { expect(isPartyNotFound(new Error("party_not_found"))).toBe(true); }); it("is case-insensitive", () => { expect(isPartyNotFound(new Error("PARTY NOT FOUND"))).toBe(true); }); it("returns false for unrelated errors", () => { expect(isPartyNotFound(new Error("Network timeout"))).toBe(false); }); it("returns false for non-Error values", () => { expect(isPartyNotFound("string")).toBe(false); expect(isPartyNotFound(null)).toBe(false); }); }); describe("isPartyAlreadyExists", () => { it("returns true for party already exists (spaces)", () => { expect(isPartyAlreadyExists(new Error("Party already exists"))).toBe(true); }); it("returns true for party_already_exists (underscores normalized)", () => { expect(isPartyAlreadyExists(new Error("party_already_exists"))).toBe(true); }); it("is case-insensitive", () => { expect(isPartyAlreadyExists(new Error("PARTY ALREADY EXISTS"))).toBe(true); }); it("returns false for unrelated errors", () => { expect(isPartyAlreadyExists(new Error("not found"))).toBe(false); }); it("returns false for non-Error values", () => { expect(isPartyAlreadyExists(undefined)).toBe(false); }); }); describe("getPartyById and getPartyByPubKey", () => { const mockCurrency = createMockCantonCurrency(); const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { setupMockCoinConfig({ nodeId: "test-node-id" }); }); beforeEach(() => { jest.clearAllMocks(); }); it("getPartyById calls GET with by=party-id and returns party", async () => { const party = { party_id: "p1", public_key: "pk1" }; mockNetwork.mockResolvedValue({ data: party, status: 200 }); const result = await getPartyById(mockCurrency, "p1"); expect(result).toEqual(party); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", url: expect.stringContaining("/party/p1?by=party-id"), }), ); }); it("getPartyByPubKey calls GET with by=public-key and returns party", async () => { const party = { party_id: "p1", public_key: "abc123" }; mockNetwork.mockResolvedValue({ data: party, status: 200 }); const result = await getPartyByPubKey(mockCurrency, "abc123"); expect(result).toEqual(party); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", url: expect.stringContaining("/party/abc123?by=public-key"), }), ); }); it("throws TopologyChangeError when party is not found", async () => { mockNetwork.mockRejectedValue(new Error("Party not found")); await expect(getPartyById(mockCurrency, "missing")).rejects.toThrow(TopologyChangeError); }); }); describe("prepareOnboarding", () => { const mockCurrency = createMockCantonCurrency(); const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { setupMockCoinConfig({ nodeId: "test-node-id" }); }); beforeEach(() => { jest.clearAllMocks(); }); it("POSTs public_key and public_key_type ed25519 to onboarding/prepare", async () => { const prepared = createMockOnboardingPrepareResponse(); mockNetwork.mockResolvedValue({ data: prepared, status: 200 }); const result = await prepareOnboarding(mockCurrency, "pk-hex"); expect(result).toEqual(prepared); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", url: expect.stringContaining("/onboarding/prepare"), data: { public_key: "pk-hex", public_key_type: "ed25519", }, }), ); }); it("rethrows errors that are not party already exists", async () => { mockNetwork.mockRejectedValue(new Error("Gateway down")); await expect(prepareOnboarding(mockCurrency, "pk")).rejects.toThrow("Gateway down"); }); }); describe("submitOnboarding", () => { const mockCurrency = createMockCantonCurrency(); const mockNetwork = network as jest.MockedFunction<typeof network>; const prepareResponse = createMockOnboardingPrepareResponse(); beforeAll(() => { setupMockCoinConfig({ nodeId: "test-node-id" }); }); beforeEach(() => { jest.clearAllMocks(); }); it("returns party on success without application_signature in body when omitted", async () => { const submitResponse = { party: { party_id: "pid", public_key: "pk" }, }; mockNetwork.mockResolvedValue({ data: submitResponse, status: 200 }); const result = await submitOnboarding(mockCurrency, "pk", prepareResponse, { signature: "sig", }); expect(result).toEqual(submitResponse); const call = mockNetwork.mock.calls[0][0] as { data: Record<string, unknown> }; expect(call.data).not.toHaveProperty("application_signature"); }); it("includes application_signature when provided", async () => { const submitResponse = { party: { party_id: "pid", public_key: "pk" }, }; mockNetwork.mockResolvedValue({ data: submitResponse, status: 200 }); await submitOnboarding(mockCurrency, "pk", prepareResponse, { signature: "sig", applicationSignature: "app-sig", }); const call = mockNetwork.mock.calls[0][0] as { data: { application_signature?: string } }; expect(call.data.application_signature).toBe("app-sig"); }); }); describe("isTopologyChangeRequired", () => { const mockCurrency = createMockCantonCurrency(); const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { setupMockCoinConfig({ nodeId: "test-node-id" }); }); beforeEach(() => { jest.clearAllMocks(); }); it("returns true when prepareOnboarding returns a response", async () => { mockNetwork.mockResolvedValue({ data: createMockOnboardingPrepareResponse(), status: 200 }); await expect(isTopologyChangeRequired(mockCurrency, "pk")).resolves.toBe(true); }); it("returns false when prepare response is falsy", async () => { mockNetwork.mockResolvedValue({ data: undefined, status: 200 }); await expect(isTopologyChangeRequired(mockCurrency, "pk")).resolves.toBe(false); }); it("returns false on party already exists", async () => { mockNetwork.mockRejectedValue(new Error("Party already exists")); await expect(isTopologyChangeRequired(mockCurrency, "pk")).resolves.toBe(false); }); it("rethrows other errors", async () => { mockNetwork.mockRejectedValue(new Error("Service unavailable")); await expect(isTopologyChangeRequired(mockCurrency, "pk")).rejects.toThrow( "Service unavailable", ); }); }); describe("isTopologyChangeRequiredCached", () => { const mockCurrency = createMockCantonCurrency(); const pubKey = "test-pub-key"; const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { setupMockCoinConfig({ nodeId: "test-node-id" }); }); beforeEach(() => { jest.clearAllMocks(); clearIsTopologyChangeRequiredCache(mockCurrency, pubKey); }); it("caches result per pubKey and nodeId", async () => { mockNetwork.mockResolvedValue({ data: createMockOnboardingPrepareResponse(), status: 200 }); await expect(isTopologyChangeRequiredCached(mockCurrency, pubKey)).resolves.toBe(true); await expect(isTopologyChangeRequiredCached(mockCurrency, pubKey)).resolves.toBe(true); expect(mockNetwork).toHaveBeenCalledTimes(1); }); it("fetches again after clearIsTopologyChangeRequiredCache", async () => { mockNetwork.mockResolvedValue({ data: createMockOnboardingPrepareResponse(), status: 200 }); await isTopologyChangeRequiredCached(mockCurrency, pubKey); expect(mockNetwork).toHaveBeenCalledTimes(1); clearIsTopologyChangeRequiredCache(mockCurrency, pubKey); mockNetwork.mockResolvedValue({ data: createMockOnboardingPrepareResponse(), status: 200 }); await isTopologyChangeRequiredCached(mockCurrency, pubKey); expect(mockNetwork).toHaveBeenCalledTimes(2); }); }); describe("getLedgerEnd, getOperations, getPendingTransferProposals, getTransferPreApproval", () => { const mockCurrency = createMockCantonCurrency(); const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { setupMockCoinConfig({ nodeId: "test-node-id" }); }); beforeEach(() => { jest.clearAllMocks(); }); it("getLedgerEnd returns numeric ledger end", async () => { mockNetwork.mockResolvedValue({ data: 42, status: 200 }); await expect(getLedgerEnd(mockCurrency)).resolves.toBe(42); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", url: expect.stringContaining("/ledger-end"), }), ); }); it("getOperations returns next and operations and passes params", async () => { const payload = { next: 1, operations: [] }; mockNetwork.mockResolvedValue({ data: payload, status: 200 }); const result = await getOperations(mockCurrency, "party-1", { cursor: 1, limit: 10 }); expect(result).toEqual(payload); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", url: expect.stringContaining("/party/party-1/operations"), params: { cursor: 1, limit: 10 }, }), ); }); it("getPendingTransferProposals returns proposals array", async () => { const proposals = [createMockPendingTransferProposal()]; mockNetwork.mockResolvedValue({ data: proposals, status: 200 }); const result = await getPendingTransferProposals(mockCurrency, "party-1"); expect(result).toEqual(proposals); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", url: expect.stringMatching(/\/party\/party-1\/transfer-proposals\?timestamp=\d+/), }), ); }); it("getTransferPreApproval returns pre-approval payload", async () => { const preApproval = { contract_id: "c1", receiver: "r1", provider: "p1", valid_from: "2020-01-01T00:00:00Z", last_renewed_at: "2020-01-01T00:00:00Z", expires_at: "2025-01-01T00:00:00Z", }; mockNetwork.mockResolvedValue({ data: preApproval, status: 200 }); const result = await getTransferPreApproval(mockCurrency, "party-1"); expect(result).toEqual(preApproval); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", url: expect.stringContaining("/party/party-1/transfer-preapproval"), }), ); }); }); describe("prepare and submit transaction helpers", () => { const mockCurrency = createMockCantonCurrency(); const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { setupMockCoinConfig({ nodeId: "test-node-id" }); }); beforeEach(() => { jest.clearAllMocks(); }); const prepareResponse = { serialized: "ser", json: {}, hash: "h", step: { type: "single-step" as const }, }; it("prepare POSTs to transaction/prepare", async () => { mockNetwork.mockResolvedValue({ data: prepareResponse, status: 200 }); const params = { type: "token-transfer-request" as const, recipient: "bob", amount: "1", instrument_id: "Amulet", execute_before_secs: 3600, }; const result = await prepare(mockCurrency, "party-1", params); expect(result).toEqual(prepareResponse); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", url: expect.stringContaining("/party/party-1/transaction/prepare"), data: params, }), ); }); it("prepareTransferRequest delegates to prepare", async () => { mockNetwork.mockResolvedValue({ data: prepareResponse, status: 200 }); const params = { type: "token-transfer-request" as const, recipient: "bob", amount: "1", instrument_id: "Amulet", execute_before_secs: 3600, }; await prepareTransferRequest(mockCurrency, "party-1", params); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ data: params, }), ); }); it("prepareTransferInstruction delegates to prepare", async () => { mockNetwork.mockResolvedValue({ data: prepareResponse, status: 200 }); const params = { type: "accept-transfer-instruction" as const, contract_id: "cid", }; await prepareTransferInstruction(mockCurrency, "party-1", params); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ data: params, }), ); }); it("preparePreApprovalTransaction sends transfer-pre-approval-proposal", async () => { const preApprovalPrepare = { serialized: "s", json: {}, hash: "hash", }; mockNetwork.mockResolvedValue({ data: preApprovalPrepare, status: 200 }); const result = await preparePreApprovalTransaction(mockCurrency, "party-1"); expect(result).toEqual(preApprovalPrepare); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", data: { type: TransactionType.TRANSFER_PRE_APPROVAL_PROPOSAL, receiver: "party-1", }, }), ); }); it("prepareTapRequest uses default fixed-point amount when amount omitted", async () => { const tapPrepare = { serialized: "s", json: null, hash: "h", step: { type: "single-step" as const }, }; mockNetwork.mockResolvedValue({ data: tapPrepare, status: 200 }); const result = await prepareTapRequest(mockCurrency, { partyId: "party-1" }); expect(result).toEqual(tapPrepare); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ data: { amount: DEFAULT_TAP_REQUEST_AMOUNT, type: TransactionType.TAP_REQUEST, }, }), ); }); it("prepareTapRequest uses provided amount string", async () => { const tapPrepare = { serialized: "s", json: null, hash: "h", step: { type: "single-step" as const }, }; mockNetwork.mockResolvedValue({ data: tapPrepare, status: 200 }); await prepareTapRequest(mockCurrency, { partyId: "party-1", amount: "1000" }); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ data: { amount: "1000", type: TransactionType.TAP_REQUEST, }, }), ); }); it("prepareTapRequest preserves explicit zero amount", async () => { const tapPrepare = { serialized: "s", json: null, hash: "h", step: { type: "single-step" as const }, }; mockNetwork.mockResolvedValue({ data: tapPrepare, status: 200 }); await prepareTapRequest(mockCurrency, { partyId: "party-1", amount: "0" }); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ data: { amount: "0", type: TransactionType.TAP_REQUEST, }, }), ); }); it("submit POSTs serialized and signature", async () => { const submitResponse = { submission_id: "sub", update_id: "upd" }; mockNetwork.mockResolvedValue({ data: submitResponse, status: 200 }); const result = await submit(mockCurrency, "party-1", "ser-hex", "sig-hex"); expect(result).toEqual(submitResponse); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", url: expect.stringContaining("/party/party-1/transaction/submit"), data: { serialized: "ser-hex", signature: "sig-hex" }, }), ); }); it("submitTransferInstruction delegates to submit", async () => { const submitResponse = { submission_id: "sub", update_id: "upd" }; mockNetwork.mockResolvedValue({ data: submitResponse, status: 200 }); const result = await submitTransferInstruction(mockCurrency, "party-1", "ser", "sig"); expect(result).toEqual(submitResponse); expect(mockNetwork).toHaveBeenCalledTimes(1); }); it("submitPreApprovalTransaction returns PreApprovalResult", async () => { const submitResponse = { submission_id: "sub1", update_id: "upd1" }; mockNetwork.mockResolvedValue({ data: submitResponse, status: 200 }); const result = await submitPreApprovalTransaction( mockCurrency, "party-1", { serialized: "ser", json: {}, hash: "h" }, "sig", ); expect(result).toEqual({ isApproved: true, submissionId: "sub1", updateId: "upd1", }); }); it("submitTapRequest POSTs tap submit payload", async () => { const tapSubmit = { submission_id: "s", update_id: "u" }; mockNetwork.mockResolvedValue({ data: tapSubmit, status: 200 }); const result = await submitTapRequest(mockCurrency, { partyId: "party-1", serialized: "ser", signature: "sig", }); expect(result).toEqual(tapSubmit); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", url: expect.stringContaining("/party/party-1/transaction/submit"), data: { serialized: "ser", signature: "sig" }, }), ); }); }); describe("getCalTokensCached", () => { const mockCurrency = createMockCantonCurrency(); const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { setupMockCoinConfig({ nodeId: "test-node-id" }); }); beforeEach(() => { jest.clearAllMocks(); getCalTokensCached.clear(mockCurrency.id); }); it("maps token id to token_identifier and caches", async () => { const calTokens = [ { id: "tok1", name: "T", ticker: "T", network: "canton_network", contract_address: "0x0", token_identifier: "ident1", }, ]; mockNetwork.mockResolvedValue({ data: calTokens, status: 200 }); const map1 = await getCalTokensCached(mockCurrency); const map2 = await getCalTokensCached(mockCurrency); expect(map1.get("tok1")).toBe("ident1"); expect(map2.get("tok1")).toBe("ident1"); expect(mockNetwork).toHaveBeenCalledTimes(1); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", url: expect.stringContaining("/v1/tokens?network=canton_network"), }), ); }); it("fetches again after cache clear", async () => { const calTokens = [ { id: "tok1", name: "T", ticker: "T", network: "canton_network", contract_address: "0x0", token_identifier: "ident1", }, ]; mockNetwork.mockResolvedValue({ data: calTokens, status: 200 }); await getCalTokensCached(mockCurrency); getCalTokensCached.clear(mockCurrency.id); mockNetwork.mockResolvedValue({ data: [{ ...calTokens[0], token_identifier: "ident2" }], status: 200, }); const map = await getCalTokensCached(mockCurrency); expect(map.get("tok1")).toBe("ident2"); expect(mockNetwork).toHaveBeenCalledTimes(2); }); }); describe("getBalance", () => { const mockCurrency = createMockCantonCurrency(); const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { setupMockCoinConfig(); }); beforeEach(() => { jest.clearAllMocks(); }); it("should return an array of balances (backwards compatibility)", async () => { mockNetwork.mockResolvedValue({ data: mockBalances, status: 200 }); const result = await getBalance(mockCurrency, "test-party-id"); expect(result).toEqual(mockBalances); }); it("should return an object with balances property", async () => { const mockResponse: GetBalanceResponse = { at_round: 123, balances: mockBalances, }; mockNetwork.mockResolvedValue({ data: mockResponse, status: 200 }); const result = await getBalance(mockCurrency, "test-party-id"); expect(result).toEqual(mockResponse.balances); }); it("should handle PARTY_ALREADY_EXISTS error", async () => { const error = new LedgerAPI4xx("Party already exists", { status: 409, url: undefined, method: "POST", }); const mockPublicKey = "test-public-key"; const mockPrepareResponse = createMockOnboardingPrepareResponse(); mockNetwork.mockRejectedValue(error); const result = await submitOnboarding(mockCurrency, mockPublicKey, mockPrepareResponse, { signature: "test-signature", }); expect(isPartyAlreadyExists(error)).toBe(true); expect(result.party).toEqual({ party_id: "test-party-id", public_key: mockPublicKey }); }); it("should handle PARTY_NOT_FOUND_BY_ID error", async () => { const partyNotFoundError = new LedgerAPI4xx("Party not found", { status: 400, url: undefined, method: "GET", }); mockNetwork.mockRejectedValue(partyNotFoundError); await expect(getBalance(mockCurrency, "test-party-id")).rejects.toThrow(TopologyChangeError); }); }); describe("getEnabledInstruments", () => { const mockCurrency = { id: "canton_network", } as unknown as CryptoCurrency; const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { coinConfig.setCoinConfig(() => ({ gatewayUrl: "https://canton-gateway-devnet.api.live.ledger-test.com", nodeId: "test-node-id", useGateway: true, networkType: "devnet", nativeInstrumentId: "Amulet", status: { type: "active", }, })); }); beforeEach(() => { jest.clearAllMocks(); }); it("should fetch and return enabled instruments from API", async () => { const mockInstrumentsResponse: InstrumentsResponse = [ { id: "Amulet", admin: "admin1" }, { id: "0x1234567890abcdef", admin: "admin2" }, { id: "0xabcdef1234567890", admin: "admin3" }, ]; mockNetwork.mockResolvedValue({ data: mockInstrumentsResponse, status: 200 }); const result = await getEnabledInstruments(mockCurrency); expect(result).toEqual( new Set(["Amulet____admin1", "0x1234567890abcdef____admin2", "0xabcdef1234567890____admin3"]), ); expect(mockNetwork).toHaveBeenCalledWith( expect.objectContaining({ method: "GET", url: expect.stringContaining("/v1/node/test-node-id/instruments"), }), ); }); it("should return empty Set on API failure (fail-safe)", async () => { const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); mockNetwork.mockRejectedValue(new Error("Network error")); const result = await getEnabledInstruments(mockCurrency); expect(result).toEqual(new Set()); expect(consoleErrorSpy).toHaveBeenCalledWith( "Failed to fetch enabled instruments:", expect.any(Error), ); consoleErrorSpy.mockRestore(); }); it("should return empty Set when API returns empty instruments list", async () => { const mockEmptyResponse: InstrumentsResponse = []; mockNetwork.mockResolvedValue({ data: mockEmptyResponse, status: 200 }); const result = await getEnabledInstruments(mockCurrency); expect(result).toEqual(new Set()); }); it("should handle malformed API response gracefully", async () => { const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); // API returns data that is not an array mockNetwork.mockResolvedValue({ data: { invalid: "response" }, status: 200 }); const result = await getEnabledInstruments(mockCurrency); expect(result).toEqual(new Set()); expect(consoleErrorSpy).toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); }); describe("getEnabledInstrumentsCached", () => { const mockCurrency = { id: "canton_network", } as unknown as CryptoCurrency; const mockNetwork = network as jest.MockedFunction<typeof network>; beforeAll(() => { coinConfig.setCoinConfig(() => ({ gatewayUrl: "https://canton-gateway-devnet.api.live.ledger-test.com", nodeId: "test-node-id", useGateway: true, networkType: "devnet", nativeInstrumentId: "Amulet", status: { type: "active", }, })); }); beforeEach(() => { jest.clearAllMocks(); // Clear cache before each test clearEnabledInstrumentsCache(mockCurrency); }); it("should cache results and return cached value on subsequent calls", async () => { const mockInstrumentsResponse: InstrumentsResponse = [ { id: "Amulet", admin: "admin1" }, { id: "0x1234567890abcdef", admin: "admin2" }, ]; mockNetwork.mockResolvedValue({ data: mockInstrumentsResponse, status: 200 }); // First call - should fetch from API const result1 = await getEnabledInstrumentsCached(mockCurrency); expect(result1).toEqual(new Set(["Amulet____admin1", "0x1234567890abcdef____admin2"])); expect(mockNetwork).toHaveBeenCalledTimes(1); // Second call - should return cached value const result2 = await getEnabledInstrumentsCached(mockCurrency); expect(result2).toEqual(new Set(["Amulet____admin1", "0x1234567890abcdef____admin2"])); expect(mockNetwork).toHaveBeenCalledTimes(1); // Still only 1 call // Third call - should still return cached value const result3 = await getEnabledInstrumentsCached(mockCurrency); expect(result3).toEqual(new Set(["Amulet____admin1", "0x1234567890abcdef____admin2"])); expect(mockNetwork).toHaveBeenCalledTimes(1); // Still only 1 call }); it("should fetch fresh data after cache is cleared", async () => { const mockResponse1: InstrumentsResponse = [{ id: "Amulet", admin: "admin1" }]; const mockResponse2: InstrumentsResponse = [ { id: "Amulet", admin: "admin1" }, { id: "0xnewtoken", admin: "admin2" }, ]; mockNetwork.mockResolvedValueOnce({ data: mockResponse1, status: 200 }); // First call const result1 = await getEnabledInstrumentsCached(mockCurrency); expect(result1).toEqual(new Set(["Amulet____admin1"])); expect(mockNetwork).toHaveBeenCalledTimes(1); // Clear cache clearEnabledInstrumentsCache(mockCurrency); // Set up new response mockNetwork.mockResolvedValueOnce({ data: mockResponse2, status: 200 }); // Second call after clear - should fetch fresh data const result2 = await getEnabledInstrumentsCached(mockCurrency); expect(result2).toEqual(new Set(["Amulet____admin1", "0xnewtoken____admin2"])); expect(mockNetwork).toHaveBeenCalledTimes(2); }); });