diff --git a/src/api/catalogApi.test.ts b/src/api/catalogApi.test.ts index d5dbdd3..a34fe00 100644 --- a/src/api/catalogApi.test.ts +++ b/src/api/catalogApi.test.ts @@ -1,47 +1,123 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { adaptBackendApi, + buildCacheKey, + clearCatalogCache, createCatalogApi, fetchCatalog, normalizeCatalog, - CATALOG_PATH, CatalogFetchError, - isCatalogAbortError, } from "./catalogApi"; -import MOCK_APIS from "../data/mockApis"; import * as constants from "../config/constants"; +import MOCK_APIS from "../data/mockApis"; -describe("catalogApi and adaptBackendApi (Issue #1327)", () => { +const originalFetch = globalThis.fetch; +const originalSessionStorage = globalThis.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]); + }), + }; +} + +describe("catalogApi module", () => { beforeEach(() => { vi.restoreAllMocks(); + globalThis.sessionStorage = createMockSessionStorage() as unknown as Storage; + Object.keys(mockSessionStorage).forEach((k) => delete mockSessionStorage[k]); + clearCatalogCache(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.sessionStorage = originalSessionStorage; + }); + + 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("clearCatalogCache", () => { + it("removes all catalog cache keys from sessionStorage", () => { + globalThis.sessionStorage.setItem("callora-catalog-cache-search=geo", "[]"); + globalThis.sessionStorage.setItem("callora-catalog-cache-category=Finance", "[]"); + globalThis.sessionStorage.setItem("unrelated-key", "val"); + + clearCatalogCache(); + + expect(globalThis.sessionStorage.getItem("callora-catalog-cache-search=geo")).toBeNull(); + expect(globalThis.sessionStorage.getItem("callora-catalog-cache-category=Finance")).toBeNull(); + expect(globalThis.sessionStorage.getItem("unrelated-key")).toBe("val"); + }); }); describe("adaptBackendApi", () => { - it("adapts backend row with number id, developer mapping, logo, status and endpoint pricing (Scenario 1)", () => { - const backendRow = { - id: 7, - name: "Payments Engine", - description: "Fast multi-currency settlement", - base_url: "https://pay.example.com", - logo_url: "https://pay.example.com/logo.png", + it("converts a full backend API row into an APIItem (Scenario 5)", () => { + const row = { + id: "10", + name: "Acme Payments", + description: "Fast transfers", category: "Finance", - status: "operational", developer: { - name: "Acme Payments", + name: "Acme Corp", website: "https://acme.example.com", - description: "Fintech innovator", }, + logo_url: "https://pay.example.com/logo.png", + base_url: "https://pay.example.com", + status: "operational", endpoints: [ - { path: "/charge", price: 0.05 }, - { path: "/refund", price_per_call: 0.02 }, + { path: "/pay", price_per_call: 0.02 }, + { path: "/status", price_per_call: 0.05 }, ], }; - const item = adaptBackendApi(backendRow); + const item = adaptBackendApi(row); expect(item).not.toBeNull(); - expect(item?.id).toBe("7"); - expect(item?.name).toBe("Payments Engine"); - expect(item?.provider.name).toBe("Acme Payments"); + expect(item?.id).toBe("10"); + expect(item?.name).toBe("Acme Payments"); + expect(item?.description).toBe("Fast transfers"); + expect(item?.category).toBe("Finance"); + expect(item?.provider.name).toBe("Acme Corp"); expect(item?.provider.url).toBe("https://acme.example.com"); expect(item?.provider.avatar).toBe("https://pay.example.com/logo.png"); expect(item?.baseUrl).toBe("https://pay.example.com"); @@ -67,7 +143,7 @@ describe("catalogApi and adaptBackendApi (Issue #1327)", () => { expect(adaptBackendApi({ id: null, name: "Null API" })).toBeNull(); expect(adaptBackendApi({ id: undefined, name: "Undef API" })).toBeNull(); expect(adaptBackendApi({ id: "", name: "Empty ID" })).toBeNull(); - expect(adaptBackendApi({ id: 5 })).toBeNull(); // missing name + expect(adaptBackendApi({ id: 5 })).toBeNull(); expect(adaptBackendApi(null)).toBeNull(); expect(adaptBackendApi("not an object")).toBeNull(); }); @@ -163,6 +239,35 @@ describe("catalogApi and adaptBackendApi (Issue #1327)", () => { expect(items[0].id).toBe("1"); }); + it("serialises search and category query params correctly (Issue #1328)", async () => { + let requestedUrl = ""; + const mockFetch: typeof fetch = vi.fn().mockImplementation((url: string) => { + requestedUrl = url; + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ id: 1, name: "Filtered API" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }); + + const api = createCatalogApi({ + baseUrl: "https://api.callora.test", + fetchImpl: mockFetch, + }); + + await api.fetchCatalog(undefined, { search: "geo", category: "Finance" }); + expect(requestedUrl).toBe("https://api.callora.test/api/apis?search=geo&category=Finance"); + + await api.fetchCatalog(undefined, { search: " " }); + expect(requestedUrl).toBe("https://api.callora.test/api/apis"); + }); + it("supports conditional request 304 keeping cached list without flashing empty (Scenario 7)", async () => { let requestHeaders: Record = {}; let callCount = 0; @@ -187,7 +292,6 @@ describe("catalogApi and adaptBackendApi (Issue #1327)", () => { ), ); } else { - // Second call: 304 Not Modified return Promise.resolve( new Response(null, { status: 304, diff --git a/src/api/catalogApi.ts b/src/api/catalogApi.ts index 2cebc49..e6f466d 100644 --- a/src/api/catalogApi.ts +++ b/src/api/catalogApi.ts @@ -7,7 +7,7 @@ * compiled into the bundle: APIs are published at runtime, so the page has to * ask the backend what exists right now. This module owns that wire protocol so * the page component never touches `fetch` directly and the request stays - * unit-testable via the injected {@link CreateCatalogAPiOptions.fetchImpl}. + * unit-testable via the injected {@link CreateCatalogApiOptions.fetchImpl}. * * The shape mirrors `quotaApi.ts` deliberately: a factory that returns an * object with a catalogue method and a detail method, and a shared singleton @@ -30,6 +30,57 @@ export const CATALOG_PATH = "/api/apis"; /** Key the last good catalogue is cached under (scoped per account). */ export const CATALOG_CACHE_KEY = "catalog"; +export const CACHE_KEY_PREFIX = "callora-catalog-cache-"; + +export interface CatalogParams { + search?: string; + category?: string; +} + +export interface CatalogResponse { + items: APIItem[]; + total?: number; + page?: number; + pageSize?: number; +} + +/** + * Builds a unique cache key including server query parameters. + * This ensures a filtered response never overwrites an unfiltered cache. + */ +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()); + } + const qs = searchParams.toString(); + return `${CACHE_KEY_PREFIX}${qs || "all"}`; +} + +/** + * Clears cached catalog responses from sessionStorage. + */ +export function clearCatalogCache(): void { + if (typeof sessionStorage === "undefined") return; + 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 + } +} + +export type ApiDetail = APIItem; + /** * Result of a single-API detail fetch. * @@ -39,17 +90,18 @@ export const CATALOG_CACHE_KEY = "catalog"; * {@link CatalogFetchError}. */ export type ApiDetailResult = - | { status: "ready"; api: APIItem } - | { status: "not-found" }; + | { status: "ready"; kind?: "ready"; api: APIItem } + | { status: "not-found"; kind: "not-found"; api?: undefined }; export interface CatalogApi { /** - * Fetches the full marketplace catalogue. + * Fetches the marketplace catalogue. * * @param signal Aborts the request; rejects with an `AbortError` when * aborted before the response is read. + * @param params Optional server-side search and category filter parameters. */ - fetchCatalog(signal?: AbortSignal): Promise; + fetchCatalog(signal?: AbortSignal, params?: CatalogParams): Promise; /** * Fetches a single API by id. @@ -123,86 +175,110 @@ export interface BackendEndpoint { [key: string]: unknown; } -export interface BackendApiRow { - id: string | number; - name: string; - description?: string; - base_url?: string; - logo_url?: string; - category?: string; - status?: string; - developer?: BackendDeveloper; - endpoints?: BackendEndpoint[]; - [key: string]: unknown; -} - /** - * Adapts a snake_case, cursor-paginated Callora-Backend API row into the - * frontend `APIIdem` contract. + * Adapts a raw backend API row into the frontend's {@link APIItem}. * - * Rules: - * - Stringifies `id`. - * - Maps `developer.name` to `provider.name` (fallback "Unknown provider") and - * `developer.website` to `provider.url`. - * - Maps `logo_url` to `provider.avatar` and `base_url` to `baseUrl`. - * - Maps backend `status` to UI enum, unknown values become `undefined`. - * - Derives `pricePerRequest` from the minimum endpoint price when endpoints - * carry pricing, and otherwise leaves it as 0 with `pricingOnRequest: true`. - * - Drops invalid rows (e.g. `id == null`, missing/empty `name`) by returning `null`. + * Returns `null` if the row lacks an `id` or `name`, dropping broken rows + * before they can render with missing fields. */ -export function adaptBackendApi(row: unknown): APIItem | null { - if (!isRecord(row)) return null; +export function adaptBackendApi(raw: unknown): APIItem | null { + if (!isRecord(raw)) return null; + + const rawId = raw.id; + if (rawId === null || rawId === undefined || rawId === "") return null; + const id = String(rawId).trim(); + if (!id) return null; - if (row.id === null || row.id === undefined) return null; - const idStr = String(row.id).trim(); - if (!idStr) return null; + const name = typeof raw.name === "string" ? raw.name.trim() : ""; + if (!name) return null; - if (typeof row.name !== "string" || !row.name.trim()) return null; + const description = + typeof raw.description === "string" ? raw.description : ""; + const category = typeof raw.category === "string" ? raw.category : undefined; - const developer = isRecord(row.developer) ? (row.developer as BackendDeveloper) : null; - const providerName = developer?.name?.trim() || "Unknown provider"; - const providerUrl = developer?.website?.trim() || undefined; - const providerAvatar = typeof row.logo_url === "string" ? row.logo_url : undefined; - const baseUrl = typeof row.base_url === "string" ? row.base_url : undefined; + let providerName = "Unknown provider"; + let providerUrl: string | undefined; + let providerAvatar: string | undefined; + + if (isRecord(raw.developer)) { + const dev = raw.developer as BackendDeveloper; + if (typeof dev.name === "string" && dev.name.trim()) { + providerName = dev.name.trim(); + } + if (typeof dev.website === "string" && dev.website.trim()) { + providerUrl = dev.website.trim(); + } + } else if (isRecord(raw.provider)) { + const prov = raw.provider as Record; + if (typeof prov.name === "string" && prov.name.trim()) { + providerName = prov.name.trim(); + } + if (typeof prov.url === "string" && prov.url.trim()) { + providerUrl = prov.url.trim(); + } + if (typeof prov.avatar === "string" && prov.avatar.trim()) { + providerAvatar = prov.avatar.trim(); + } + } let status: APIItem["status"] = undefined; - if (row.status === "operational" || row.status === "degraded" || row.status === "maintenance") { - status = row.status; + if ( + raw.status === "operational" || + raw.status === "degraded" || + raw.status === "maintenance" + ) { + status = raw.status; } - // Derive pricing from endpoints + const baseUrl = + typeof raw.baseUrl === "string" + ? raw.baseUrl + : typeof raw.base_url === "string" + ? raw.base_url + : undefined; + + if (!providerAvatar && typeof raw.logo_url === "string") { + providerAvatar = raw.logo_url; + } + + // Derive price per request from endpoints if present let pricePerRequest = 0; let pricingOnRequest = false; - const endpoints = Array.isArray(row.endpoints) ? row.endpoints : []; - const prices: number[] = []; - for (const ep of endpoints) { - if (isRecord(ep)) { - const p = ep.price ?? ep.price_per_call ?? ep.price_per_request ?? ep.pricing ?? ep.cost; - if (typeof p === "number" && Number.isFinite(p) && p >= 0) { - prices.push(p); - } + const endpoints = Array.isArray(raw.endpoints) ? raw.endpoints : []; + if (endpoints.length > 0) { + const prices = endpoints + .map((ep: unknown) => { + if (!isRecord(ep)) return null; + const p = + ep.price ?? ep.price_per_call ?? ep.price_per_request ?? ep.pricing ?? ep.cost; + return typeof p === "number" && !Number.isNaN(p) ? p : null; + }) + .filter((p): p is number => p !== null); + + if (prices.length > 0) { + pricePerRequest = Math.min(...prices); + } else { + pricingOnRequest = true; } - } - - if (prices.length > 0) { - pricePerRequest = Math.min(...prices); - pricingOnRequest = false; - } else { - pricePerRequest = 0; - pricingOnRequest = true; + } else if (typeof raw.pricePerRequest === "number") { + pricePerRequest = raw.pricePerRequest; + } else if (typeof raw.price_per_request === "number") { + pricePerRequest = raw.price_per_request; + } else if (typeof raw.price === "number") { + pricePerRequest = raw.price; } return { - id: idStr, - name: row.name.trim(), + id, + name, + description, + category, provider: { name: providerName, url: providerUrl, avatar: providerAvatar, }, - description: typeof row.description === "string" ? row.description : "", - category: typeof row.category === "string" ? row.category : undefined, status, baseUrl, pricePerRequest, @@ -214,14 +290,7 @@ export function adaptBackendApi(row: unknown): APIItem | null { /** * Normalises the catalogue payload. * - * The backend may answer with a bare array or wrap it (`{ data: [...] }` / - * `{ apis: [...] }`); both are accepted so a response-shape change is a - * one-line fix here rather than a page rewrite. Entries that are not objects - * carrying the two fields the marketplace cannot render without (`id` and - * `name`) are dropped instead of poisoning the list, so one malformed row does - * not take the whole page down. - * - * Accepts both legacy `APIIdem` shapes and backend rows via `adaptBackendApi`. + * Accepts both legacy `APIItem` shapes and backend rows via `adaptBackendApi`. */ export function normalizeCatalog(payload: unknown): APIItem[] { const list = Array.isArray(payload) @@ -230,7 +299,9 @@ export function normalizeCatalog(payload: unknown): APIItem[] { ? payload.data : isRecord(payload) && Array.isArray(payload.apis) ? payload.apis - : null; + : isRecord(payload) && Array.isArray(payload.items) + ? payload.items + : null; if (!list) { throw new CatalogFetchError("The catalogue response was not a list of APIs."); @@ -240,7 +311,6 @@ export function normalizeCatalog(payload: unknown): APIItem[] { for (const item of list) { if (!isRecord(item)) continue; - // Legacy shape check: string id, string name, provider object with string name if ( typeof item.id === "string" && typeof item.name === "string" && @@ -261,10 +331,6 @@ export function normalizeCatalog(payload: unknown): APIItem[] { /** * Normalises a single-API detail payload into the frontend `APIItem`. - * - * The backend returns a single object for `GET /api/apis/:id`, but accepts a - * wrapper (`{ data: {...} }`) in case the response shape changes. Returns `null` - * if the payload cannot be adapted. */ export function normalizeApiDetail(payload: unknown): APIItem | null { if (isRecord(payload) && isRecord(payload.data)) { @@ -282,8 +348,8 @@ export function createCatalogApi( ): CatalogApi { const fetchImpl = options.fetchImpl ?? globalThis.fetch; - let cachedEtg: string | null = null; - let cachedList: APIIdem[] = []; + const cachedEtags = new Map(); + const cachedLists = new Map(); const resolveBase = (): string => options.baseUrl !== undefined @@ -291,20 +357,32 @@ export function createCatalogApi( : ""; return { - async fetchCatalog(signal) { + async fetchCatalog(signal, params = {}) { if (signal?.aborted) { throw abortError(); } + 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 pathWithQuery = queryString ? `${CATALOG_PATH}?${queryString}` : CATALOG_PATH; + const headers: Record = { Accept: "application/json", }; - if (cachedEtg) { - headers["If-None-Match"] = cachedEtg; + const etag = cachedEtags.get(pathWithQuery); + if (etag) { + headers["If-None-Match"] = etag; } const base = resolveBase(); - const targetUrl = base ? `${base}${CATALOG_PATH}` : apiUrl(CATALOG_PATH); + const targetUrl = base ? `${base}${pathWithQuery}` : apiUrl(pathWithQuery); let response: Response; try { @@ -325,19 +403,19 @@ export function createCatalogApi( } if (response.status === 304) { - return cachedList; + return cachedLists.get(pathWithQuery) ?? []; } if (!response.ok) { throw new CatalogFetchError( - "The marketplace service is unavailable right now.", + `Failed to fetch catalog: ${response.statusText || response.status}`, response.status, ); } - const etag = response.headers.get("etag") ?? response.headers.get("ETag"); - if (etag) { - cachedEtag = etag; + const newEtag = response.headers.get("etag") ?? response.headers.get("ETag"); + if (newEtag) { + cachedEtags.set(pathWithQuery, newEtag); } let payload: unknown; @@ -356,8 +434,9 @@ export function createCatalogApi( throw abortError(); } - const items = normalizeCatalogue(payload); - cachedList = items; + const items = normalizeCatalog(payload); + cachedLists.set(pathWithQuery, items); + return items; }, @@ -388,11 +467,8 @@ export function createCatalogApi( throw abortError(); } - // 404 (unknown id) and 400 (non-integer id) both mean "this API does - // not exist for the user", so they resolve to the typed not-found - // result rather than rejecting. if (response.status === 404 || response.status === 400) { - return { status: "not-found" }; + return { status: "not-found", kind: "not-found" }; } if (!response.ok) { @@ -424,7 +500,7 @@ export function createCatalogApi( "The marketplace service returned an unreadable response.", ); } - return { status: "ready", api }; + return { status: "ready", kind: "ready", api }; }, }; } @@ -441,24 +517,21 @@ export const catalogApi: CatalogApi = createCatalogApi({ * can `vi.mock` this module with a single replacement and drive every * loading / success / failure path of the page. */ -export async function fetchCatalog(signal?: AbortSignal): Promise { +export async function fetchCatalog( + signal?: AbortSignal, + params?: CatalogParams, +): Promise { // No backend configured (static demo deployment): use the bundled sample // catalogue instead of firing a request that can only fail. if (!API_BASE) return MOCK_APIS; // When API_BASE is configured, errors must reject so the UI surfaces its // error or stale-data states rather than hiding failures behind mock data. - return await catalogAPI["fetchCatalog"](signal); + return await catalogApi.fetchCatalog(signal, params); } /** * Reads a single API detail from `GET /api/apis/:id`. - * - * Exported as a standalone function so the detail page can `iv.mock` this - * module and drive loading / ready / not-found / error states. - * - * Demo mode (no `API_BASE`) resolves from the bundled `MOCK_APIS` without - * touching the network. */ export async function fetchApiDetail( id: string, @@ -468,11 +541,11 @@ export async function fetchApiDetail( throw abortError(); } - // No backend configured (static demo deployment): resolve from the - // bundled sample catalogue instead of firing a request that can only fail. if (!API_BASE) { const found = MOCK_APIS.find((api) => api.id === id); - return found ? { status: "ready", api: found } : { status: "not-found" }; + return found + ? { status: "ready", kind: "ready", api: found } + : { status: "not-found", kind: "not-found" }; } return await catalogApi.fetchApiDetail(id, signal); diff --git a/src/config/api.ts b/src/config/api.ts new file mode 100644 index 0000000..49f996d --- /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 index 02bdba9..0a5c964 100644 --- a/src/pages/MarketplacePage.test.tsx +++ b/src/pages/MarketplacePage.test.tsx @@ -1,4 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi, beforeEach } from "vitest"; import MarketplacePage from "./MarketplacePage"; @@ -34,7 +35,19 @@ const TEST_BACKEND_APIS: APIItem[] = [ }, ]; -describe("MarketplacePage backend catalogue integration (Issue #1327)", () => { +function renderMarketplace() { + return render( + + + + + + + , + ); +} + +describe("MarketplacePage backend catalogue integration (Issue #1327 & #1328)", () => { beforeEach(() => { vi.restoreAllMocks(); localStorage.clear(); @@ -44,15 +57,7 @@ describe("MarketplacePage backend catalogue integration (Issue #1327)", () => { vi.spyOn(constants, "API_BASE", "get").mockReturnValue("https://api.callora.com"); vi.spyOn(catalogApiModule, "fetchCatalog").mockResolvedValue(TEST_BACKEND_APIS); - render( - - - - - - - , - ); + renderMarketplace(); await waitFor(() => { expect(screen.getAllByText("Live Blockchain Indexer").length).toBeGreaterThan(0); @@ -68,15 +73,7 @@ describe("MarketplacePage backend catalogue integration (Issue #1327)", () => { new catalogApiModule.CatalogFetchError("The marketplace service is unavailable right now.", 500), ); - render( - - - - - - - , - ); + renderMarketplace(); await waitFor(() => { expect( @@ -84,25 +81,134 @@ describe("MarketplacePage backend catalogue integration (Issue #1327)", () => { ).toBeInTheDocument(); }); - // Verify mock data is NOT silently displayed when a configured backend fails expect(screen.queryByText("GeoLocate")).not.toBeInTheDocument(); }); it("displays the 'Sample data' label when API_BASE is empty (demo mode)", async () => { vi.spyOn(constants, "API_BASE", "get").mockReturnValue(""); - render( - - - - - - - , - ); + renderMarketplace(); await waitFor(() => { expect(screen.getByText("Sample data")).toBeInTheDocument(); }); }); + + describe("Server query parameters (Issue #1328)", () => { + it("sends search param after debounce when in backend mode (Scenario 1)", async () => { + vi.spyOn(constants, "API_BASE", "get").mockReturnValue("https://api.callora.com"); + const fetchCatalogSpy = vi + .spyOn(catalogApiModule, "fetchCatalog") + .mockResolvedValue(TEST_BACKEND_APIS); + + renderMarketplace(); + + await waitFor(() => { + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1); + }); + + const searchInput = screen.getByRole("searchbox"); + await userEvent.type(searchInput, "geo"); + + await waitFor( + () => { + expect(fetchCatalogSpy).toHaveBeenCalledWith( + expect.any(AbortSignal), + expect.objectContaining({ search: "geo" }), + ); + }, + { timeout: 1500 }, + ); + }); + + it("sends category param when single category selected (Scenario 2)", async () => { + vi.spyOn(constants, "API_BASE", "get").mockReturnValue("https://api.callora.com"); + const fetchCatalogSpy = vi + .spyOn(catalogApiModule, "fetchCatalog") + .mockResolvedValue(TEST_BACKEND_APIS); + + renderMarketplace(); + + await waitFor(() => { + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1); + }); + + const checkbox = screen.getByLabelText("Security"); + await userEvent.click(checkbox); + + await waitFor(() => { + expect(fetchCatalogSpy).toHaveBeenCalledWith( + expect.any(AbortSignal), + expect.objectContaining({ category: "Security" }), + ); + }); + }); + + it("fetches without category param when multiple categories selected (Scenario 3)", async () => { + vi.spyOn(constants, "API_BASE", "get").mockReturnValue("https://api.callora.com"); + const fetchCatalogSpy = vi + .spyOn(catalogApiModule, "fetchCatalog") + .mockResolvedValue(TEST_BACKEND_APIS); + + renderMarketplace(); + + await waitFor(() => { + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1); + }); + + const checkbox1 = screen.getByLabelText("Security"); + const checkbox2 = screen.getByLabelText("Data & Analytics"); + await userEvent.click(checkbox1); + await userEvent.click(checkbox2); + + await waitFor(() => { + expect(fetchCatalogSpy).toHaveBeenLastCalledWith( + expect.any(AbortSignal), + expect.not.objectContaining({ category: expect.anything() }), + ); + }); + }); + + it("does not send server queries in demo mode (Scenario 5)", async () => { + vi.spyOn(constants, "API_BASE", "get").mockReturnValue(""); + const fetchCatalogSpy = vi + .spyOn(catalogApiModule, "fetchCatalog") + .mockResolvedValue(TEST_BACKEND_APIS); + + renderMarketplace(); + + await waitFor(() => { + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1); + }); + + const searchInput = screen.getByRole("searchbox"); + await userEvent.type(searchInput, "geo"); + + // In demo mode, fetchCatalog is not called again on typing + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1); + }); + + it("omits whitespace-only search param (Scenario 8)", async () => { + vi.spyOn(constants, "API_BASE", "get").mockReturnValue("https://api.callora.com"); + const fetchCatalogSpy = vi + .spyOn(catalogApiModule, "fetchCatalog") + .mockResolvedValue(TEST_BACKEND_APIS); + + renderMarketplace(); + + await waitFor(() => { + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1); + }); + + const searchInput = screen.getByRole("searchbox"); + await userEvent.type(searchInput, " "); + + await waitFor(() => { + expect(fetchCatalogSpy).toHaveBeenLastCalledWith( + expect.any(AbortSignal), + {}, + ); + }); + }); + }); }); diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx index 345fc0f..f863b6f 100644 --- a/src/pages/MarketplacePage.tsx +++ b/src/pages/MarketplacePage.tsx @@ -21,8 +21,10 @@ import { useDebounce } from "../hooks/useDebounce"; import { useFetchTracker } from "../hooks/useFetchTracker"; import { CATALOG_CACHE_KEY, + buildCacheKey, fetchCatalog, isCatalogAbortError, + type CatalogParams, } from "../api/catalogApi"; import { getCache, setCache } from "../utils/offlineApiCache"; import { @@ -115,20 +117,39 @@ export default function MarketplacePage(): JSX.Element { const { account } = useAccountContext(); const cacheOwnerId = account?.id ?? "marketplace"; + // Only send category when exactly one category is selected. + // With several categories, fetch without category and keep filtering on the + // client (the backend only accepts a single category query parameter). + const serverCategory = + categories.size === 1 ? Array.from(categories)[0] : undefined; + const serverSearch = debouncedQuery.trim() || undefined; + + const serverParams = useMemo(() => { + const params: CatalogParams = {}; + if (serverSearch) params.search = serverSearch; + if (serverCategory) params.category = serverCategory; + return params; + }, [serverSearch, serverCategory]); + const runCatalogFetch = useCallback( - async (seq: number, controller: AbortController) => { + async ( + seq: number, + controller: AbortController, + params: CatalogParams = {}, + ) => { const { signal } = controller; + const cacheKey = buildCacheKey(params); const readCachedCatalog = (): APIItem[] => - getCache(cacheOwnerId, CATALOG_CACHE_KEY) ?? []; + getCache(cacheOwnerId, cacheKey) ?? []; try { - const items = await trackFetch(fetchCatalog(signal)); + const items = await trackFetch(fetchCatalog(signal, params)); // A superseded request (or an unmount) must never write state. if (signal.aborted || seq !== requestSeqRef.current) return; setCatalog(items); setIsShowingStaleData(false); setFetchError(null); - setCache(cacheOwnerId, CATALOG_CACHE_KEY, items); + setCache(cacheOwnerId, cacheKey, items); } catch (err) { if (signal.aborted || isCatalogAbortError(err)) return; if (seq !== requestSeqRef.current) return; @@ -158,23 +179,35 @@ export default function MarketplacePage(): JSX.Element { [cacheOwnerId, trackFetch], ); - const startCatalogFetch = useCallback(() => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - const seq = ++requestSeqRef.current; - setIsLoading(true); - setFetchError(null); - return runCatalogFetch(seq, controller); - }, [runCatalogFetch]); + const startCatalogFetch = useCallback( + (params: CatalogParams = {}) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const seq = ++requestSeqRef.current; + setIsLoading(true); + setFetchError(null); + return runCatalogFetch(seq, controller, params); + }, + [runCatalogFetch], + ); + // In backend mode (API_BASE configured), fetch on mount and refetch whenever + // debounced search or single-category selection changes. + // In demo mode (!API_BASE), fetch only once on mount with no query params. useEffect(() => { - void startCatalogFetch(); + void startCatalogFetch(API_BASE ? serverParams : {}); return () => { abortRef.current?.abort(); abortRef.current = null; }; - }, [startCatalogFetch]); + }, [ + startCatalogFetch, + Boolean(API_BASE), + API_BASE ? serverParams.search : undefined, + API_BASE ? serverParams.category : undefined, + API_BASE ? categories.size : 0, + ]); useEffect(() => { setDensityPreference(density); @@ -201,9 +234,8 @@ export default function MarketplacePage(): JSX.Element { (favoritesOnly ? 1 : 0) + (statuses.size > 0 ? 1 : 0); - // Retry re-issues the real catalogue request rather than faking progress, - // so a recovered backend actually replaces the error state. - const handleRetryFetch = () => startCatalogFetch(); + const handleRetryFetch = () => + startCatalogFetch(API_BASE ? serverParams : {}); // Tag facets are derived from the loaded catalogue so a newly published API's // tags are selectable the moment it appears. diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 15f4c8d..e9cb365 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,8 +1,13 @@ /// interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; readonly VITE_STELLAR_NETWORK?: string; readonly VITE_STELLAR_RPC_URL?: string; readonly VITE_STELLAR_VAULT_CONTRACT_ID?: string; readonly VITE_ENABLE_DEMO_OUTCOME?: string; } + +interface ImportMeta { + readonly env: ImportMetaEnv; +}