Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
4548fda
wip
simo6529 Sep 30, 2025
5cb472a
wip
simo6529 Sep 30, 2025
c39541e
feat(nft-picker): complete picker implementation
simo6529 Sep 30, 2025
c442f87
wip
simo6529 Sep 30, 2025
8419dae
wip
simo6529 Sep 30, 2025
11b9e1f
wip
simo6529 Oct 1, 2025
315f55d
wip
simo6529 Oct 1, 2025
3d93cd1
wip
simo6529 Oct 1, 2025
59807f1
wip
simo6529 Oct 1, 2025
cea4849
wip
simo6529 Oct 1, 2025
13e7740
wip
simo6529 Oct 1, 2025
206382e
wip
simo6529 Oct 1, 2025
505fcbf
wip
simo6529 Oct 1, 2025
c3495af
wip
simo6529 Oct 1, 2025
b65f9f5
wip
simo6529 Oct 1, 2025
ec46c76
wip
simo6529 Oct 1, 2025
8c616ce
wip
simo6529 Oct 1, 2025
2274a32
wip
simo6529 Oct 1, 2025
ca0cdd7
wip
simo6529 Oct 1, 2025
5ccf789
wip
simo6529 Oct 1, 2025
5a1dfb6
wip
simo6529 Oct 1, 2025
5291ab4
wip
simo6529 Oct 1, 2025
680ff31
Merge branch 'main' into feature/nft-picker
simo6529 Oct 1, 2025
43d4c21
wip
simo6529 Oct 1, 2025
8e45385
wip
simo6529 Oct 1, 2025
65ecf1d
wip
simo6529 Oct 1, 2025
96ea43f
wip
simo6529 Oct 1, 2025
6174c46
wip
simo6529 Oct 1, 2025
f63ecb2
wip
simo6529 Oct 1, 2025
82c7842
wip
simo6529 Oct 1, 2025
976f6a4
wip
simo6529 Oct 1, 2025
a845855
wip
simo6529 Oct 1, 2025
e572005
wip
simo6529 Oct 1, 2025
da79b29
Merge branch 'main' into feature/nft-picker
simo6529 Oct 1, 2025
d8b4f2f
wip
simo6529 Oct 1, 2025
9b12f70
wip
simo6529 Oct 1, 2025
30b9928
wip
simo6529 Oct 1, 2025
b0674e8
wip
simo6529 Oct 1, 2025
939ca1d
wip
simo6529 Oct 1, 2025
a060c8f
wip
simo6529 Oct 1, 2025
4744511
wip
simo6529 Oct 6, 2025
9b5dee7
wip
simo6529 Oct 6, 2025
20e251e
wip
simo6529 Oct 6, 2025
0539eb0
wip
simo6529 Oct 6, 2025
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
163 changes: 163 additions & 0 deletions __tests__/components/nft-picker/NftPicker.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import {
MAX_ENUMERATION,
MAX_SAFE,
bigintCompare,
expandRangesWindow,
formatCanonical,
fromCanonicalRanges,
isRangeTooLargeError,
mergeAndSort,
parseTokenExpressionToBigints,
parseTokenExpressionToRanges,
toCanonicalRanges,
tryToNumberArray,
} from "@/components/nft-picker/NftPicker.utils";

import type { TokenRange } from "@/components/nft-picker/NftPicker.types";

