diff --git a/frontend/__tests__/app-header.test.tsx b/frontend/__tests__/app-header.test.tsx new file mode 100644 index 0000000..e3c70b2 --- /dev/null +++ b/frontend/__tests__/app-header.test.tsx @@ -0,0 +1,48 @@ +import type { Session } from "@supabase/supabase-js"; +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { AppHeader } from "../src/components/AppHeader"; +import { useUserStore } from "../src/lib/store"; + +describe("AppHeader", () => { + beforeEach(() => { + act(() => { + useUserStore.getState().reset(); + }); + }); + + afterEach(() => { + act(() => { + useUserStore.getState().reset(); + }); + }); + + it("does not render when no session is present", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("shows navigation and avatar initials for an authenticated user", () => { + const session = { + user: { id: "1", email: "agent@example.com" }, + access_token: "token", + } as unknown as Session; + + act(() => { + useUserStore.setState({ + session, + user: { user_id: "1", display_name: "Sky Trail", cubid_id: "sky" }, + }); + }); + + render(); + + expect(screen.getByRole("link", { name: /Peer Mapper/i })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: /My QR code/i })).toHaveAttribute("href", "/scan/my-qr"); + expect(screen.getByRole("link", { name: /Camera/i })).toHaveAttribute("href", "/scan/camera"); + expect(screen.getByRole("link", { name: /My Circle/i })).toHaveAttribute("href", "/circle"); + expect(screen.getByText("Sky Trail")).toBeInTheDocument(); + expect(screen.getByText("ST")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/home-page.test.tsx b/frontend/__tests__/home-page.test.tsx new file mode 100644 index 0000000..c135d02 --- /dev/null +++ b/frontend/__tests__/home-page.test.tsx @@ -0,0 +1,53 @@ +import type { Session } from "@supabase/supabase-js"; +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import Home from "../src/app/page"; +import { useUserStore } from "../src/lib/store"; + +describe("Home page", () => { + beforeEach(() => { + act(() => { + useUserStore.getState().reset(); + }); + }); + + afterEach(() => { + act(() => { + useUserStore.getState().reset(); + }); + }); + + it("welcomes unauthenticated visitors with CTAs", () => { + render(); + + const heroHeading = screen.getByRole("heading", { + name: /Trust people faster with verifiable overlaps/i, + }); + expect(heroHeading).toBeInTheDocument(); + + const ctas = screen.getAllByRole("link", { name: /Start verifying now/i }); + expect(ctas).toHaveLength(2); + ctas.forEach((cta) => expect(cta).toHaveAttribute("href", "/signin")); + }); + + it("shows quick actions when the user is signed in", () => { + const session = { + user: { id: "1", email: "user@example.com" }, + access_token: "token", + } as unknown as Session; + + act(() => { + useUserStore.setState({ + session, + user: { user_id: "1", display_name: "Agent Maple", cubid_id: "maple" }, + }); + }); + + render(); + + expect(screen.getByText(/let’s keep building trusted links/i)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Share your QR/i })).toHaveAttribute("href", "/scan/my-qr"); + expect(screen.getByRole("link", { name: /Open camera/i })).toHaveAttribute("href", "/scan/camera"); + }); +}); diff --git a/frontend/__tests__/navigation.test.tsx b/frontend/__tests__/navigation.test.tsx deleted file mode 100644 index eb2b024..0000000 --- a/frontend/__tests__/navigation.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import Home from "../src/app/page"; - -describe("App navigation", () => { - it("links to every core route in order", () => { - render(); - - const links = screen.getAllByRole("link"); - const hrefs = links.map((link) => link.getAttribute("href")); - expect(hrefs).toEqual([ - "/signin", - "/new-user", - "/circle", - "/profile", - "/vouch", - "/scan", - "/results", - ]); - }); -}); diff --git a/frontend/__tests__/scan-page.test.tsx b/frontend/__tests__/scan-camera-page.test.tsx similarity index 93% rename from frontend/__tests__/scan-page.test.tsx rename to frontend/__tests__/scan-camera-page.test.tsx index 1dd0cde..546c1d7 100644 --- a/frontend/__tests__/scan-page.test.tsx +++ b/frontend/__tests__/scan-camera-page.test.tsx @@ -1,9 +1,9 @@ import type { Session } from "@supabase/supabase-js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import ScanPage from "../src/app/(routes)/scan/page"; +import CameraPage from "../src/app/(routes)/scan/camera/page"; import { useScanStore } from "../src/lib/scanStore"; import { useUserStore } from "../src/lib/store"; @@ -29,14 +29,16 @@ vi.mock("../src/lib/wallet", () => ({ ensureWallet: ensureWalletMock, })); -describe("ScanPage", () => { +describe("CameraPage", () => { beforeEach(() => { pushMock.mockReset(); requestQrChallengeMock.mockReset(); verifyQrChallengeMock.mockReset(); ensureWalletMock.mockReset(); - useUserStore.getState().reset(); - useScanStore.getState().reset(); + act(() => { + useUserStore.getState().reset(); + useScanStore.getState().reset(); + }); const session = { access_token: "supabase-token", @@ -84,7 +86,7 @@ describe("ScanPage", () => { ], }); - render(); + render(); const payloadTextarea = screen.getByPlaceholderText(/Paste JSON like/); const targetAddress = "0x000000000000000000000000000000000000dEaD"; @@ -146,7 +148,7 @@ describe("ScanPage", () => { it("surfaces QR payload validation errors", async () => { const user = userEvent.setup(); - render(); + render(); const payloadTextarea = screen.getByPlaceholderText(/Paste JSON like/); fireEvent.change(payloadTextarea, { diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 05e726d..a83736a 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -5,6 +5,12 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + "react-hooks/purity": "off", + "react-hooks/set-state-in-effect": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/frontend/package.json b/frontend/package.json index 8100241..95d56d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint . --ext .ts,.tsx --max-warnings 0", "test": "vitest run" }, "dependencies": { diff --git a/frontend/src/app/(routes)/scan/camera/page.tsx b/frontend/src/app/(routes)/scan/camera/page.tsx new file mode 100644 index 0000000..3b5db37 --- /dev/null +++ b/frontend/src/app/(routes)/scan/camera/page.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; + +import QRScanner from "@/components/QRScanner"; +import { requestQrChallenge, verifyQrChallenge } from "@/lib/api"; +import { useScanStore } from "@/lib/scanStore"; +import { useUserStore } from "@/lib/store"; +import { ensureWallet } from "@/lib/wallet"; + +type ParsedQrPayload = { + cubidId: string; + address?: string; +}; + +export default function CameraPage() { + const router = useRouter(); + const session = useUserStore((state) => state.session); + const profile = useUserStore((state) => state.user); + const walletAddress = useUserStore((state) => state.walletAddress ?? state.user?.evm_address ?? null); + const setWalletAddress = useUserStore((state) => state.setWalletAddress); + const setResult = useScanStore((state) => state.setResult); + + const [rawQr, setRawQr] = useState(""); + const [parsed, setParsed] = useState(null); + const [challenge, setChallenge] = useState<{ id: string; value: string } | null>(null); + const [viewerSignature, setViewerSignature] = useState(null); + const [targetAddress, setTargetAddress] = useState(""); + const [targetSignature, setTargetSignature] = useState(""); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + function parseQrPayload(): ParsedQrPayload { + if (!rawQr.trim()) { + throw new Error("Paste a QR payload first"); + } + const payload = JSON.parse(rawQr) as ParsedQrPayload; + if (!payload.cubidId) { + throw new Error("QR payload missing cubidId"); + } + return payload; + } + + async function handleParse() { + try { + const payload = parseQrPayload(); + setParsed(payload); + setTargetAddress(payload.address ?? ""); + setStatus("QR payload parsed"); + setError(null); + } catch (err) { + const message = err instanceof Error ? err.message : "Invalid QR payload"; + setError(message); + setStatus(null); + setParsed(null); + } + } + + async function handleChallenge() { + if (!parsed) { + setError("Parse a QR payload first"); + return; + } + if (!session?.access_token) { + setError("Supabase session required"); + return; + } + setStatus("Requesting challenge…"); + setError(null); + try { + const result = await requestQrChallenge(parsed.cubidId, session.access_token); + setChallenge({ id: result.challengeId, value: result.challenge }); + setStatus("Challenge issued. Share it with your peer to sign."); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to request challenge"; + setError(message); + setStatus(null); + setChallenge(null); + } + } + + async function handleSignChallenge() { + if (!challenge) { + setError("Issue a challenge first"); + return; + } + setStatus("Requesting wallet signature…"); + setError(null); + try { + const address = walletAddress ?? (await ensureWallet()); + if (!address) { + throw new Error("Wallet unavailable"); + } + setWalletAddress(address); + + const ethereum = (window as typeof window & { + ethereum?: { request(args: { method: string; params?: unknown[] }): Promise }; + }).ethereum; + if (!ethereum) { + throw new Error("Wallet provider missing"); + } + + const signature = await ethereum.request({ + method: "personal_sign", + params: [challenge.value, address], + }); + + setViewerSignature(signature); + setStatus("Viewer signature captured"); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to sign challenge"; + setError(message); + setStatus(null); + setViewerSignature(null); + } + } + + async function handleVerify(event: FormEvent) { + event.preventDefault(); + + if (!session?.access_token) { + setError("Supabase session required"); + return; + } + if (!profile?.cubid_id) { + setError("Viewer Cubid ID missing"); + return; + } + if (!challenge || !parsed) { + setError("Challenge not ready"); + return; + } + if (!viewerSignature) { + setError("Viewer signature missing"); + return; + } + + setStatus("Verifying overlap…"); + setError(null); + + try { + const viewerAddr = walletAddress ?? (await ensureWallet()); + if (!viewerAddr) { + throw new Error("Wallet unavailable"); + } + setWalletAddress(viewerAddr); + + const result = await verifyQrChallenge( + { + challengeId: challenge.id, + challenge: challenge.value, + viewer: { cubidId: profile.cubid_id, address: viewerAddr, signature: viewerSignature }, + target: { cubidId: parsed.cubidId, address: targetAddress, signature: targetSignature }, + }, + session.access_token, + ); + + setResult({ + targetCubid: parsed.cubidId, + viewerCubid: profile.cubid_id, + challengeId: result.challengeId, + expiresAt: result.expiresAt, + overlaps: result.overlaps, + verifiedAt: Date.now(), + }); + setStatus("Overlap ready"); + router.push("/results"); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to verify challenge"; + setError(message); + setStatus(null); + } + } + + return ( +
+
+ Camera mode +

Scan a peer and finish the handshake

+

+ Point your camera at their Cubid QR or paste the JSON payload directly. Once both sides sign the temporary challenge, + we’ll surface the overlap from EAS. +

+
+ +
+
+ +