Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-mirrors-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/live-common": patch
---

Add characterization tests for the previously untested dada-client cache selectors, query hooks and API transforms
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { selectInterestRateByCurrency } from "../interestRateSelectors";
import type { ApiState } from "../selectorUtils";
import type { InterestRate } from "..";

const bitcoinRate: InterestRate = {
currencyId: "bitcoin",
rate: 4.2,
type: "APY",
fetchAt: "2026-07-31T00:00:00.000Z",
};

function stateWith(pages: Record<string, unknown>[]): ApiState {
return { assetsDataApi: { queries: { a: { data: { pages } } } } } as ApiState;
}

describe("selectInterestRateByCurrency", () => {
it("reads from the interestRates collection", () => {
const state = stateWith([{ interestRates: { bitcoin: bitcoinRate } }]);

expect(selectInterestRateByCurrency(state, "bitcoin")).toEqual(bitcoinRate);
});

it("returns undefined when the currency has no rate", () => {
const state = stateWith([{ interestRates: { bitcoin: bitcoinRate } }]);

expect(selectInterestRateByCurrency(state, "ethereum")).toBeUndefined();
});

it("does not read from the markets collection", () => {
const state = stateWith([{ markets: { bitcoin: { price: 100 } } }]);

expect(selectInterestRateByCurrency(state, "bitcoin")).toBeUndefined();
});

it("returns undefined for an empty state", () => {
expect(selectInterestRateByCurrency({}, "bitcoin")).toBeUndefined();
});

it("preserves every field of the stored rate", () => {
const state = stateWith([{ interestRates: { bitcoin: bitcoinRate } }]);

expect(selectInterestRateByCurrency(state, "bitcoin")).toEqual(
expect.objectContaining({ currencyId: "bitcoin", rate: 4.2, type: "APY" }),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { selectMarketByCurrency } from "../marketSelectors";
import type { ApiState } from "../selectorUtils";

const bitcoinMarket = {
price: 65000.42,
priceChangePercentage24h: 1.2345,
marketCap: 1_280_000_000_000,
};

function stateWith(pages: Record<string, unknown>[]): ApiState {
return { assetsDataApi: { queries: { a: { data: { pages } } } } } as ApiState;
}

describe("selectMarketByCurrency", () => {
it("reads from the markets collection", () => {
const state = stateWith([{ markets: { bitcoin: bitcoinMarket } }]);

expect(selectMarketByCurrency(state, "bitcoin")).toEqual(bitcoinMarket);
});

it("returns undefined when the currency has no market entry", () => {
const state = stateWith([{ markets: { bitcoin: bitcoinMarket } }]);

expect(selectMarketByCurrency(state, "ethereum")).toBeUndefined();
});

it("does not read from the interestRates collection", () => {
const state = stateWith([{ interestRates: { bitcoin: { rate: 4.2 } } }]);

expect(selectMarketByCurrency(state, "bitcoin")).toBeUndefined();
});

it("returns undefined for an empty state", () => {
expect(selectMarketByCurrency({}, "bitcoin")).toBeUndefined();
});

it("returns partial market entries unchanged, without filling defaults", () => {
const state = stateWith([{ markets: { bitcoin: { price: 65000 } } }]);

expect(selectMarketByCurrency(state, "bitcoin")).toEqual({ price: 65000 });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { createCurrencyDataSelector, type ApiState } from "../selectorUtils";

type Rate = { rate: number };

function stateWithQueries(queries: Record<string, unknown>): ApiState {
return { assetsDataApi: { queries } } as ApiState;
}

function pagedQuery(...pages: Record<string, unknown>[]) {
return { data: { pages } };
}

describe("createCurrencyDataSelector", () => {
const selectRate = createCurrencyDataSelector<Rate>("interestRates");

it("finds the entry matching the currency id under the requested data key", () => {
const state = stateWithQueries({
'getAssetsData({"product":"lld"})': pagedQuery({
interestRates: { bitcoin: { rate: 4.2 } },
}),
});

expect(selectRate(state, "bitcoin")).toEqual({ rate: 4.2 });
});

it("returns undefined for a currency id that is absent", () => {
const state = stateWithQueries({
a: pagedQuery({ interestRates: { bitcoin: { rate: 4.2 } } }),
});

expect(selectRate(state, "ethereum")).toBeUndefined();
});

it("only reads the requested data key and ignores the others", () => {
const state = stateWithQueries({
a: pagedQuery({ markets: { bitcoin: { price: 100 } } }),
});

expect(selectRate(state, "bitcoin")).toBeUndefined();
});

describe("tolerates missing levels of the cache shape", () => {
it("returns undefined when the api slice is absent entirely", () => {
expect(selectRate({}, "bitcoin")).toBeUndefined();
});

it("returns undefined when the api slice has no queries", () => {
expect(selectRate({ assetsDataApi: {} }, "bitcoin")).toBeUndefined();
});

it("returns undefined when there are no cache entries", () => {
expect(selectRate(stateWithQueries({}), "bitcoin")).toBeUndefined();
});

it("skips a cache entry that has no data", () => {
const state = stateWithQueries({ pending: {} });

expect(selectRate(state, "bitcoin")).toBeUndefined();
});

it("skips a cache entry whose data has no pages and keeps scanning the rest", () => {
const state = stateWithQueries({
// shape produced by the non-infinite getAssetData endpoint
flat: { data: { interestRates: { bitcoin: { rate: 9.9 } } } },
paged: pagedQuery({ interestRates: { bitcoin: { rate: 4.2 } } }),
});

expect(selectRate(state, "bitcoin")).toEqual({ rate: 4.2 });
});

it("skips a page that lacks the data key", () => {
const state = stateWithQueries({
a: pagedQuery({}, { interestRates: { bitcoin: { rate: 4.2 } } }),
});

expect(selectRate(state, "bitcoin")).toEqual({ rate: 4.2 });
});
});

describe("scans every cache entry regardless of query args", () => {
/*
* Characterizes existing behavior, not a recommendation: the selector has no
* access to the query args of the entries it walks, so a value cached by one
* query is served to callers of any other. Preserve on migration.
*/
it("returns a value cached by an unrelated query", () => {
const state = stateWithQueries({
'getAssetsData({"search":"something-else"})': pagedQuery({
interestRates: { solana: { rate: 7.1 } },
}),
});

expect(selectRate(state, "solana")).toEqual({ rate: 7.1 });
});

it("returns the first match in cache-entry order when several entries hold the same id", () => {
const state = stateWithQueries({
first: pagedQuery({ interestRates: { bitcoin: { rate: 1 } } }),
second: pagedQuery({ interestRates: { bitcoin: { rate: 2 } } }),
});

expect(selectRate(state, "bitcoin")).toEqual({ rate: 1 });
});

it("returns the first match in page order within one entry", () => {
const state = stateWithQueries({
a: pagedQuery(
{ interestRates: { bitcoin: { rate: 1 } } },
{ interestRates: { bitcoin: { rate: 2 } } },
),
});

expect(selectRate(state, "bitcoin")).toEqual({ rate: 1 });
});

it("finds a match on a later page of a later entry", () => {
const state = stateWithQueries({
a: pagedQuery({ interestRates: {} }),
b: pagedQuery({ interestRates: {} }, { interestRates: { cardano: { rate: 3.3 } } }),
});

expect(selectRate(state, "cardano")).toEqual({ rate: 3.3 });
});
});

describe("falsy stored values", () => {
it("treats a falsy stored value as absent", () => {
const state = stateWithQueries({
a: pagedQuery({ interestRates: { bitcoin: 0 } }),
b: pagedQuery({ interestRates: { bitcoin: { rate: 4.2 } } }),
});

expect(selectRate(state, "bitcoin")).toEqual({ rate: 4.2 });
});
});

describe("independent instances", () => {
it("keeps separate data keys isolated from each other", () => {
const selectMarket = createCurrencyDataSelector<{ price: number }>("markets");
const state = stateWithQueries({
a: pagedQuery({
interestRates: { bitcoin: { rate: 4.2 } },
markets: { bitcoin: { price: 100 } },
}),
});

expect(selectRate(state, "bitcoin")).toEqual({ rate: 4.2 });
expect(selectMarket(state, "bitcoin")).toEqual({ price: 100 });
});

it("returns correct values when called with alternating currency ids", () => {
const state = stateWithQueries({
a: pagedQuery({
interestRates: { bitcoin: { rate: 4.2 }, ethereum: { rate: 3.1 } },
}),
});

expect(selectRate(state, "bitcoin")).toEqual({ rate: 4.2 });
expect(selectRate(state, "ethereum")).toEqual({ rate: 3.1 });
expect(selectRate(state, "bitcoin")).toEqual({ rate: 4.2 });
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @jest-environment jsdom
*/

import { renderHook } from "@testing-library/react";
import { useAssetData } from "../useAssetData";
import { useGetAssetDataQuery } from "../../state-manager/api";

jest.mock("../../state-manager/api", () => ({
useGetAssetDataQuery: jest.fn(),
}));

const mockUseGetAssetDataQuery = jest.mocked(useGetAssetDataQuery);

const defaultQueryResult = {
data: undefined,
isLoading: false,
error: undefined,
isSuccess: false,
isError: false,
isFetching: false,
refetch: jest.fn(),
};

const baseParams = { product: "lld" as const, version: "1.0.0" };

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockResult = (overrides: Record<string, unknown> = {}) =>
mockUseGetAssetDataQuery.mockReturnValue({ ...defaultQueryResult, ...overrides } as any);

describe("useAssetData", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("query arguments", () => {
it("forwards only currencyIds, product, version and isStaging", () => {
mockResult();

renderHook(() => useAssetData({ ...baseParams, currencyIds: ["bitcoin"], isStaging: true }));

expect(mockUseGetAssetDataQuery).toHaveBeenCalledWith({
currencyIds: ["bitcoin"],
product: "lld",
version: "1.0.0",
isStaging: true,
});
});

it("drops params the underlying query does not accept", () => {
mockResult();

renderHook(() =>
useAssetData({ ...baseParams, search: "btc", useCase: "send", includeTestNetworks: true }),
);

expect(mockUseGetAssetDataQuery).toHaveBeenCalledWith({
currencyIds: undefined,
product: "lld",
version: "1.0.0",
isStaging: undefined,
});
});

it("passes no options object, so the query is never skipped", () => {
mockResult();

renderHook(() => useAssetData(baseParams));

expect(mockUseGetAssetDataQuery).toHaveBeenCalledTimes(1);
expect(mockUseGetAssetDataQuery.mock.calls[0]).toHaveLength(1);
});
});

describe("loading state collapses isLoading and isFetching", () => {
it.each([
[{ isLoading: true, isFetching: false }, true],
[{ isLoading: false, isFetching: true }, true],
[{ isLoading: true, isFetching: true }, true],
[{ isLoading: false, isFetching: false }, false],
])("reports %p as isLoading %p", (flags, expected) => {
mockResult(flags);

const { result } = renderHook(() => useAssetData(baseParams));

expect(result.current.isLoading).toBe(expected);
});

it("still reports loading while refetching with data already present", () => {
mockResult({ data: { cryptoAssets: {} }, isFetching: true, isSuccess: true });

const { result } = renderHook(() => useAssetData(baseParams));

expect(result.current.isLoading).toBe(true);
expect(result.current.data).toEqual({ cryptoAssets: {} });
});
});

describe("passthrough", () => {
it("passes data, success and refetch straight through", () => {
const refetch = jest.fn();
const data = { cryptoAssets: { bitcoin: {} } };
mockResult({ data, isSuccess: true, refetch });

const { result } = renderHook(() => useAssetData(baseParams));

expect(result.current.data).toBe(data);
expect(result.current.isSuccess).toBe(true);
expect(result.current.refetch).toBe(refetch);
});

it("passes the raw error through without parsing it", () => {
const error = { status: 500, data: "boom" };
mockResult({ error, isError: true });

const { result } = renderHook(() => useAssetData(baseParams));

expect(result.current.error).toBe(error);
expect(result.current.isError).toBe(true);
});

it("does not expose errorInfo or isFetching", () => {
mockResult({ isFetching: true, error: { status: 500 } });

const { result } = renderHook(() => useAssetData(baseParams));

expect(result.current).not.toHaveProperty("errorInfo");
expect(result.current).not.toHaveProperty("isFetching");
});
});
});
Loading
Loading