Skip to content
217 changes: 217 additions & 0 deletions __tests__/hooks/useAlchemyNftQueries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { fetchOwnerNfts } from "@/hooks/useAlchemyNftQueries";

const MOCK_API_ENDPOINT = "https://api.example.com";

jest.mock("@/config/env", () => ({
publicEnv: {
API_ENDPOINT: "https://api.example.com",
BASE_ENDPOINT: "https://example.com",
ALLOWLIST_API_ENDPOINT: "https://allowlist.example.com",
},
}));

describe("useAlchemyNftQueries", () => {
const originalFetch = globalThis.fetch;

beforeEach(() => {
jest.clearAllMocks();
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

describe("fetchOwnerNfts", () => {
const mockAlchemyResponse = {
ownedNfts: [
{
tokenId: "1",
tokenType: "ERC721",
name: "Test NFT",
tokenUri: "https://example.com/1",
image: null,
},
],
pageKey: undefined,
};

const expectedProcessedResult = [
{
tokenId: "1",
tokenType: "ERC721",
name: "Test NFT",
tokenUri: "https://example.com/1",
image: null,
},
];

it("should return processed data from primary endpoint when successful", async () => {
globalThis.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAlchemyResponse),
});

const result = await fetchOwnerNfts(1, "0x123", "0xowner");

expect(result).toEqual(expectedProcessedResult);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/alchemy/owner-nfts?chainId=1&contract=0x123&owner=0xowner",
{ signal: undefined }
);
});

it("should fallback to backend proxy when primary endpoint fails with non-ok response", async () => {
globalThis.fetch = jest
.fn()
.mockResolvedValueOnce({
ok: false,
status: 400,
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAlchemyResponse),
});

const result = await fetchOwnerNfts(1, "0x123", "0xowner");

expect(result).toEqual(expectedProcessedResult);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
expect(globalThis.fetch).toHaveBeenNthCalledWith(
1,
"/api/alchemy/owner-nfts?chainId=1&contract=0x123&owner=0xowner",
{ signal: undefined }
);
expect(globalThis.fetch).toHaveBeenNthCalledWith(
2,
`${MOCK_API_ENDPOINT}/alchemy-proxy/owner-nfts?chainId=1&contract=0x123&owner=0xowner`,
{ signal: undefined }
);
});

it("should fallback to backend proxy when primary endpoint throws network error", async () => {
globalThis.fetch = jest
.fn()
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAlchemyResponse),
});

const result = await fetchOwnerNfts(1, "0x123", "0xowner");

expect(result).toEqual(expectedProcessedResult);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it("should throw error when both primary and fallback fail", async () => {
globalThis.fetch = jest
.fn()
.mockResolvedValueOnce({
ok: false,
status: 500,
})
.mockResolvedValueOnce({
ok: false,
status: 500,
});

await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow(
"Request failed with status 500"
);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it("should pass abort signal to fetch calls", async () => {
const controller = new AbortController();
globalThis.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAlchemyResponse),
});

await fetchOwnerNfts(1, "0x123", "0xowner", controller.signal);

expect(globalThis.fetch).toHaveBeenCalledWith(expect.any(String), {
signal: controller.signal,
});
});

it("should handle different chain IDs correctly", async () => {
globalThis.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAlchemyResponse),
});

await fetchOwnerNfts(11155111, "0x123", "0xowner");

expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/alchemy/owner-nfts?chainId=11155111&contract=0x123&owner=0xowner",
{ signal: undefined }
);
});

it("should NOT fallback when request is aborted via AbortController", async () => {
const abortError = new DOMException(
"The operation was aborted.",
"AbortError"
);
globalThis.fetch = jest.fn().mockRejectedValueOnce(abortError);

await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});

it("should NOT fallback when request throws Error with name AbortError", async () => {
const abortError = new Error("The operation was aborted.");
abortError.name = "AbortError";
globalThis.fetch = jest.fn().mockRejectedValueOnce(abortError);

await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});

it("should process raw Alchemy response correctly", async () => {
const rawAlchemyResponse = {
ownedNfts: [
{
tokenId: "123",
tokenType: "ERC1155",
name: null,
tokenUri: null,
image: { thumbnailUrl: "https://img.example.com/123.png" },
},
{
tokenId: "456",
tokenType: "ERC721",
name: "Cool NFT",
tokenUri: "https://metadata.example.com/456",
image: null,
},
],
};

globalThis.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(rawAlchemyResponse),
});

const result = await fetchOwnerNfts(1, "0x123", "0xowner");

