diff --git a/backend/src/__tests__/certificate.integration.test.ts b/backend/src/__tests__/certificate.integration.test.ts new file mode 100644 index 0000000..23ebd3f --- /dev/null +++ b/backend/src/__tests__/certificate.integration.test.ts @@ -0,0 +1,117 @@ +/** + * Integration test — GET /api/v1/certificates + * + * Mounts the real certificate router through Express and drives it with + * supertest. Only the Certificate Mongoose model is mocked, so the route + * validation and controller → service path behave exactly as in production. + */ +import request from "supertest"; +import express from "express"; +import certificateRoutes from "../routes/certificate.routes"; +import { globalErrorHandler } from "../middlewares/errorHandler"; +import Certificate from "../models/Certificate.model"; + +jest.mock("../models/Certificate.model"); + +const CertificateMock = Certificate as unknown as { + find: jest.Mock; + countDocuments: jest.Mock; +}; + +function mockQueryChain(docs: unknown[], total: number) { + const lean = jest.fn().mockResolvedValue(docs); + const limit = jest.fn().mockReturnValue({ lean }); + const skip = jest.fn().mockReturnValue({ limit }); + const sort = jest.fn().mockReturnValue({ skip }); + const populateManifest = jest.fn().mockReturnValue({ sort }); + const populateAsset = jest.fn().mockReturnValue({ populate: populateManifest }); + CertificateMock.find.mockReturnValue({ populate: populateAsset }); + CertificateMock.countDocuments.mockResolvedValue(total); +} + +function buildTestApp() { + const app = express(); + app.use(express.json()); + app.use("/api/v1/certificates", certificateRoutes); + app.use(globalErrorHandler); + return app; +} + +const sampleDoc = { + _id: "665f1e2b3f4a5b6c7d8e9f01", + certificateId: "cert-onchain-0001", + transactionHash: "deadbeefcafe0011", + contractAddress: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + stellarNetwork: "testnet", + mintedAt: new Date("2026-07-10T09:30:00.000Z"), + assetId: { + fileName: "Aurora — Limited Edition Album", + mimeType: "audio/mpeg", + }, + manifestId: { + contentHash: "0xa1b2c3d4e5f6", + creator: "GBVBK2TX7QHEQNIMUPBVPZ7EONL52TWKQ7OXFDJPAJPYGNZFACUQBXP", + metadata: { description: "Limited release album verified on-chain" }, + }, +}; + +describe("GET /api/v1/certificates", () => { + const app = buildTestApp(); + + beforeEach(() => { + jest.clearAllMocks(); + mockQueryChain([sampleDoc], 1); + }); + + it("serves the public global index without a creatorId", async () => { + const res = await request(app).get("/api/v1/certificates"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.total).toBe(1); + expect(res.body.data.certificates[0].certificateId).toBe("cert-onchain-0001"); + // Global mode: no creator filter applied. + expect(CertificateMock.find).toHaveBeenCalledWith({}); + }); + + it("applies the search filter the frontend sends", async () => { + const res = await request(app) + .get("/api/v1/certificates") + .query({ search: "aurora", limit: "50", skip: "0" }); + + expect(res.status).toBe(200); + const filter = CertificateMock.find.mock.calls[0][0]; + expect(filter.$and).toHaveLength(1); + expect(filter.$and[0].$or[0].certificateId.$regex).toBe("aurora"); + expect(filter.$and[0].$or[0].certificateId.$options).toBe("i"); + }); + + it("still serves the legacy per-creator listing", async () => { + const res = await request(app) + .get("/api/v1/certificates") + .query({ creatorId: "665f1e2b3f4a5b6c7d8e9f01" }); + + expect(res.status).toBe(200); + const filter = CertificateMock.find.mock.calls[0][0]; + expect(String(filter.$and[0].creatorId)).toBe("665f1e2b3f4a5b6c7d8e9f01"); + }); + + it("rejects an invalid creatorId with a 400 envelope", async () => { + const res = await request(app) + .get("/api/v1/certificates") + .query({ creatorId: "not-valid" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Invalid query parameters"); + }); + + it("rejects an out-of-range limit", async () => { + const res = await request(app) + .get("/api/v1/certificates") + .query({ limit: "250" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); +}); diff --git a/backend/src/__tests__/certificate.routes.test.ts b/backend/src/__tests__/certificate.routes.test.ts new file mode 100644 index 0000000..8d2abbc --- /dev/null +++ b/backend/src/__tests__/certificate.routes.test.ts @@ -0,0 +1,76 @@ +import { listCertificatesQuerySchema } from "../routes/certificate.routes"; + +describe("listCertificatesQuerySchema", () => { + it("accepts a bare request for the public global index", () => { + const result = listCertificatesQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.creatorId).toBeUndefined(); + expect(result.data.search).toBeUndefined(); + expect(result.data.limit).toBe(20); + expect(result.data.skip).toBe(0); + } + }); + + it("accepts the global index query used by the search page (search+limit+skip)", () => { + const result = listCertificatesQuerySchema.safeParse({ + search: "aurora", + limit: "50", + skip: "0", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.search).toBe("aurora"); + expect(result.data.limit).toBe(50); + } + }); + + it("keeps supporting the legacy per-creator query", () => { + const result = listCertificatesQuerySchema.safeParse({ + creatorId: "665f1e2b3f4a5b6c7d8e9f01", + }); + expect(result.success).toBe(true); + }); + + it("accepts search + creatorId combined", () => { + const result = listCertificatesQuerySchema.safeParse({ + creatorId: "665f1e2b3f4a5b6c7d8e9f01", + search: "cert.*(1)", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.search).toBe("cert.*(1)"); + } + }); + + it("trims whitespace around the search term", () => { + const result = listCertificatesQuerySchema.safeParse({ + search: " aurora ", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.search).toBe("aurora"); + } + }); + + it("rejects a malformed creatorId", () => { + expect( + listCertificatesQuerySchema.safeParse({ creatorId: "bad-id" }).success, + ).toBe(false); + }); + + it("rejects limit above 100 and negative skip", () => { + expect(listCertificatesQuerySchema.safeParse({ limit: "999" }).success).toBe( + false, + ); + expect(listCertificatesQuerySchema.safeParse({ skip: "-1" }).success).toBe( + false, + ); + }); + + it("rejects an over-long search term", () => { + expect( + listCertificatesQuerySchema.safeParse({ search: "x".repeat(300) }).success, + ).toBe(false); + }); +}); diff --git a/backend/src/__tests__/certificate.service.test.ts b/backend/src/__tests__/certificate.service.test.ts new file mode 100644 index 0000000..c7bfc25 --- /dev/null +++ b/backend/src/__tests__/certificate.service.test.ts @@ -0,0 +1,128 @@ +import mongoose from "mongoose"; +import { certificateService } from "../services/certificate.service"; +import Certificate from "../models/Certificate.model"; +import type { ListCertificatesQuery } from "../types/certificate.types"; + +jest.mock("../models/Certificate.model"); + +const CertificateMock = Certificate as unknown as { + find: jest.Mock; + countDocuments: jest.Mock; +}; + +/** Wire the Mongoose query chain used by CertificateService.listCertificates. */ +function mockQueryChain(docs: unknown[], total: number) { + const lean = jest.fn().mockResolvedValue(docs); + const limit = jest.fn().mockReturnValue({ lean }); + const skip = jest.fn().mockReturnValue({ limit }); + const sort = jest.fn().mockReturnValue({ skip }); + const populateManifest = jest.fn().mockReturnValue({ sort }); + const populateAsset = jest.fn().mockReturnValue({ populate: populateManifest }); + CertificateMock.find.mockReturnValue({ populate: populateAsset }); + CertificateMock.countDocuments.mockResolvedValue(total); +} + +function baseQuery(partial: Partial): ListCertificatesQuery { + return { limit: 20, skip: 0, ...partial }; +} + +describe("CertificateService.listCertificates", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("queries the global index when creatorId is omitted", async () => { + mockQueryChain([{ certificateId: "cert-1" }], 1); + + const result = await certificateService.listCertificates(baseQuery({})); + + expect(CertificateMock.find).toHaveBeenCalledWith({}); + expect(result.total).toBe(1); + expect(result.certificates).toHaveLength(1); + }); + + it("filters by creatorId when supplied", async () => { + mockQueryChain([], 0); + const creatorId = new mongoose.Types.ObjectId().toHexString(); + + await certificateService.listCertificates(baseQuery({ creatorId })); + + const filter = CertificateMock.find.mock.calls[0][0]; + expect(filter.$and).toHaveLength(1); + expect(String(filter.$and[0].creatorId)).toBe(creatorId); + }); + + it("builds a case-insensitive $or search across on-chain identifiers", async () => { + mockQueryChain([], 0); + + await certificateService.listCertificates(baseQuery({ search: "cert-ABC" })); + + const filter = CertificateMock.find.mock.calls[0][0]; + const searchCondition = filter.$and.find( + (c: Record) => "$or" in c, + ) as { $or: Record[] }; + + expect(searchCondition.$or.map((entry) => Object.keys(entry)[0])).toEqual([ + "certificateId", + "transactionHash", + "contractAddress", + ]); + expect(searchCondition.$or[0].certificateId.$regex).toBe("cert-ABC"); + expect(searchCondition.$or[0].certificateId.$options).toBe("i"); + }); + + it("escapes regex metacharacters in the search term", async () => { + mockQueryChain([], 0); + + await certificateService.listCertificates(baseQuery({ search: "cert.*(1)" })); + + const filter = CertificateMock.find.mock.calls[0][0]; + const searchCondition = filter.$and.find( + (c: Record) => "$or" in c, + ) as { $or: Record[] }; + + expect(searchCondition.$or[0].certificateId.$regex).toBe( + "cert\\.\\*\\(1\\)", + ); + }); + + it("combines creatorId and search under $and", async () => { + mockQueryChain([{ certificateId: "cert-9" }], 1); + const creatorId = new mongoose.Types.ObjectId().toHexString(); + + await certificateService.listCertificates( + baseQuery({ creatorId, search: "cert-9" }), + ); + + const filter = CertificateMock.find.mock.calls[0][0]; + expect(filter.$and).toHaveLength(2); + expect(CertificateMock.countDocuments).toHaveBeenCalledWith(filter); + }); + + it("rejects an invalid creatorId", async () => { + await expect( + certificateService.listCertificates(baseQuery({ creatorId: "not-an-id" })), + ).rejects.toMatchObject({ code: "INVALID_CREATOR_ID" }); + }); + + it("rejects out-of-range pagination", async () => { + await expect( + certificateService.listCertificates(baseQuery({ limit: 0 })), + ).rejects.toMatchObject({ code: "INVALID_PAGINATION" }); + await expect( + certificateService.listCertificates(baseQuery({ skip: -1 })), + ).rejects.toMatchObject({ code: "INVALID_PAGINATION" }); + }); + + it("populates asset and manifest relations for the frontend", async () => { + mockQueryChain([], 0); + + await certificateService.listCertificates(baseQuery({})); + + const findResult = CertificateMock.find.mock.results[0].value; + expect(findResult.populate).toHaveBeenCalledWith( + "assetId", + "fileName mimeType storageReferenceId", + ); + }); +}); diff --git a/backend/src/routes/certificate.routes.ts b/backend/src/routes/certificate.routes.ts index 97ba424..9f202e4 100644 --- a/backend/src/routes/certificate.routes.ts +++ b/backend/src/routes/certificate.routes.ts @@ -2,8 +2,11 @@ * Certificate Routes – request validation schemas and route definitions. * * Endpoints: - * GET /api/v1/certificates?creatorId=&limit=20&skip=0 - * Returns a paginated list of certificates owned by the given user. + * GET /api/v1/certificates?creatorId=&search=&limit=20&skip=0 + * Returns a paginated list of certificates. `creatorId` narrows the list + * to a single owner; when omitted the endpoint exposes the public global + * certificate index. `search` matches the on-chain certificateId, + * transactionHash and contractAddress (case-insensitive). * * All Zod schemas are co-located with the routes that use them. */ @@ -14,12 +17,19 @@ import { StatusCodes } from "http-status-codes"; import { certificateController } from "../controllers/certificate.controller"; // --------------------------------------------------------------------------- -// Zod schema – query parameters for the certificate list endpoint +// Zod schema – query parameters for the certificate list endpoint. +// Exported so the validation contract can be unit-tested directly. // --------------------------------------------------------------------------- -const listCertificatesQuerySchema = z.object({ +export const listCertificatesQuerySchema = z.object({ creatorId: z .string() - .regex(/^[a-f\d]{24}$/i, "creatorId must be a valid MongoDB ObjectId"), + .regex(/^[a-f\d]{24}$/i, "creatorId must be a valid MongoDB ObjectId") + .optional(), + search: z + .string() + .trim() + .max(256, "search must be at most 256 characters") + .optional(), limit: z .string() .optional() @@ -72,10 +82,13 @@ function validateListCertificatesQuery( const router = Router(); /** - * GET /api/v1/certificates?creatorId=&limit=20&skip=0 + * GET /api/v1/certificates?creatorId=&search=&limit=20&skip=0 * * Query parameters: - * - creatorId (required) – MongoDB ObjectId of the certificate owner. + * - creatorId (optional) – MongoDB ObjectId of the certificate owner. When + * omitted, the public global certificate index is returned. + * - search (optional, ≤256 chars) – matches certificateId, transactionHash + * and contractAddress (case-insensitive). * - limit (optional, 1–100, default 20) – page size. * - skip (optional, ≥0, default 0) – offset for pagination. * diff --git a/backend/src/services/certificate.service.ts b/backend/src/services/certificate.service.ts index 7df69d3..90e25db 100644 --- a/backend/src/services/certificate.service.ts +++ b/backend/src/services/certificate.service.ts @@ -7,11 +7,16 @@ import type { CertificateListResult, } from "../types/certificate.types"; +/** Escape user input so it can be safely embedded in a MongoDB `$regex`. */ +function escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export class CertificateService { async listCertificates(query: ListCertificatesQuery): Promise { - const { creatorId, limit, skip } = query; + const { creatorId, search, limit, skip } = query; - if (!mongoose.Types.ObjectId.isValid(creatorId)) { + if (creatorId && !mongoose.Types.ObjectId.isValid(creatorId)) { throw new AppError( "creatorId must be a valid MongoDB ObjectId", StatusCodes.BAD_REQUEST, @@ -35,11 +40,35 @@ export class CertificateService { ); } - const filter = { creatorId: new mongoose.Types.ObjectId(creatorId) }; + const conditions: Record[] = []; + + // Per-owner listing when creatorId is supplied; otherwise the query + // targets the public global certificate index. + if (creatorId) { + conditions.push({ creatorId: new mongoose.Types.ObjectId(creatorId) }); + } + + // Full-text-ish search across the on-chain identifiers of a certificate. + if (search) { + const matcher = { $regex: escapeRegex(search), $options: "i" }; + conditions.push({ + $or: [ + { certificateId: matcher }, + { transactionHash: matcher }, + { contractAddress: matcher }, + ], + }); + } + + const filter = conditions.length > 0 ? { $and: conditions } : {}; const [certificates, total] = await Promise.all([ Certificate.find(filter) - .sort({ createdAt: -1 }) + // Populate the linked asset + manifest so the frontend can render + // human-readable names/descriptions without extra round-trips. + .populate("assetId", "fileName mimeType storageReferenceId") + .populate("manifestId", "contentHash creator metadata") + .sort({ mintedAt: -1, createdAt: -1 }) .skip(skip) .limit(limit) .lean[]>(), diff --git a/backend/src/types/certificate.types.ts b/backend/src/types/certificate.types.ts index 0d72d04..17dcf45 100644 --- a/backend/src/types/certificate.types.ts +++ b/backend/src/types/certificate.types.ts @@ -6,7 +6,17 @@ import mongoose from "mongoose"; /** Validated query parameters for GET /api/v1/certificates */ export interface ListCertificatesQuery { - creatorId: string; + /** + * Optional MongoDB ObjectId of the certificate owner. + * When omitted, the endpoint returns the public global certificate index + * across all creators (used by the frontend Global Certificate Search). + */ + creatorId?: string; + /** + * Optional full-text-ish search term. Matched (case-insensitively) against + * the on-chain `certificateId`, `transactionHash` and `contractAddress`. + */ + search?: string; /** Maximum number of records to return (1–100, default 20). */ limit: number; /** Number of records to skip for offset-based pagination (default 0). */ diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..cb5c46d --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,13 @@ +# Base URL of the StellarProof backend API (Express + MongoDB, fronts the +# Soroban provenance contract index). Used by the search page to fetch real +# certificate data via GET /api/v1/certificates. +# Local development default: +NEXT_PUBLIC_API_URL=http://localhost:4000 + +# Expected Stellar network: mainnet | testnet | futurenet +NEXT_PUBLIC_STELLAR_NETWORK=testnet + +# Soroban contract / network config (used by wallet + verification flows) +NEXT_PUBLIC_VITE_HORIZON_URL=https://horizon-testnet.stellar.org +NEXT_PUBLIC_VITE_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +NEXT_PUBLIC_VITE_SOROBAN_CONTRACT_ID= diff --git a/frontend/.gitignore b/frontend/.gitignore index 55e7e1a..ba25f29 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/frontend/app/dashboard/assets/components/AssetGrid.tsx b/frontend/app/dashboard/assets/components/AssetGrid.tsx index ed8444d..457df5e 100644 --- a/frontend/app/dashboard/assets/components/AssetGrid.tsx +++ b/frontend/app/dashboard/assets/components/AssetGrid.tsx @@ -248,23 +248,19 @@ interface AssetGridProps { } export default function AssetGrid({ assets }: AssetGridProps) { - const [items, setItems] = useState(assets ?? null); + const [mockItems, setMockItems] = useState(null); const [isLoading, setIsLoading] = useState(!assets); + const items = assets ?? mockItems; useEffect(() => { - if (assets) { - setItems(assets); - setIsLoading(false); - return; - } + if (assets) return; let cancelled = false; - setIsLoading(true); // Simulate asset retrieval until the service layer is wired in. const timer = setTimeout(() => { if (!cancelled) { - setItems(MOCK_ASSETS); + setMockItems(MOCK_ASSETS); setIsLoading(false); } }, 400); diff --git a/frontend/app/search/__tests__/search.test.tsx b/frontend/app/search/__tests__/search.test.tsx index 787903a..2e31138 100644 --- a/frontend/app/search/__tests__/search.test.tsx +++ b/frontend/app/search/__tests__/search.test.tsx @@ -1,144 +1,191 @@ +/** + * Tests for the Global Certificate Search page. + * + * The page fetches real certificate data from the backend API through + * `app/search/services/searchService`; the service is mocked here so the + * tests can assert loading, success, error, debounce, cache-restore and + * view-toggle behaviour deterministically. + */ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import SearchPage from "../page"; -import { searchCertificates } from "@/services/certificate"; -import type { ProvenanceCertificate } from "@/services/certificate"; - -jest.mock("@/services/certificate", () => ({ +import { + fetchAllCertificates, + searchCertificates, +} from "../services/searchService"; +import type { SearchResult } from "../types"; + +jest.mock("../services/searchService", () => ({ + fetchAllCertificates: jest.fn(), searchCertificates: jest.fn(), })); -const mockedSearchCertificates = searchCertificates as jest.MockedFunction< +// The app Header pulls in wallet/network contexts; it is not under test here. +jest.mock("../../../components/Header", () => ({ + __esModule: true, + default: () =>
, +})); + +const mockedFetchAll = fetchAllCertificates as jest.MockedFunction< + typeof fetchAllCertificates +>; +const mockedSearch = searchCertificates as jest.MockedFunction< typeof searchCertificates >; -const mockCertificate: ProvenanceCertificate = { - id: "cert-demo-001", - ownerAddress: "GBVBK2TX7QHEQNIMUPBVPZ7EONL52TWKQ7OXFDJPAJPYGNZFACUQBXP", - mintedAt: "2024-11-15T10:30:00Z", - manifestHash: "0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - contentHash: "0xb2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3", - attestationHash: "0xc3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", +const aurora: SearchResult = { + id: "cert-aurora-001", + name: "Aurora — Limited Edition Album", + description: "Limited release album verified on-chain", + hash: "0xa1b2c3d4e5f6", + creator: "GBVBK2TX7QHEQNIMUPBVPZ7EONL52TWKQ7OXFDJPAJPYGNZFACUQBXP", + mintedAt: "2026-01-12T10:42:00Z", + status: "verified", + type: "Audio", + network: "Stellar", +}; + +const painting: SearchResult = { + id: "cert-painting-003", + name: "Genesis — Original Painting", + description: "Original artwork anchored on Stellar", + hash: "0xc3d4e5f6a7b8", + creator: "GDQP2KPQGKIHYMV727FKZ5XZ7Y7Q3O3F2K3Z3JJQNZQFCK4LVNXJKJLE", + mintedAt: "2026-02-21T12:00:00Z", + status: "pending", + type: "Image", + network: "Stellar", }; describe("SearchPage", () => { beforeEach(() => { - mockedSearchCertificates.mockReset(); + jest.clearAllMocks(); + mockedFetchAll.mockResolvedValue([aurora, painting]); + mockedSearch.mockResolvedValue([aurora]); }); - it("renders the search form", () => { + it("loads the global certificate index from the API on mount", async () => { render(); + expect( - screen.getByRole("heading", { name: /global certificate search/i }) + screen.getByRole("heading", { name: /global certificate search/i }), ).toBeInTheDocument(); - expect(screen.getByRole("searchbox")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /^search$/i }) - ).toBeDisabled(); - }); - it("shows a loading state while the search is in flight", async () => { - const user = userEvent.setup(); - let resolveSearch: (value: { results: ProvenanceCertificate[] }) => void = () => {}; - mockedSearchCertificates.mockReturnValue( - new Promise((resolve) => { - resolveSearch = resolve; - }) + expect(mockedFetchAll).toHaveBeenCalledTimes(1); + expect(mockedFetchAll).toHaveBeenCalledWith( + expect.objectContaining({ signal: expect.any(AbortSignal) }), ); - render(); - await user.type(screen.getByRole("searchbox"), "cert-demo-001"); - await user.click(screen.getByRole("button", { name: /^search$/i })); - - expect(document.querySelector(".animate-pulse")).toBeInTheDocument(); - - resolveSearch({ results: [mockCertificate] }); - await waitFor(() => - expect(document.querySelector(".animate-pulse")).not.toBeInTheDocument() - ); + // Certificates returned by the API are rendered into the list view. + expect( + await screen.findByText("Aurora — Limited Edition Album"), + ).toBeInTheDocument(); + expect( + screen.getByText("Genesis — Original Painting"), + ).toBeInTheDocument(); + expect(mockedSearch).not.toHaveBeenCalled(); }); - it("renders results on a successful search", async () => { - const user = userEvent.setup(); - mockedSearchCertificates.mockResolvedValue({ results: [mockCertificate] }); + it("shows a loading skeleton while the fetch is in flight", () => { + mockedFetchAll.mockReturnValue(new Promise(() => {})); render(); - await user.type(screen.getByRole("searchbox"), "cert-demo-001"); - await user.click(screen.getByRole("button", { name: /^search$/i })); - expect(await screen.findByText("cert-demo-001")).toBeInTheDocument(); - expect(mockedSearchCertificates).toHaveBeenCalledWith( - "cert-demo-001", - "id" - ); + expect( + screen.getByRole("list", { name: /loading search results/i }), + ).toBeInTheDocument(); + expect(screen.getByText("Loading…")).toBeInTheDocument(); }); - it("renders an error message when no certificates are found", async () => { - const user = userEvent.setup(); - mockedSearchCertificates.mockResolvedValue({ - results: [], - error: "No certificates found. Check the value and try again.", - }); + it("renders an error state when the initial fetch fails", async () => { + mockedFetchAll.mockRejectedValue(new Error("network down")); render(); - await user.type(screen.getByRole("searchbox"), "unknown-cert"); - await user.click(screen.getByRole("button", { name: /^search$/i })); - expect(await screen.findByRole("alert")).toHaveTextContent( - /no certificates found/i - ); + expect( + await screen.findByText( + /failed to load global certificate index/i, + ), + ).toBeInTheDocument(); }); - it("renders an error message when the search rejects", async () => { + it("performs a debounced search against the API when the user types", async () => { const user = userEvent.setup(); - mockedSearchCertificates.mockRejectedValue(new Error("network down")); - render(); - await user.type(screen.getByRole("searchbox"), "cert-demo-001"); - await user.click(screen.getByRole("button", { name: /^search$/i })); - expect(await screen.findByRole("alert")).toHaveTextContent( - /something went wrong/i + // Wait for the initial index load to finish first. + await screen.findByText("Aurora — Limited Edition Album"); + + await user.type(screen.getByRole("searchbox"), "aurora"); + + await waitFor(() => + expect(mockedSearch).toHaveBeenCalledWith( + "aurora", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), ); + + // Search response replaces the index data. + expect( + await screen.findByText("Aurora — Limited Edition Album"), + ).toBeInTheDocument(); }); - it("toggles the search field between certificate ID and content hash", async () => { + it("shows an error message when the search request fails", async () => { + mockedSearch.mockRejectedValue(new Error("server exploded")); const user = userEvent.setup(); - mockedSearchCertificates.mockResolvedValue({ results: [mockCertificate] }); - render(); - const idButton = screen.getByRole("button", { name: /certificate id/i }); - const hashButton = screen.getByRole("button", { name: /content hash/i }); + await screen.findByText("Aurora — Limited Edition Album"); + await user.type(screen.getByRole("searchbox"), "broken"); - expect(idButton).toHaveAttribute("aria-pressed", "true"); - expect(hashButton).toHaveAttribute("aria-pressed", "false"); + expect( + await screen.findByText(/search failed\. please try again\./i), + ).toBeInTheDocument(); + }); - await user.click(hashButton); + it("restores the cached index when the search is cleared", async () => { + const user = userEvent.setup(); + render(); - expect(idButton).toHaveAttribute("aria-pressed", "false"); - expect(hashButton).toHaveAttribute("aria-pressed", "true"); + await screen.findByText("Genesis — Original Painting"); + await user.type(screen.getByRole("searchbox"), "aurora"); + await waitFor(() => expect(mockedSearch).toHaveBeenCalledTimes(1)); - await user.type(screen.getByRole("searchbox"), "0xb2c3"); - await user.click(screen.getByRole("button", { name: /^search$/i })); + // Clear restores the cached index without hitting the API again. + await user.click(screen.getByRole("button", { name: /clear search/i })); - expect(mockedSearchCertificates).toHaveBeenCalledWith("0xb2c3", "contentHash"); + expect(screen.getByRole("searchbox")).toHaveValue(""); + await waitFor(() => + expect( + screen.getByText("Genesis — Original Painting"), + ).toBeInTheDocument(), + ); + expect(mockedFetchAll).toHaveBeenCalledTimes(1); + expect(mockedSearch).toHaveBeenCalledTimes(1); }); - it("clears the query and results when the clear button is pressed", async () => { + it("passes adapted certificate data to the grid view", async () => { const user = userEvent.setup(); - mockedSearchCertificates.mockResolvedValue({ results: [mockCertificate] }); - + mockedFetchAll.mockResolvedValue([aurora]); render(); - const input = screen.getByRole("searchbox"); - await user.type(input, "cert-demo-001"); - await user.click(screen.getByRole("button", { name: /^search$/i })); - expect(await screen.findByText("cert-demo-001")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /clear search/i })); + await screen.findByText("Aurora — Limited Edition Album"); + expect( + screen.queryByAltText("Aurora — Limited Edition Album"), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("radio", { name: /grid view/i })); + + // GridView receives mapped `{ id, title, thumbnailUrl, issuerName, issueDate }`. + expect( + screen.getByAltText("Aurora — Limited Edition Album"), + ).toBeInTheDocument(); - expect(input).toHaveValue(""); - expect(screen.queryByText("cert-demo-001")).not.toBeInTheDocument(); + await user.click(screen.getByRole("radio", { name: /list view/i })); + expect( + screen.queryByAltText("Aurora — Limited Edition Album"), + ).not.toBeInTheDocument(); }); }); diff --git a/frontend/app/search/__tests__/searchService.test.tsx b/frontend/app/search/__tests__/searchService.test.tsx new file mode 100644 index 0000000..2e31138 --- /dev/null +++ b/frontend/app/search/__tests__/searchService.test.tsx @@ -0,0 +1,191 @@ +/** + * Tests for the Global Certificate Search page. + * + * The page fetches real certificate data from the backend API through + * `app/search/services/searchService`; the service is mocked here so the + * tests can assert loading, success, error, debounce, cache-restore and + * view-toggle behaviour deterministically. + */ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import SearchPage from "../page"; +import { + fetchAllCertificates, + searchCertificates, +} from "../services/searchService"; +import type { SearchResult } from "../types"; + +jest.mock("../services/searchService", () => ({ + fetchAllCertificates: jest.fn(), + searchCertificates: jest.fn(), +})); + +// The app Header pulls in wallet/network contexts; it is not under test here. +jest.mock("../../../components/Header", () => ({ + __esModule: true, + default: () =>
, +})); + +const mockedFetchAll = fetchAllCertificates as jest.MockedFunction< + typeof fetchAllCertificates +>; +const mockedSearch = searchCertificates as jest.MockedFunction< + typeof searchCertificates +>; + +const aurora: SearchResult = { + id: "cert-aurora-001", + name: "Aurora — Limited Edition Album", + description: "Limited release album verified on-chain", + hash: "0xa1b2c3d4e5f6", + creator: "GBVBK2TX7QHEQNIMUPBVPZ7EONL52TWKQ7OXFDJPAJPYGNZFACUQBXP", + mintedAt: "2026-01-12T10:42:00Z", + status: "verified", + type: "Audio", + network: "Stellar", +}; + +const painting: SearchResult = { + id: "cert-painting-003", + name: "Genesis — Original Painting", + description: "Original artwork anchored on Stellar", + hash: "0xc3d4e5f6a7b8", + creator: "GDQP2KPQGKIHYMV727FKZ5XZ7Y7Q3O3F2K3Z3JJQNZQFCK4LVNXJKJLE", + mintedAt: "2026-02-21T12:00:00Z", + status: "pending", + type: "Image", + network: "Stellar", +}; + +describe("SearchPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedFetchAll.mockResolvedValue([aurora, painting]); + mockedSearch.mockResolvedValue([aurora]); + }); + + it("loads the global certificate index from the API on mount", async () => { + render(); + + expect( + screen.getByRole("heading", { name: /global certificate search/i }), + ).toBeInTheDocument(); + + expect(mockedFetchAll).toHaveBeenCalledTimes(1); + expect(mockedFetchAll).toHaveBeenCalledWith( + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + + // Certificates returned by the API are rendered into the list view. + expect( + await screen.findByText("Aurora — Limited Edition Album"), + ).toBeInTheDocument(); + expect( + screen.getByText("Genesis — Original Painting"), + ).toBeInTheDocument(); + expect(mockedSearch).not.toHaveBeenCalled(); + }); + + it("shows a loading skeleton while the fetch is in flight", () => { + mockedFetchAll.mockReturnValue(new Promise(() => {})); + + render(); + + expect( + screen.getByRole("list", { name: /loading search results/i }), + ).toBeInTheDocument(); + expect(screen.getByText("Loading…")).toBeInTheDocument(); + }); + + it("renders an error state when the initial fetch fails", async () => { + mockedFetchAll.mockRejectedValue(new Error("network down")); + + render(); + + expect( + await screen.findByText( + /failed to load global certificate index/i, + ), + ).toBeInTheDocument(); + }); + + it("performs a debounced search against the API when the user types", async () => { + const user = userEvent.setup(); + render(); + + // Wait for the initial index load to finish first. + await screen.findByText("Aurora — Limited Edition Album"); + + await user.type(screen.getByRole("searchbox"), "aurora"); + + await waitFor(() => + expect(mockedSearch).toHaveBeenCalledWith( + "aurora", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), + ); + + // Search response replaces the index data. + expect( + await screen.findByText("Aurora — Limited Edition Album"), + ).toBeInTheDocument(); + }); + + it("shows an error message when the search request fails", async () => { + mockedSearch.mockRejectedValue(new Error("server exploded")); + const user = userEvent.setup(); + render(); + + await screen.findByText("Aurora — Limited Edition Album"); + await user.type(screen.getByRole("searchbox"), "broken"); + + expect( + await screen.findByText(/search failed\. please try again\./i), + ).toBeInTheDocument(); + }); + + it("restores the cached index when the search is cleared", async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText("Genesis — Original Painting"); + await user.type(screen.getByRole("searchbox"), "aurora"); + await waitFor(() => expect(mockedSearch).toHaveBeenCalledTimes(1)); + + // Clear restores the cached index without hitting the API again. + await user.click(screen.getByRole("button", { name: /clear search/i })); + + expect(screen.getByRole("searchbox")).toHaveValue(""); + await waitFor(() => + expect( + screen.getByText("Genesis — Original Painting"), + ).toBeInTheDocument(), + ); + expect(mockedFetchAll).toHaveBeenCalledTimes(1); + expect(mockedSearch).toHaveBeenCalledTimes(1); + }); + + it("passes adapted certificate data to the grid view", async () => { + const user = userEvent.setup(); + mockedFetchAll.mockResolvedValue([aurora]); + render(); + + await screen.findByText("Aurora — Limited Edition Album"); + expect( + screen.queryByAltText("Aurora — Limited Edition Album"), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("radio", { name: /grid view/i })); + + // GridView receives mapped `{ id, title, thumbnailUrl, issuerName, issueDate }`. + expect( + screen.getByAltText("Aurora — Limited Edition Album"), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("radio", { name: /list view/i })); + expect( + screen.queryByAltText("Aurora — Limited Edition Album"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/app/search/page.tsx b/frontend/app/search/page.tsx index b065f08..9440fe5 100644 --- a/frontend/app/search/page.tsx +++ b/frontend/app/search/page.tsx @@ -41,6 +41,11 @@ function readViewPreference(): ViewMode { */ const SEARCH_DEBOUNCE_MS = 300; +/** True when a rejected promise stemmed from an intentional abort. */ +function isAbortError(err: unknown): boolean { + return err instanceof DOMException && err.name === "AbortError"; +} + /** * Global Certificate Search page. * @@ -68,19 +73,19 @@ export default function SearchPage() { /* ---------------------------------------------------------------- */ useEffect(() => { let cancelled = false; + const controller = new AbortController(); - fetchAllCertificates() + fetchAllCertificates({ signal: controller.signal }) .then((data) => { if (cancelled) return; setAllResults(data); setResults(data); }) - .catch(() => { - if (!cancelled) { - setError( - "Failed to load global certificate index. Please try again.", - ); - } + .catch((err: unknown) => { + if (cancelled || isAbortError(err)) return; + setError( + "Failed to load global certificate index. Please try again.", + ); }) .finally(() => { if (!cancelled) setLoading(false); @@ -88,6 +93,7 @@ export default function SearchPage() { return () => { cancelled = true; + controller.abort(); }; }, []); @@ -113,15 +119,15 @@ export default function SearchPage() { if (trimmed === "") return; let cancelled = false; + const controller = new AbortController(); const timer = window.setTimeout(() => { - searchCertificates(trimmed) + searchCertificates(trimmed, { signal: controller.signal }) .then((data) => { if (!cancelled) setResults(data); }) - .catch(() => { - if (!cancelled) { - setError("Search failed. Please try again."); - } + .catch((err: unknown) => { + if (cancelled || isAbortError(err)) return; + setError("Search failed. Please try again."); }) .finally(() => { if (!cancelled) setLoading(false); @@ -130,10 +136,25 @@ export default function SearchPage() { return () => { cancelled = true; + controller.abort(); window.clearTimeout(timer); }; }, [query]); + /** + * Restore the cached global index and clear transient state. Shared by the + * input's onChange (empty string) and the explicit "clear search" button. + */ + function restoreCachedResults() { + setError(null); + if (allResults.length > 0) { + setResults(allResults); + } + // Always clear loading on the empty path so the skeleton does not + // remain stale after a type→clear sequence. + setLoading(false); + } + function handleQueryChange(event: React.ChangeEvent) { const next = event.target.value; setError(null); @@ -143,18 +164,19 @@ export default function SearchPage() { // Clearing the search: restore cached full dataset without a spinner. if (trimmed === "") { - if (allResults.length > 0) { - setResults(allResults); - } - // Always clear loading on the empty path so the skeleton does not - // remain stale after a type→clear sequence. - setLoading(false); + restoreCachedResults(); return; } setLoading(true); } + /** Clear the input and immediately show the cached global index again. */ + function handleClearSearch() { + setQuery(""); + restoreCachedResults(); + } + const verifiedCount = results.filter((r) => r.status === "verified").length; const totalCount = results.length; @@ -165,15 +187,9 @@ export default function SearchPage() { const gridResults: Certificate[] = results.map((r) => ({ id: r.id, title: r.name || "Untitled Certificate", - // Placeholder thumbnail logic. Replace with actual data when available. - thumbnailUrl: - r.type === "Image" - ? `https://picsum.photos/seed/${r.id}/400/300` - : r.type === "Video" - ? `https://picsum.photos/seed/${r.id}/400/300` - : r.type === "Audio" - ? `https://picsum.photos/seed/${r.id}/400/300` - : `https://picsum.photos/seed/${r.id}/400/300`, + // Deterministic placeholder thumbnail; the asset rendering pipeline is + // tracked separately, so films/photos/documents share one strategy. + thumbnailUrl: `https://picsum.photos/seed/${encodeURIComponent(r.id)}/400/300`, issuerName: r.creator, issueDate: r.mintedAt, })); @@ -284,7 +300,7 @@ export default function SearchPage() { initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} - onClick={() => setQuery("")} + onClick={handleClearSearch} aria-label="Clear search" title="Clear search" className="absolute right-2.5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-400 dark:text-gray-500 hover:text-primary dark:hover:text-primary-light hover:bg-primary/10 dark:hover:bg-primary/20 transition-colors" @@ -318,7 +334,7 @@ export default function SearchPage() { /> ) : ( )} diff --git a/frontend/app/search/services/searchService.ts b/frontend/app/search/services/searchService.ts index 7b0cf56..0d9c8e5 100644 --- a/frontend/app/search/services/searchService.ts +++ b/frontend/app/search/services/searchService.ts @@ -1,116 +1,252 @@ import type { SearchResult } from "../types"; /** - * Mock dataset used by the Global Certificate Search results UI while the - * on-chain indexer is under development. Replace `searchCertificates()` with - * a live oracle / indexer call once the registry backend is wired up. + * Live certificate search service. + * + * Talks to the StellarProof backend REST API (`GET /api/v1/certificates`), + * which fronts the off-chain index of certificates minted through the + * provenance Soroban contract. Records in this index only exist after a + * successful on-chain mint, so every row is backed by verifiable Soroban / + * Stellar chain data. + * + * The backend certificate list endpoint was extended to support a public + * global index (`creatorId` optional) plus a `search` filter that matches + * the on-chain certificateId / transactionHash / contractAddress. */ -const MOCK_RESULTS: SearchResult[] = [ - { - id: "cert-aurora-001", - name: "Aurora — Limited Edition Album", - description: "Limited release album with full ownership verified on-chain.", - hash: "0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", - creator: "GBVBK2TX7QHEQNIMUPBVPZ7EONL52TWKQ7OXFDJPAJPYGNZFACUQBXP", - mintedAt: "2025-01-12T10:42:00Z", - status: "verified", - type: "Audio", - }, - { - id: "cert-3d-print-002", - name: "Industrial 3D Print STL Pack", - description: "STL files for 3D printing with authenticity verified on blockchain.", - hash: "0xb2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1", - creator: "GABXFFYHSZOGVKCW3YSXAOZZKFN5HGCMRHSNKGZLPM3KCRQZZFU2XJNC", - mintedAt: "2025-02-04T18:15:00Z", - status: "verified", - type: "3D Model", - }, - { - id: "cert-painting-003", - name: "Genesis — Original Painting", - description: "Original physical artwork with on-chain provenance record.", - hash: "0xc3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2", - creator: "GDQP2KPQGKIHYMV727FKZ5XZ7Y7Q3O3F2K3Z3JJQNZQFCK4LVNXJKJLE", - mintedAt: "2025-02-21T12:00:00Z", - status: "pending", - type: "Image", - }, - { - id: "cert-text-004", - name: "Independent Journalism Report", - description: "Long-form investigative article with cryptographic fingerprint.", - hash: "0xd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", - creator: "GCXFGHG4FOEZHK4YQYHZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQX", - mintedAt: "2024-12-30T09:30:00Z", - status: "verified", - type: "Document", - }, - { - id: "cert-audio-005", - name: "Field Recording — Mountain Pass", - description: "High-fidelity nature recording with IPFS-backed proof.", - hash: "0xe5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4", - creator: "GCNZG3VQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJG", - mintedAt: "2025-03-02T07:10:00Z", - status: "verified", - type: "Audio", - }, - { - id: "cert-photo-006", - name: "Wedding Photography Series", - description: "Photo set with creator signature certificate.", - hash: "", - creator: "GDFFGHG4FOEZHK4YQYHZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQX", - mintedAt: "2025-03-15T22:00:00Z", - status: "failed", - type: "Image", - }, - { - id: "cert-video-007", - name: "Short Film — Northern Lights", - description: "Award-winning short film, content hash anchored on Stellar.", - hash: "0xf6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5", - creator: "GBQQXGCM3XKZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJGZQXJ", - mintedAt: "2025-03-25T15:45:00Z", + +/* -------------------------------------------------------------------------- */ +/* API response wire shapes */ +/* -------------------------------------------------------------------------- */ + +interface ApiAsset { + fileName?: string; + mimeType?: string; + storageReferenceId?: string; +} + +interface ApiManifest { + contentHash?: string; + creator?: string; + metadata?: { + description?: string; + [key: string]: unknown; + }; +} + +interface ApiCertificate { + _id?: string; + certificateId?: string; + transactionHash?: string; + contractAddress?: string; + stellarNetwork?: string; + ledgerSequence?: number; + mintedAt?: string; + createdAt?: string; + creatorId?: string; + assetId?: ApiAsset | string | null; + manifestId?: ApiManifest | string | null; +} + +interface CertificateListPayload { + certificates?: ApiCertificate[]; + total?: number; + limit?: number; + skip?: number; +} + +interface ApiEnvelope { + success?: boolean; + data?: CertificateListPayload; + error?: string; + message?: string; +} + +/* -------------------------------------------------------------------------- */ +/* Configuration */ +/* -------------------------------------------------------------------------- */ + +/** + * Base URL of the StellarProof backend. Configure with + * `NEXT_PUBLIC_API_URL` (see frontend/.env.example). Defaults to the local + * development backend. + */ +export const API_BASE_URL: string = + process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; + +const CERTIFICATES_ENDPOINT = `${API_BASE_URL.replace(/\/$/, "")}/api/v1/certificates`; + +/** Default page size used for the global index + search requests. */ +const DEFAULT_LIMIT = 50; + +export interface FetchCertificatesOptions { + /** AbortSignal so stale/unmounted requests can be cancelled. */ + signal?: AbortSignal; + /** Maximum number of records (backend caps at 100). */ + limit?: number; + /** Offset for pagination. */ + skip?: number; +} + +/* -------------------------------------------------------------------------- */ +/* Mapping to SearchResult */ +/* -------------------------------------------------------------------------- */ + +/** Bucket a MIME type into the coarse category the UI displays. */ +function bucketType(mimeType?: string): string | undefined { + if (!mimeType) return undefined; + const mime = mimeType.toLowerCase(); + if (mime.startsWith("image/")) return "Image"; + if (mime.startsWith("video/")) return "Video"; + if (mime.startsWith("audio/")) return "Audio"; + if (mime.startsWith("model/")) return "3D Model"; + if (mime.startsWith("text/") || mime === "application/pdf") return "Document"; + return "Other"; +} + +function isPopulatedAsset(asset: ApiCertificate["assetId"]): asset is ApiAsset { + return typeof asset === "object" && asset !== null; +} + +function isPopulatedManifest( + manifest: ApiCertificate["manifestId"], +): manifest is ApiManifest { + return typeof manifest === "object" && manifest !== null; +} + +/** Normalise an ISO-ish timestamp to an ISO string. */ +function toIso(value?: string): string { + if (!value) return new Date(0).toISOString(); + const date = new Date(value); + return Number.isNaN(date.getTime()) ? new Date(0).toISOString() : date.toISOString(); +} + +/** + * Maps a backend certificate document (the off-chain mirror of the on-chain + * provenance certificate) onto the flat `SearchResult` consumed by the + * search List/Grid views. + */ +export function mapCertificateToSearchResult( + cert: ApiCertificate, +): SearchResult { + const asset = isPopulatedAsset(cert.assetId) ? cert.assetId : undefined; + const manifest = isPopulatedManifest(cert.manifestId) + ? cert.manifestId + : undefined; + + return { + id: cert.certificateId ?? cert._id ?? "unknown", + name: asset?.fileName?.trim() || undefined, + description: manifest?.metadata?.description ?? undefined, + // Prefer the content hash (what users search by); fall back to the mint tx. + hash: manifest?.contentHash ?? cert.transactionHash ?? "", + // The Stellar public key recorded in the manifest; fall back to the + // owning user id when the manifest was not populated. + creator: manifest?.creator ?? cert.creatorId ?? "", + mintedAt: toIso(cert.mintedAt ?? cert.createdAt), + // A document only exists in this index after a successful on-chain mint. status: "verified", - type: "Video", - }, -]; + network: cert.stellarNetwork + ? `Stellar ${cert.stellarNetwork.charAt(0).toUpperCase()}${cert.stellarNetwork.slice(1)}` + : "Stellar", + type: bucketType(asset?.mimeType), + }; +} + +/* -------------------------------------------------------------------------- */ +/* Fetch plumbing */ +/* -------------------------------------------------------------------------- */ + +/** Build the (empty-terminated) query string for the list endpoint. */ +function buildUrl(params: { search?: string; limit: number; skip: number }): string { + const qs = new URLSearchParams(); + if (params.search) qs.set("search", params.search); + qs.set("limit", String(params.limit)); + qs.set("skip", String(params.skip)); + return `${CERTIFICATES_ENDPOINT}?${qs.toString()}`; +} /** - * Simulated network delay (ms) used while the real indexer is offline. + * Low-level helper that performs the GET against the certificate list + * endpoint, unwraps the standard backend envelope (`{ success, data }`) and + * normalises errors to plain `Error` instances with user-facing messages. */ -const MOCK_LATENCY_MS = 600; +async function fetchCertificates( + options: FetchCertificatesOptions & { search?: string }, +): Promise { + const { search, limit = DEFAULT_LIMIT, skip = 0, signal } = options; + + let response: Response; + try { + response = await fetch(buildUrl({ search, limit, skip }), { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + // Propagate cancellations untouched so callers can ignore them. + throw err; + } + throw new Error( + "Unable to reach the StellarProof API. Check your connection and try again.", + ); + } + + let body: ApiEnvelope | undefined; + try { + body = (await response.json()) as ApiEnvelope; + } catch { + // Non-JSON body (proxy error page, HTML gateway response, …). + body = undefined; + } + + if (!response.ok) { + throw new Error( + body?.error ?? + body?.message ?? + `Certificate search failed with status ${response.status}.`, + ); + } + + if (!body || body.success === false) { + throw new Error( + body?.error ?? body?.message ?? "Certificate search failed. Please try again.", + ); + } + + const raw = body.data?.certificates; + if (!Array.isArray(raw)) { + // Defensive: the endpoint contract changed underneath us. + throw new Error("Unexpected response shape from the certificates API."); + } + + return raw.map(mapCertificateToSearchResult); +} + +/* -------------------------------------------------------------------------- */ +/* Public API */ +/* -------------------------------------------------------------------------- */ /** - * Returns the full mock dataset. Will be replaced by a backend indexer call. + * Loads the first page of the public global certificate index — every + * certificate minted through the provenance Soroban contract, newest first. + * Used to seed the search page before the user types a query. */ -export async function fetchAllCertificates(): Promise { - await new Promise((resolve) => setTimeout(resolve, MOCK_LATENCY_MS)); - return MOCK_RESULTS; +export async function fetchAllCertificates( + options: FetchCertificatesOptions = {}, +): Promise { + return fetchCertificates(options); } /** - * Mock search across the certificate index. Performs a case-insensitive - * match against id, name, description, creator and hash. Returns an - * empty array when no rows match. + * Searches the global certificate index by id, transaction hash, contract + * address, asset name or creator. An empty/blank query returns the full + * index page (same payload as {@link fetchAllCertificates}). */ export async function searchCertificates( query: string, + options: FetchCertificatesOptions = {}, ): Promise { - await new Promise((resolve) => setTimeout(resolve, MOCK_LATENCY_MS)); - - const trimmed = query.trim().toLowerCase(); - if (!trimmed) return MOCK_RESULTS; - - return MOCK_RESULTS.filter((item) => { - return ( - item.id.toLowerCase().includes(trimmed) || - (item.name?.toLowerCase().includes(trimmed) ?? false) || - (item.description?.toLowerCase().includes(trimmed) ?? false) || - item.creator.toLowerCase().includes(trimmed) || - item.hash.toLowerCase().includes(trimmed) - ); - }); + const trimmed = query.trim(); + return fetchCertificates({ ...options, search: trimmed || undefined }); } diff --git a/frontend/features/verification/components/steps/MediaUploadStep.tsx b/frontend/features/verification/components/steps/MediaUploadStep.tsx index d61b560..79feef3 100644 --- a/frontend/features/verification/components/steps/MediaUploadStep.tsx +++ b/frontend/features/verification/components/steps/MediaUploadStep.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useCallback, useState, useRef, type DragEvent, type ChangeEvent } from 'react'; -import { Upload, FileImage, FileVideo, FileText, X, AlertCircle, CheckCircle2, Loader2, Eye, EyeOff } from 'lucide-react'; +import { Upload, FileImage, FileVideo, FileText, X, AlertCircle, CheckCircle2, Loader2, Eye } from 'lucide-react'; // ── Types ────────────────────────────────────────────────── export interface MediaFile { @@ -99,6 +99,25 @@ export default function MediaUploadStep({ [externalFiles, onFilesChange], ); + const simulateUpload = useCallback(async (fileId: string) => { + const updateProgress = (progress: number) => { + setFiles((prev) => + prev.map((f) => + f.id === fileId + ? { ...f, progress, status: progress >= 100 ? 'done' : 'uploading' } + : f, + ), + ); + }; + + for (let p = 0; p <= 100; p += 20) { + await new Promise((resolve) => setTimeout(resolve, 150)); + updateProgress(p); + } + + updateProgress(100); +}, [setFiles]); + // ── File Processing ─────────────────────────────────── const processFiles = useCallback( async (fileList: FileList | File[]) => { @@ -152,26 +171,9 @@ export default function MediaUploadStep({ await simulateUpload(entry.id); } }, - [maxSize, multiple, files, setFiles], + [maxSize, multiple, files, setFiles, simulateUpload], ); - const simulateUpload = async (fileId: string) => { - const updateProgress = (progress: number) => { - setFiles((prev) => - prev.map((f) => - f.id === fileId - ? { ...f, progress, status: progress >= 100 ? 'done' : 'uploading' } - : f, - ), - ); - }; - - for (let p = 0; p <= 100; p += 20) { - await new Promise((r) => setTimeout(r, 150)); - updateProgress(p); - } - updateProgress(100); - }; // ── Drag Handlers ───────────────────────────────────── const handleDragEnter = useCallback((e: DragEvent) => { diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 07db3fe..f4da994 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -9,6 +9,11 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "images.unsplash.com", }, + { + // Placeholder certificate thumbnails used by the search GridView. + protocol: "https", + hostname: "picsum.photos", + }, ], }, };