describe("NftPicker.utils", () => {
describe("parseTokenExpressionToBigints", () => {
it("parses decimal and range inputs", () => {
const result = parseTokenExpressionToBigints("1,2,5-7");
expect(result.map((value) => value.toString())).toEqual([
"1",
"2",
"5",
"6",
"7",
]);
});

it("parses hexadecimal values", () => {
const result = parseTokenExpressionToBigints("0x1A,0x1B-0x1D");
expect(result.map((value) => value.toString(16))).toEqual([
"1a",
"1b",
"1c",
"1d",
]);
});

it("trims whitespace and normalises ranges", () => {
const result = parseTokenExpressionToBigints(" 1 - 3 , 5 \n");
expect(result.map((value) => value.toString())).toEqual([
"1",
"2",
"3",
"5",
]);
});

it("swaps reversed ranges", () => {
const result = parseTokenExpressionToBigints("5-2");
expect(result.map((value) => value.toString())).toEqual([
"2",
"3",
"4",
"5",
]);
});

it("throws parse errors for invalid segments", () => {
expect(() => parseTokenExpressionToBigints("1,foo"))
.toThrow();
try {
parseTokenExpressionToBigints("1,foo");
} catch (error) {
expect(Array.isArray(error)).toBe(true);
const errors = error as { message: string }[];
expect(errors[0].message).toBe("Invalid token format");
}
});

it("throws a parse error when a single range exceeds the enumeration ceiling", () => {
expect(() => parseTokenExpressionToRanges("0-10000"))
.toThrow();
try {
parseTokenExpressionToRanges("0-10000");
} catch (error) {
expect(Array.isArray(error)).toBe(true);
const errors = error as { code?: string; message: string }[];
expect(errors[0]?.code).toBe("range-too-large");
expect(errors[0]?.message).toContain("exceeding the limit");
}
});

it("throws a parse error when combined ranges exceed the enumeration ceiling", () => {
const input = "0-5000,6000-11000";
expect(() => parseTokenExpressionToBigints(input)).toThrow();
try {
parseTokenExpressionToBigints(input);
} catch (error) {
expect(Array.isArray(error)).toBe(true);
const errors = error as { code?: string; message: string }[];
expect(errors[0]?.code).toBe("range-too-large");
expect(errors[0]?.message).toContain(MAX_ENUMERATION.toString());
}
});
});

it("merges and sorts token ids", () => {
const result = mergeAndSort([
5n,
3n,
3n,
4n,
1n,
]);
expect(result).toEqual([1n, 3n, 4n, 5n]);
});

it("converts to and from canonical ranges", () => {
const ranges = toCanonicalRanges([1n, 2n, 3n, 5n, 6n]);
expect(ranges).toEqual([
{ start: 1n, end: 3n },
{ start: 5n, end: 6n },
]);
const expanded = fromCanonicalRanges(ranges);
expect(expanded).toEqual([1n, 2n, 3n, 5n, 6n]);
});

it("throws a structured error when expanding canonical ranges beyond the ceiling", () => {
const oversized = [{ start: 0n, end: 10_000n }];
expect(() => fromCanonicalRanges(oversized)).toThrow();
try {
fromCanonicalRanges(oversized);
} catch (error) {
expect(isRangeTooLargeError(error)).toBe(true);
if (isRangeTooLargeError(error)) {
expect(error.limit).toBe(MAX_ENUMERATION);
expect(error.size).toBe(10_001n);
}
}
});

it("formats canonical ranges", () => {
const formatted = formatCanonical([
{ start: 1n, end: 3n },
{ start: 5n, end: 5n },
]);
expect(formatted).toBe("1-3,5");
});

it("converts to numbers and counts unsafe values", () => {
const { numbers, unsafeCount } = tryToNumberArray([1n, MAX_SAFE + 1n]);
expect(numbers).toEqual([1]);
expect(unsafeCount).toBe(1);
});

it("compares bigints", () => {
expect(bigintCompare(1n, 2n)).toBe(-1);
expect(bigintCompare(2n, 1n)).toBe(1);
expect(bigintCompare(3n, 3n)).toBe(0);
});

it("expands ranges within a window", () => {
const ranges: TokenRange[] = [
{ start: 1n, end: 3n },
{ start: 10n, end: 12n },
];
const window = expandRangesWindow(ranges, 2, 4);
expect(window).toEqual([3n, 10n, 11n, 12n]);
});
});
142 changes: 107 additions & 35 deletions __tests__/services/alchemy-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,110 @@
import { getNftsForContractAndOwner } from "@/services/alchemy-api";
import { sepolia } from "wagmi/chains";