expect(result).toHaveLength(2);
expect(result[0]).toEqual({
tokenId: "123",
tokenType: "ERC1155",
name: null,
tokenUri: null,
image: { thumbnailUrl: "https://img.example.com/123.png" },
});
expect(result[1]).toEqual({
tokenId: "456",
tokenType: "ERC721",
name: "Cool NFT",
tokenUri: "https://metadata.example.com/456",
image: null,
});
});
});
});
44 changes: 34 additions & 10 deletions app/api/alchemy/collections/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { NextRequest, NextResponse } from "next/server";

import { searchNftCollections } from "@/services/alchemy-api";
import { getAlchemyApiKey } from "@/config/alchemyEnv";
import type { SupportedChain } from "@/types/nft";

const NO_STORE_HEADERS = { "Cache-Control": "no-store" };

const NETWORK_MAP: Record<SupportedChain, string> = {
ethereum: "eth-mainnet",
};

function resolveNetwork(chain: SupportedChain = "ethereum"): string {
return NETWORK_MAP[chain] ?? NETWORK_MAP.ethereum;
}

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("query") ?? "";
Expand All @@ -16,22 +24,38 @@ export async function GET(request: NextRequest) {
}

const chain = (searchParams.get("chain") ?? "ethereum") as SupportedChain;
const hideSpam = searchParams.get("hideSpam") !== "0" &&
searchParams.get("hideSpam") !== "false";
const pageKey = searchParams.get("pageKey") ?? undefined;

try {
const result = await searchNftCollections({
query,
chain,
hideSpam,
pageKey,
const apiKey = getAlchemyApiKey();
const network = resolveNetwork(chain);
const url = new URL(
`https://${network}.g.alchemy.com/nft/v3/${apiKey}/searchContractMetadata`
);
url.searchParams.set("query", query.trim());
if (pageKey) {
url.searchParams.set("pageKey", pageKey);
}

const response = await fetch(url.toString(), {
headers: { Accept: "application/json" },
signal: request.signal,
});
return NextResponse.json(result, { headers: NO_STORE_HEADERS });

if (!response.ok) {
return NextResponse.json(
{ error: "Failed to search NFT collections" },
{ status: response.status, headers: NO_STORE_HEADERS }
);
}

const payload = await response.json();
return NextResponse.json(payload, { headers: NO_STORE_HEADERS });
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to search NFT collections";
error instanceof Error
? error.message
: "Failed to search NFT collections";
return NextResponse.json(
{ error: message },
{ status: 400, headers: NO_STORE_HEADERS }
Expand Down
48 changes: 41 additions & 7 deletions app/api/alchemy/contract/route.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,63 @@
import { NextRequest, NextResponse } from "next/server";

import { getContractOverview } from "@/services/alchemy-api";
import { getAlchemyApiKey } from "@/config/alchemyEnv";
import { isValidEthAddress } from "@/helpers/Helpers";
import { normaliseAddress } from "@/helpers/alchemy/response-processing";
import type { SupportedChain } from "@/types/nft";

const NO_STORE_HEADERS = { "Cache-Control": "no-store" };

const NETWORK_MAP: Record<SupportedChain, string> = {
ethereum: "eth-mainnet",
};

function resolveNetwork(chain: SupportedChain = "ethereum"): string {
return NETWORK_MAP[chain] ?? NETWORK_MAP.ethereum;
}

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const address = searchParams.get("address") as `0x${string}` | null;
if (!address) {
const address = searchParams.get("address");
if (!address || !isValidEthAddress(address)) {
return NextResponse.json(
{ error: "address is required" },
{ status: 400, headers: NO_STORE_HEADERS }
);
}

const checksum = normaliseAddress(address);
if (!checksum) {
return NextResponse.json(null, { headers: NO_STORE_HEADERS });
}

const chain = (searchParams.get("chain") ?? "ethereum") as SupportedChain;

try {
const overview = await getContractOverview({
address,
chain,
const apiKey = getAlchemyApiKey();
const network = resolveNetwork(chain);
const url = `https://${network}.g.alchemy.com/nft/v3/${apiKey}/getContractMetadata?contractAddress=${checksum}`;

const response = await fetch(url, {
headers: { Accept: "application/json" },
signal: request.signal,
});
return NextResponse.json(overview, { headers: NO_STORE_HEADERS });

if (response.status === 404) {
return NextResponse.json(null, { headers: NO_STORE_HEADERS });
}

if (!response.ok) {
return NextResponse.json(
{ error: "Failed to fetch contract metadata" },
{ status: response.status, headers: NO_STORE_HEADERS }
);
}

const payload = await response.json();
return NextResponse.json(
{ ...payload, _checksum: checksum },
{ headers: NO_STORE_HEADERS }
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to fetch contract metadata";
Expand Down
Loading