// Tests for src/shared/holders.js and the balance-list spam gate that reads // it (issue #230). // // The rule these pin down: an explorer that reports no holders_count has told // us nothing, and "nothing" must not be recorded as "zero holders". Zero is // the strongest spam signal the wallet has, so handing it out for free turns // a missing field into a hidden asset. jest.mock("../src/shared/log", () => ({ log: { debugf: () => {}, infof: () => {}, warnf: () => {}, errorf: () => {}, }, debugFetch: jest.fn(), setRuntimeDebug: () => {}, isDebug: () => false, })); global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); global.chrome = { storage: { local: {} } }; const { LOW_HOLDER_THRESHOLD, parseHoldersCount, isLowHolderCount, } = require("../src/shared/holders"); const { fetchTokenBalances } = require("../src/shared/balances"); const { debugFetch } = require("../src/shared/log"); const BLOCKSCOUT = "https://eth.blockscout.com/api/v2"; const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a"; const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; const NOVEL_TOKEN = "0x1111111111111111111111111111111111111111"; describe("parseHoldersCount", () => { test("a reported count parses to that number", () => { expect(parseHoldersCount("3500000")).toBe(3500000); expect(parseHoldersCount(3500000)).toBe(3500000); }); test('a reported "0" parses to 0, which is not null', () => { expect(parseHoldersCount("0")).toBe(0); expect(parseHoldersCount(0)).toBe(0); }); test("an omitted, null or empty count is unknown", () => { expect(parseHoldersCount(undefined)).toBeNull(); expect(parseHoldersCount(null)).toBeNull(); expect(parseHoldersCount("")).toBeNull(); }); test("an unparseable count is unknown rather than zero", () => { expect(parseHoldersCount("many")).toBeNull(); expect(parseHoldersCount(NaN)).toBeNull(); }); }); describe("isLowHolderCount", () => { test("the threshold is the documented 1,000 holders", () => { expect(LOW_HOLDER_THRESHOLD).toBe(1000); }); test("a reported count below the threshold is low", () => { expect(isLowHolderCount(0)).toBe(true); expect(isLowHolderCount(999)).toBe(true); }); test("a reported count at or above the threshold is not low", () => { expect(isLowHolderCount(1000)).toBe(false); expect(isLowHolderCount(1001)).toBe(false); }); test("an unknown count is not low", () => { expect(isLowHolderCount(null)).toBe(false); expect(isLowHolderCount(undefined)).toBe(false); }); }); // fetchTokenBalances applies its own spam gate, which is not the low-holder // display filter: it has no setting behind it and decides what the balance // list contains at all. It stays strict on an unknown count — see the // comment at the gate — but must stop recording that unknown as zero. describe("the balance-list spam gate", () => { function respondWith(items) { debugFetch.mockImplementation(async () => ({ ok: true, status: 200, statusText: "OK", json: async () => items, })); } function item(overrides = {}) { const { token, ...rest } = overrides; return { value: "12500000", ...rest, token: { type: "ERC-20", address_hash: NOVEL_TOKEN, symbol: "SPAMTKN", name: "Spam Token", decimals: "6", holders_count: "50000", ...token, }, }; } beforeEach(() => { debugFetch.mockReset(); }); test("a token with plenty of reported holders is listed", async () => { respondWith([item()]); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []); expect(balances).toHaveLength(1); expect(balances[0].holders).toBe(50000); }); test("a token reporting zero holders is still excluded", async () => { respondWith([item({ token: { holders_count: "0" } })]); expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); }); test("an unknown holder count does not admit an unvouched token", async () => { respondWith([item({ token: { holders_count: null } })]); expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); }); // The path that reaches the send selector and the history filter: a token // the user vouched for by tracking it is listed whatever the explorer // says, and it must carry the unknown count through as null, not as the // zero that would then hide it downstream. test("a tracked token with an unknown count is listed with holders null", async () => { respondWith([item({ token: { holders_count: undefined } })]); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [ { address: NOVEL_TOKEN.toUpperCase() }, ]); expect(balances).toHaveLength(1); expect(balances[0].holders).toBeNull(); }); test("a known-list token with an unknown count is listed with holders null", async () => { respondWith([ item({ token: { address_hash: USDC_CONTRACT, symbol: "USDC", holders_count: null, }, }), ]); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []); expect(balances).toHaveLength(1); expect(balances[0].holders).toBeNull(); }); test("no test in this file performed a network request", () => { expect(global.fetch).not.toHaveBeenCalled(); }); });