it("fetches all pages and maps nfts", async () => {
const responses = [
{
ownedNfts: [
{
tokenId: "1",
tokenType: "ERC721",
name: "a",
tokenUri: "u1",
image: "i1",
jest.mock("@/config/env", () => ({
publicEnv: { ALCHEMY_API_KEY: "test" },
}));

jest.mock("@/services/6529api", () => ({
fetchUrl: jest.fn(),
postData: jest.fn(),
}));

import {
getContractOverview,
getTokensMetadata,
searchNftCollections,
} from "@/services/alchemy-api";
import { fetchUrl, postData } from "@/services/6529api";

const mockedFetchUrl = fetchUrl as jest.MockedFunction<typeof fetchUrl>;
const mockedPostData = postData as jest.MockedFunction<typeof postData>;

describe("services/alchemy-api", () => {
beforeEach(() => {
mockedFetchUrl.mockReset();
mockedPostData.mockReset();
});

describe("searchNftCollections", () => {
it("returns suggestions and filters spam", async () => {
mockedFetchUrl.mockResolvedValue({
contracts: [
{
address: "0x1234567890abcdef1234567890abcdef12345678",
name: "Example",
tokenType: "ERC721",
totalSupply: "1000",
openSeaMetadata: { floorPrice: 1.2, safelistRequestStatus: "verified" },
isSpam: false,
},
{
address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
name: "Spam",
isSpam: true,
},
],
});

const result = await searchNftCollections({ query: "exa" });

expect(mockedFetchUrl).toHaveBeenCalledTimes(1);
expect(result.items).toHaveLength(1);
expect(result.hiddenCount).toBe(1);
});
});

describe("getContractOverview", () => {
it("throws on invalid address", async () => {
await expect(
getContractOverview({ address: "invalid" as `0x${string}` })
).rejects.toThrow("Invalid contract address");
});

it("normalises contract metadata", async () => {
mockedFetchUrl.mockResolvedValue({
contractMetadata: {
name: "Example",
tokenType: "ERC1155",
totalSupply: "5000",
},
],
pageKey: "next",
},
{
ownedNfts: [
{
tokenId: "2",
tokenType: "ERC721",
name: "b",
tokenUri: "u2",
image: "i2",
openSeaMetadata: {
floorPrice: 0.5,
safelistRequestStatus: "verified",
},
],
},
];
let call = 0;
globalThis.fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve(responses[call++]) })
) as any;
const result = await getNftsForContractAndOwner(sepolia.id, "0xc", "0xowner");
expect(result).toHaveLength(2);
expect(fetch).toHaveBeenCalledTimes(2);
expect((fetch as any).mock.calls[0][0]).toContain("eth-sepolia");
address: "0x1234567890abcdef1234567890abcdef12345678",
});

const result = await getContractOverview({
address: "0x1234567890abcdef1234567890abcdef12345678" as `0x${string}`,
});

expect(result?.name).toBe("Example");
expect(result?.floorPriceEth).toBe(0.5);
});
});

describe("getTokensMetadata", () => {
it("fetches batched token metadata", async () => {
mockedPostData.mockResolvedValue({
status: 200,
response: {
tokens: [
{
tokenId: "1",
title: "Token 1",
image: { cachedUrl: "https://example.com/1.png" },
},
],
},
});

const result = await getTokensMetadata({
address: "0x1234567890abcdef1234567890abcdef12345678" as `0x${string}`,
tokenIds: ["1"],
});

expect(mockedPostData).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(1);
expect(result[0].tokenId.toString()).toBe("1");
expect(result[0].imageUrl).toBe("https://example.com/1.png");
});
});
});
12 changes: 12 additions & 0 deletions app/demo/nft-picker/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getAppMetadata } from "@/components/providers/metadata";
import type { Metadata } from "next";

import DemoNftPicker from "./picker-client";

export const metadata: Metadata = getAppMetadata({
title: "NFT Picker Demo",
});

export default function NftPickerDemoPage() {
return <DemoNftPicker />;
}
30 changes: 30 additions & 0 deletions app/demo/nft-picker/picker-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { useState } from "react";

import { NftPicker } from "@/components/nft-picker/NftPicker";
import type { NftPickerSelection } from "@/components/nft-picker/NftPicker.types";

const stringifySelection = (value: unknown) =>
JSON.stringify(
value,
(_, nestedValue) => (typeof nestedValue === "bigint" ? nestedValue.toString() : nestedValue),
2,
);

export default function DemoNftPicker() {
const [selection, setSelection] = useState<NftPickerSelection | null>(null);

return (
<div className="tw-@container tw-mx-auto tw-flex tw-max-w-3xl tw-flex-col tw-gap-4 tw-p-6">

<NftPicker onChange={setSelection} allowAll allowRanges hideSpam className="tw-shadow-lg" />
<section className="tw-rounded-md tw-border tw-border-iron-700 tw-bg-iron-900 tw-p-3">
<h2 className="tw-mb-2 tw-text-sm tw-font-semibold tw-text-white">Selection (for testing purposes)</h2>
<pre className="tw-max-h-80 tw-overflow-auto tw-text-xs tw-text-iron-200">
{stringifySelection(selection)}
</pre>
</section>
</div>
);
}
Loading