From 9b0698d2feb5ad2bd7fbbe2490e5796208daebb8 Mon Sep 17 00:00:00 2001 From: Femi John Date: Mon, 19 Oct 2026 20:37:10 +0100 Subject: [PATCH 1/2] feat(marketplace): send search and category to /api/apis --- package-lock.json | 104 ++++++++ package.json | 2 + src/api/catalogApi.test.ts | 249 +++++++++++++++++++ src/api/catalogApi.ts | 98 ++++++++ src/config/api.ts | 7 + src/pages/MarketplacePage.test.tsx | 381 +++++++++++++++++++++++++++++ src/pages/MarketplacePage.tsx | 232 ++++++++++-------- src/vite-env.d.ts | 8 + tsconfig.tsbuildinfo | 2 +- 9 files changed, 977 insertions(+), 106 deletions(-) create mode 100644 src/api/catalogApi.test.ts create mode 100644 src/api/catalogApi.ts create mode 100644 src/config/api.ts create mode 100644 src/pages/MarketplacePage.test.tsx diff --git a/package-lock.json b/package-lock.json index f6f66f53..6dd0764c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "react-router-dom": "^6.30.3" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^14.1.0", + "@testing-library/user-event": "^14.6.8", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", @@ -23,6 +25,13 @@ "vitest": "^1.6.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1298,6 +1307,33 @@ "node": ">=14" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "14.3.1", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", @@ -1317,6 +1353,20 @@ "react-dom": "^18.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1930,6 +1980,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -2589,6 +2646,16 @@ "node": ">=0.10.0" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3074,6 +3141,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -3493,6 +3570,20 @@ "react-dom": ">=16.8" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -3834,6 +3925,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", diff --git a/package.json b/package.json index 81035db3..90f7ee1a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "react-router-dom": "^6.30.3" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^14.1.0", + "@testing-library/user-event": "^14.6.8", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", diff --git a/src/api/catalogApi.test.ts b/src/api/catalogApi.test.ts new file mode 100644 index 00000000..a0cf9155 --- /dev/null +++ b/src/api/catalogApi.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { fetchCatalog, clearCatalogCache, buildCacheKey } from "./catalogApi"; + +const originalFetch = global.fetch; +const originalSessionStorage = global.sessionStorage; + +const mockSessionStorage: Record = {}; + +function createMockSessionStorage() { + return { + getItem: vi.fn((key: string) => mockSessionStorage[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + mockSessionStorage[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete mockSessionStorage[key]; + }), + key: vi.fn((index: number) => Object.keys(mockSessionStorage)[index] ?? null), + get length() { + return Object.keys(mockSessionStorage).length; + }, + clear: vi.fn(() => { + Object.keys(mockSessionStorage).forEach((key) => delete mockSessionStorage[key]); + }), + }; +} + +function createAbortSignal(): AbortSignal { + return new AbortController().signal; +} + +const TEST_BASE_URL = "https://api.example.com"; + +vi.mock("../config/api", () => ({ + getApiBaseUrl: vi.fn(() => TEST_BASE_URL), +})); + +describe("catalogApi", () => { + beforeEach(() => { + vi.useFakeTimers(); + global.fetch = vi.fn(); + global.sessionStorage = createMockSessionStorage() as unknown as Storage; + mockSessionStorage.length = 0; + clearCatalogCache(); + }); + + afterEach(() => { + vi.useRealTimers(); + global.fetch = originalFetch; + global.sessionStorage = originalSessionStorage; + clearCatalogCache(); + }); + + describe("buildCacheKey", () => { + it("returns base key for empty params", () => { + expect(buildCacheKey({})).toBe("callora-catalog-cache-all"); + }); + + it("includes search param", () => { + expect(buildCacheKey({ search: "geo" })).toBe("callora-catalog-cache-search=geo"); + }); + + it("includes category param", () => { + expect(buildCacheKey({ category: "Finance" })).toBe("callora-catalog-cache-category=Finance"); + }); + + it("includes both search and category", () => { + expect(buildCacheKey({ search: "geo", category: "Finance" })).toBe( + "callora-catalog-cache-search=geo&category=Finance" + ); + }); + + it("trims whitespace and omits empty values", () => { + expect(buildCacheKey({ search: " geo ", category: "" })).toBe("callora-catalog-cache-search=geo"); + expect(buildCacheKey({ search: "", category: " Finance " })).toBe( + "callora-catalog-cache-category=Finance" + ); + }); + }); + + describe("fetchCatalog", () => { + const mockResponse = { + items: [ + { id: "1", name: "API 1", provider: { name: "Provider" }, description: "Desc", pricePerRequest: 0.01 }, + { id: "2", name: "API 2", provider: { name: "Provider" }, description: "Desc", pricePerRequest: 0.02 }, + ], + total: 2, + page: 1, + pageSize: 12, + }; + + beforeEach(() => { + clearCatalogCache(); + (global.fetch as vi.Mock).mockReset(); + }); + + it("fetches with search param", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await fetchCatalog(createAbortSignal(), { search: "geo" }); + + expect(global.fetch).toHaveBeenCalledWith( + `${TEST_BASE_URL}/api/apis?search=geo`, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(result).toEqual(mockResponse); + }); + + it("fetches with category param", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await fetchCatalog(createAbortSignal(), { category: "Finance" }); + + expect(global.fetch).toHaveBeenCalledWith( + `${TEST_BASE_URL}/api/apis?category=Finance`, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(result).toEqual(mockResponse); + }); + + it("fetches with both search and category", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await fetchCatalog(createAbortSignal(), { search: "geo", category: "Finance" }); + + expect(global.fetch).toHaveBeenCalledWith( + `${TEST_BASE_URL}/api/apis?search=geo&category=Finance`, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(result).toEqual(mockResponse); + }); + + it("fetches without params when empty", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await fetchCatalog(createAbortSignal(), {}); + + expect(global.fetch).toHaveBeenCalledWith( + `${TEST_BASE_URL}/api/apis`, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(result).toEqual(mockResponse); + }); + + it("omits empty string params", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + await fetchCatalog(createAbortSignal(), { search: "", category: " " }); + + expect(global.fetch).toHaveBeenCalledWith( + `${TEST_BASE_URL}/api/apis`, + expect.any(Object) + ); + }); + + it("returns cached response on subsequent calls", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + await fetchCatalog(createAbortSignal(), { search: "geo" }); + (global.fetch as vi.Mock).mockClear(); + + const result = await fetchCatalog(createAbortSignal(), { search: "geo" }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(result).toEqual(mockResponse); + }); + + it("different params create different cache keys", async () => { + (global.fetch as vi.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + await fetchCatalog(createAbortSignal(), { search: "geo" }); + await fetchCatalog(createAbortSignal(), { search: "payment" }); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it("throws on non-ok response", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: false, + statusText: "Not Found", + }); + + await expect(fetchCatalog(createAbortSignal(), { search: "geo" })).rejects.toThrow( + "Failed to fetch catalog: Not Found" + ); + }); + + it("aborts when signal is aborted", async () => { + const controller = new AbortController(); + const abortError = new Error("Aborted"); + abortError.name = "AbortError"; + + (global.fetch as vi.Mock).mockRejectedValueOnce(abortError); + + const promise = fetchCatalog(controller.signal, { search: "geo" }); + controller.abort(); + + await expect(promise).rejects.toThrow("Aborted"); + }); + }); + + describe("clearCatalogCache", () => { + it("removes all catalog cache keys", async () => { + const mockResponse = { + items: [], + total: 0, + page: 1, + pageSize: 12, + }; + + (global.fetch as vi.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + await fetchCatalog(createAbortSignal(), { search: "geo" }); + await fetchCatalog(createAbortSignal(), { category: "Finance" }); + + expect(global.sessionStorage.getItem("callora-catalog-cache-search=geo")).toBeTruthy(); + expect(global.sessionStorage.getItem("callora-catalog-cache-category=Finance")).toBeTruthy(); + + clearCatalogCache(); + + expect(global.sessionStorage.getItem("callora-catalog-cache-search=geo")).toBeNull(); + expect(global.sessionStorage.getItem("callora-catalog-cache-category=Finance")).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/src/api/catalogApi.ts b/src/api/catalogApi.ts new file mode 100644 index 00000000..30c0dc41 --- /dev/null +++ b/src/api/catalogApi.ts @@ -0,0 +1,98 @@ +import type { APIItem } from "../data/mockApis"; +import { getApiBaseUrl } from "../config/api"; + +export interface CatalogParams { + search?: string; + category?: string; +} + +export interface CatalogResponse { + items: APIItem[]; + total: number; + page: number; + pageSize: number; +} + +const CACHE_KEY_PREFIX = "callora-catalog-cache-"; + +function getBaseUrl(): string { + const base = getApiBaseUrl(); + return base && base.trim() ? base : "https://api.callora.com"; +} + +export function buildCacheKey(params: CatalogParams): string { + const searchParams = new URLSearchParams(); + if (params.search?.trim()) { + searchParams.set("search", params.search.trim()); + } + if (params.category?.trim()) { + searchParams.set("category", params.category.trim()); + } + return `${CACHE_KEY_PREFIX}${searchParams.toString() || "all"}`; +} + +function getCachedResponse(key: string): CatalogResponse | null { + try { + const cached = sessionStorage.getItem(key); + if (cached) { + return JSON.parse(cached); + } + } catch { + // Ignore parse errors + } + return null; +} + +function setCachedResponse(key: string, response: CatalogResponse): void { + try { + sessionStorage.setItem(key, JSON.stringify(response)); + } catch { + // Ignore quota errors + } +} + +export async function fetchCatalog( + signal: AbortSignal, + params: CatalogParams = {} +): Promise { + const cacheKey = buildCacheKey(params); + const cached = getCachedResponse(cacheKey); + if (cached) { + return cached; + } + + const searchParams = new URLSearchParams(); + if (params.search?.trim()) { + searchParams.set("search", params.search.trim()); + } + if (params.category?.trim()) { + searchParams.set("category", params.category.trim()); + } + + const queryString = searchParams.toString(); + const url = `${getBaseUrl()}/api/apis${queryString ? `?${queryString}` : ""}`; + + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`Failed to fetch catalog: ${response.statusText}`); + } + + const data = (await response.json()) as CatalogResponse; + setCachedResponse(cacheKey, data); + return data; +} + +export function clearCatalogCache(): void { + try { + const keysToRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((key) => sessionStorage.removeItem(key)); + } catch { + // Ignore errors + } +} \ No newline at end of file diff --git a/src/config/api.ts b/src/config/api.ts new file mode 100644 index 00000000..49f996df --- /dev/null +++ b/src/config/api.ts @@ -0,0 +1,7 @@ +export function getApiBaseUrl(): string { + return import.meta.env.VITE_API_BASE_URL ?? ""; +} + +export function isBackendMode(): boolean { + return Boolean(getApiBaseUrl()); +} \ No newline at end of file diff --git a/src/pages/MarketplacePage.test.tsx b/src/pages/MarketplacePage.test.tsx new file mode 100644 index 00000000..c662a182 --- /dev/null +++ b/src/pages/MarketplacePage.test.tsx @@ -0,0 +1,381 @@ +import { render, screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import "@testing-library/jest-dom"; +import { BrowserRouter } from "react-router-dom"; + +const originalFetch = global.fetch; + +function renderMarketplace(MarketplacePage: React.ComponentType) { + return render(, { wrapper: BrowserRouter }); +} + +async function loadMarketplacePage() { + vi.resetModules(); + const mod = await import("./MarketplacePage"); + return mod.default; +} + +describe("MarketplacePage", () => { + describe("Demo mode (no VITE_API_BASE_URL)", () => { + let MarketplacePage: React.ComponentType; + + beforeEach(async () => { + global.fetch = vi.fn(); + vi.stubEnv("VITE_API_BASE_URL", ""); + MarketplacePage = await loadMarketplacePage(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.unstubAllEnvs(); + }); + + it("renders without fetching", () => { + renderMarketplace(MarketplacePage); + expect(global.fetch).not.toHaveBeenCalled(); + expect(screen.getByText("API Marketplace")).toBeInTheDocument(); + }); + + it("filters client-side by search", async () => { + renderMarketplace(MarketplacePage); + await waitFor(() => { + expect(screen.getByText((content) => content.includes("Showing") && content.includes("APIs"))).toBeInTheDocument(); + }, { timeout: 2000 }); + await act(async () => {}); + + const searchInput = screen.getByRole("search").querySelector("input") as HTMLInputElement; + await userEvent.type(searchInput, "Weather"); + + await waitFor(() => { + expect(screen.getByText("WeatherSim API")).toBeInTheDocument(); + expect(screen.queryByText("QuickPay")).not.toBeInTheDocument(); + }, { timeout: 2000 }); + }); + + it("filters client-side by category", async () => { + renderMarketplace(MarketplacePage); + await waitFor(() => { + expect(screen.getByText((content) => content.includes("Showing") && content.includes("APIs"))).toBeInTheDocument(); + }, { timeout: 2000 }); + await act(async () => {}); + + const checkbox = screen.getByLabelText("Data & Analytics"); + await userEvent.click(checkbox); + + expect(screen.getByText("WeatherSim API")).toBeInTheDocument(); + expect(screen.queryByText("QuickPay")).not.toBeInTheDocument(); + }); + + it("filters client-side by multiple categories", async () => { + renderMarketplace(MarketplacePage); + await waitFor(() => { + expect(screen.getByText((content) => content.includes("Showing") && content.includes("APIs"))).toBeInTheDocument(); + }, { timeout: 2000 }); + await act(async () => {}); + + const checkbox1 = screen.getByLabelText("Data & Analytics"); + const checkbox2 = screen.getByLabelText("Payment Processing"); + await userEvent.click(checkbox1); + await userEvent.click(checkbox2); + + expect(screen.getByText("WeatherSim API")).toBeInTheDocument(); + expect(screen.getByText("QuickPay")).toBeInTheDocument(); + }); + + it("filters client-side by price range", async () => { + renderMarketplace(MarketplacePage); + await waitFor(() => { + expect(screen.getByText((content) => content.includes("Showing") && content.includes("APIs"))).toBeInTheDocument(); + }, { timeout: 2000 }); + await act(async () => {}); + + const minInput = screen.getByPlaceholderText("min"); + const maxInput = screen.getByPlaceholderText("max"); + await userEvent.type(minInput, "0.005"); + await userEvent.type(maxInput, "0.015"); + + expect(screen.getByText("WeatherSim API")).toBeInTheDocument(); + expect(screen.queryByText("QuickPay")).not.toBeInTheDocument(); + }); + + it("sorts by price ascending", async () => { + renderMarketplace(MarketplacePage); + await waitFor(() => { + expect(screen.getByText((content) => content.includes("Showing") && content.includes("APIs"))).toBeInTheDocument(); + }, { timeout: 2000 }); + await act(async () => {}); + + const selects = screen.getAllByRole("combobox"); + const sortSelect = selects[selects.length - 1]; + await userEvent.selectOptions(sortSelect, "priceAsc"); + + const cards = screen.getAllByRole("button", { name: /View details for/ }); + // Verify sorting by checking that prices are in ascending order + const prices = cards.map((card) => { + const text = card.textContent || ""; + const match = text.match(/\$([\d.]+)\s*\/?\s*call/); + return match ? parseFloat(match[1]) : Infinity; + }); + for (let i = 1; i < prices.length; i++) { + expect(prices[i]).toBeGreaterThanOrEqual(prices[i - 1]); + } + }); + + it("shows empty state when no results", async () => { + renderMarketplace(MarketplacePage); + await waitFor(() => { + expect(screen.getByText((content) => content.includes("Showing") && content.includes("APIs"))).toBeInTheDocument(); + }, { timeout: 2000 }); + await act(async () => {}); + + const searchInput = screen.getByRole("search").querySelector("input") as HTMLInputElement; + await userEvent.type(searchInput, "nonexistent"); + + await waitFor(() => { + expect(screen.getByText(/No results found/)).toBeInTheDocument(); + }, { timeout: 2000 }); + expect(screen.getByRole("button", { name: /Clear all filters/i })).toBeInTheDocument(); + }); + + it("clear filters resets all filters", async () => { + renderMarketplace(MarketplacePage); + await waitFor(() => { + expect(screen.getByText((content) => content.includes("Showing") && content.includes("APIs"))).toBeInTheDocument(); + }, { timeout: 2000 }); + await act(async () => {}); + + const searchInput = screen.getByRole("search").querySelector("input") as HTMLInputElement; + await userEvent.type(searchInput, "Weather"); + + await waitFor(() => { + expect(screen.getByText("WeatherSim API")).toBeInTheDocument(); + }, { timeout: 2000 }); + + await userEvent.click(screen.getByRole("button", { name: /Clear filters/i })); + + expect(searchInput.value).toBe(""); + expect(screen.getByText("WeatherSim API")).toBeInTheDocument(); + expect(screen.getByText("QuickPay")).toBeInTheDocument(); + }); + }); + + describe("Backend mode (VITE_API_BASE_URL set)", () => { + const mockResponse = { + items: [ + { id: "1", name: "Backend API 1", provider: { name: "Provider" }, description: "Desc", pricePerRequest: 0.01, category: "Finance" }, + { id: "2", name: "Backend API 2", provider: { name: "Provider" }, description: "Desc", pricePerRequest: 0.02, category: "Data & Analytics" }, + ], + total: 2, + page: 1, + pageSize: 12, + }; + + let MarketplacePage: React.ComponentType; + + beforeEach(async () => { + global.fetch = vi.fn(); + vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com"); + MarketplacePage = await loadMarketplacePage(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.unstubAllEnvs(); + }); + + it("fetches catalog on mount", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + renderMarketplace(MarketplacePage); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/api/apis", + expect.any(Object) + ); + }); + + await waitFor(() => expect(screen.getByText("Backend API 1")).toBeInTheDocument()); + }); + + it("sends search param after debounce", async () => { + (global.fetch as vi.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + renderMarketplace(MarketplacePage); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + (global.fetch as vi.Mock).mockClear(); + + const searchInput = screen.getByRole("search").querySelector("input") as HTMLInputElement; + await userEvent.type(searchInput, "geo"); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/api/apis?search=geo", + expect.any(Object) + ); + }, { timeout: 1000 }); + }); + + it("sends category param when single category selected", async () => { + (global.fetch as vi.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + renderMarketplace(MarketplacePage); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + (global.fetch as vi.Mock).mockClear(); + + const checkbox = screen.getByLabelText("Finance"); + await userEvent.click(checkbox); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/api/apis?category=Finance", + expect.any(Object) + ); + }); + }); + + it("does not send category param when multiple categories selected", async () => { + (global.fetch as vi.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + renderMarketplace(MarketplacePage); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + (global.fetch as vi.Mock).mockClear(); + + const checkbox1 = screen.getByLabelText("Finance"); + const checkbox2 = screen.getByLabelText("Data & Analytics"); + await userEvent.click(checkbox1); + await userEvent.click(checkbox2); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/api/apis", + expect.any(Object) + ); + }); + }); + + it("aborts previous request on rapid changes", async () => { + let resolveFirst: (value: Response) => void; + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + (global.fetch as vi.Mock) + .mockReturnValueOnce(firstPromise) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + renderMarketplace(MarketplacePage); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + const searchInput = screen.getByRole("search").querySelector("input") as HTMLInputElement; + await userEvent.type(searchInput, "a"); + await waitFor(() => {}, { timeout: 350 }); + await userEvent.type(searchInput, "ab"); + await waitFor(() => {}, { timeout: 350 }); + await userEvent.type(searchInput, "abc"); + await waitFor(() => {}, { timeout: 350 }); + + expect(global.fetch).toHaveBeenCalledTimes(3); + + resolveFirst!({ + ok: true, + json: () => Promise.resolve({ ...mockResponse, items: [] }), + }); + + await act(async () => { + await Promise.resolve(); + }); + }); + + it("shows error state on fetch failure", async () => { + (global.fetch as vi.Mock).mockRejectedValueOnce(new Error("Network error")); + + renderMarketplace(MarketplacePage); + + await waitFor(() => { + expect(screen.getByText(/Failed to fetch/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument(); + }); + + it("uses cached response on repeat request", async () => { + (global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + renderMarketplace(MarketplacePage); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + const searchInput = screen.getByRole("search").querySelector("input") as HTMLInputElement; + await userEvent.type(searchInput, "test"); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(2); + }, { timeout: 1000 }); + + (global.fetch as vi.Mock).mockClear(); + + searchInput.value = ""; + await userEvent.type(searchInput, "test"); + await waitFor(() => {}, { timeout: 350 }); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("omits whitespace-only search param", async () => { + (global.fetch as vi.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + renderMarketplace(MarketplacePage); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + (global.fetch as vi.Mock).mockClear(); + + const searchInput = screen.getByRole("search").querySelector("input") as HTMLInputElement; + await userEvent.type(searchInput, " "); + await waitFor(() => {}, { timeout: 350 }); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/api/apis", + expect.any(Object) + ); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx index 572265e0..aa97edbd 100644 --- a/src/pages/MarketplacePage.tsx +++ b/src/pages/MarketplacePage.tsx @@ -1,21 +1,80 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import ApiCard, { ApiCardSkeleton } from "../components/ApiCard"; import useDocumentTitle from "../hooks/useDocumentTitle"; import SearchBar from "../components/SearchBar"; import FiltersSidebar from "../components/FiltersSidebar"; -import EmptyState, { type EmptyStateProps } from "../components/EmptyState"; -import MOCK_APIS, { type APIItem } from "../data/mockApis"; +import EmptyState from "../components/EmptyState"; import { useDebounce } from "../hooks/useDebounce"; -import { LOADING_DELAY_MS } from "../config/constants"; +import { fetchCatalog, type CatalogParams, type CatalogResponse } from "../api/catalogApi"; +import type { APIItem } from "../data/mockApis"; +import MOCK_APIS from "../data/mockApis"; + +const API_BASE = import.meta.env.VITE_API_BASE_URL; + +const isBackendMode = Boolean(API_BASE); + +function getCategoryFromSelection(selectedCategories: Set): string | undefined { + return selectedCategories.size === 1 ? selectedCategories.values().next().value : undefined; +} + +function filterItemsClientSide( + items: APIItem[], + debouncedSearch: string, + selectedCategories: Set, + minPrice: number | null, + maxPrice: number | null, + popularity: string, + sort: string +): APIItem[] { + let filtered = items.slice(); + + if (debouncedSearch.trim()) { + const q = debouncedSearch.toLowerCase(); + filtered = filtered.filter((a) => { + return ( + a.name.toLowerCase().includes(q) || + a.description.toLowerCase().includes(q) || + a.provider?.name?.toLowerCase().includes(q) || + (a.tags || []).some((t) => t.toLowerCase().includes(q)) + ); + }); + } + + if (selectedCategories.size > 0) { + filtered = filtered.filter((a) => selectedCategories.has(a.category ?? "")); + } + + const hasInvertedPrice = minPrice !== null && maxPrice !== null && minPrice > maxPrice; + if (!hasInvertedPrice) { + if (minPrice !== null) filtered = filtered.filter((a) => a.pricePerRequest >= minPrice); + if (maxPrice !== null) filtered = filtered.filter((a) => a.pricePerRequest <= maxPrice); + } + + if (popularity === "mostUsed") { + filtered = filtered.sort((a, b) => (b.usageCount ?? 0) - (a.usageCount ?? 0)); + } else if (popularity === "newest") { + filtered = filtered.sort( + (a, b) => + Date.parse(b.createdAt ?? "1970-01-01") - Date.parse(a.createdAt ?? "1970-01-01"), + ); + } + + if (sort === "priceAsc") filtered = filtered.sort((a, b) => a.pricePerRequest - b.pricePerRequest); + if (sort === "priceDesc") filtered = filtered.sort((a, b) => b.pricePerRequest - a.pricePerRequest); + if (sort === "popularity") filtered = filtered.sort((a, b) => (b.usageCount ?? 0) - (a.usageCount ?? 0)); + if (sort === "newest") + filtered = filtered.sort( + (a, b) => Date.parse(b.createdAt ?? "1970-01-01") - Date.parse(a.createdAt ?? "1970-01-01"), + ); + + return filtered; +} export default function MarketplacePage(): JSX.Element { - useDocumentTitle('Marketplace – Callora', 'Explore APIs on the Callora marketplace, discover and integrate APIs for your applications.'); + useDocumentTitle("Marketplace – Callora", "Explore APIs on the Callora marketplace, discover and integrate APIs for your applications."); const [search, setSearch] = useState(""); - // Debounce search input to prevent excessive re-renders on large lists const debouncedSearch = useDebounce(search, 300); - const [selectedCategories, setSelectedCategories] = useState>( - new Set(), - ); + const [selectedCategories, setSelectedCategories] = useState>(new Set()); const [minPrice, setMinPrice] = useState(null); const [maxPrice, setMaxPrice] = useState(null); const [popularity, setPopularity] = useState("any"); @@ -24,16 +83,10 @@ export default function MarketplacePage(): JSX.Element { const [showFiltersMobile, setShowFiltersMobile] = useState(false); const [isLoading, setIsLoading] = useState(true); const [fetchError, setFetchError] = useState(null); + const [catalogItems, setCatalogItems] = useState([]); - useEffect(() => { - // Simulate initial data loading with occasional error for testing - const timer = setTimeout(() => { - setIsLoading(false); - // Uncomment below to test error state - // setFetchError("Failed to fetch APIs. Please try again."); - }, LOADING_DELAY_MS); - return () => clearTimeout(timer); - }, []); + const abortRef = useRef(null); + const requestSeqRef = useRef(0); const toggleCategory = (c: string) => { const copy = new Set(selectedCategories); @@ -51,10 +104,6 @@ export default function MarketplacePage(): JSX.Element { setSearch(""); }; - /** - * Determine if any filters are currently active. - * Used to distinguish between "no APIs exist" vs "filters too narrow" empty states. - */ const hasActiveFilters = () => { return ( search.trim() !== "" || @@ -65,97 +114,86 @@ export default function MarketplacePage(): JSX.Element { ); }; - /** - * Handle retry for fetch errors. - * Clears error state and simulates refetch. - */ const handleRetryFetch = async () => { setFetchError(null); setIsLoading(true); - // Simulate network request await new Promise((resolve) => setTimeout(resolve, 500)); setIsLoading(false); }; - const filtered = useMemo(() => { - let items = MOCK_APIS.slice(); - + const fetchParams = useMemo(() => { + const params: CatalogParams = {}; if (debouncedSearch.trim()) { - const q = debouncedSearch.toLowerCase(); - items = items.filter((a) => { - return ( - a.name.toLowerCase().includes(q) || - a.description.toLowerCase().includes(q) || - a.provider?.name?.toLowerCase().includes(q) || - (a.tags || []).some((t) => t.toLowerCase().includes(q)) - ); - }); + params.search = debouncedSearch.trim(); } - - if (selectedCategories.size > 0) { - items = items.filter((a) => selectedCategories.has(a.category ?? "")); + const category = getCategoryFromSelection(selectedCategories); + if (category) { + params.category = category; } + return params; + }, [debouncedSearch, selectedCategories]); - const hasInvertedPrice = minPrice !== null && maxPrice !== null && minPrice > maxPrice; - if (!hasInvertedPrice) { - if (minPrice !== null) - items = items.filter((a) => a.pricePerRequest >= minPrice); - if (maxPrice !== null) - items = items.filter((a) => a.pricePerRequest <= maxPrice); + useEffect(() => { + if (!isBackendMode) { + setCatalogItems(MOCK_APIS); + setIsLoading(false); + return; } - // popularity-based ordering - if (popularity === "mostUsed") { - items = items.sort((a, b) => (b.usageCount ?? 0) - (a.usageCount ?? 0)); - } else if (popularity === "newest") { - items = items.sort( - (a, b) => - Date.parse(b.createdAt ?? "1970-01-01") - - Date.parse(a.createdAt ?? "1970-01-01"), - ); - } + const seq = ++requestSeqRef.current; + const controller = new AbortController(); + abortRef.current = controller; - // explicit sort options - if (sort === "priceAsc") - items = items.sort((a, b) => a.pricePerRequest - b.pricePerRequest); - if (sort === "priceDesc") - items = items.sort((a, b) => b.pricePerRequest - a.pricePerRequest); - if (sort === "popularity") - items = items.sort((a, b) => (b.usageCount ?? 0) - (a.usageCount ?? 0)); - if (sort === "newest") - items = items.sort( - (a, b) => - Date.parse(b.createdAt ?? "1970-01-01") - - Date.parse(a.createdAt ?? "1970-01-01"), - ); + setIsLoading(true); + setFetchError(null); + + fetchCatalog(controller.signal, fetchParams) + .then((response: CatalogResponse) => { + if (seq !== requestSeqRef.current) return; + setCatalogItems(response.items); + setIsLoading(false); + }) + .catch((err: Error) => { + if (seq !== requestSeqRef.current) return; + if (err.name === "AbortError") return; + setFetchError(err.message); + setIsLoading(false); + }); - return items; - }, [debouncedSearch, selectedCategories, minPrice, maxPrice, popularity, sort]); + return () => { + controller.abort(); + }; + }, [fetchParams, isBackendMode]); + + const filtered = useMemo(() => { + return filterItemsClientSide( + catalogItems, + debouncedSearch, + selectedCategories, + minPrice, + maxPrice, + popularity, + sort + ); + }, [catalogItems, debouncedSearch, selectedCategories, minPrice, maxPrice, popularity, sort]); const displayedItems = filtered.slice(0, shown); const handleViewDetails = (api: APIItem) => { history.pushState({}, "", `/details/${api.id}`); - // inform our small client router that the URL changed window.dispatchEvent(new PopStateEvent("popstate")); }; return (
- {/* Top row: title + search only */} -
+

API Marketplace

- {/* Bottom: filters left, content right */} -
+