@ledgerhq/coin-canton
Version:
923 lines (747 loc) • 28.6 kB
text/typescript
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);
});
});