diff --git a/frontend/__tests__/app-header.test.tsx b/frontend/__tests__/app-header.test.tsx index e3c70b2..072e0d4 100644 --- a/frontend/__tests__/app-header.test.tsx +++ b/frontend/__tests__/app-header.test.tsx @@ -38,7 +38,7 @@ describe("AppHeader", () => { render(); - expect(screen.getByRole("link", { name: /Peer Mapper/i })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: /Trust Me Bro/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"); diff --git a/frontend/__tests__/profile-page.test.tsx b/frontend/__tests__/profile-page.test.tsx new file mode 100644 index 0000000..5b102bf --- /dev/null +++ b/frontend/__tests__/profile-page.test.tsx @@ -0,0 +1,82 @@ +import type { Session } from "@supabase/supabase-js"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ProfilePage from "../src/app/(routes)/profile/page"; +import { useUserStore } from "../src/lib/store"; + +const { replaceMock } = vi.hoisted(() => ({ + replaceMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: replaceMock, + }), +})); + +describe("ProfilePage", () => { + beforeEach(() => { + replaceMock.mockReset(); + act(() => { + useUserStore.getState().reset(); + }); + + const session = { + access_token: "token", + user: { id: "user-1", email: "user@example.com" }, + } as unknown as Session; + + act(() => { + useUserStore.setState({ + session, + user: { + user_id: session.user.id, + cubid_id: "cubid_me", + display_name: "Maple Leaf", + photo_url: "https://example.com/photo.png", + }, + }); + }); + }); + + afterEach(() => { + act(() => { + useUserStore.getState().reset(); + }); + }); + + it("shows an on-page preview of the profile", () => { + render(); + + expect(screen.getByText(/This name can be a nickname/i)).toBeInTheDocument(); + expect(screen.getByText(/Maple Leaf/)).toBeInTheDocument(); + + const previewHeading = screen.getByRole("heading", { name: /Preview for peers/i }); + const previewAside = previewHeading.closest("aside"); + expect(previewAside).not.toBeNull(); + if (previewAside) { + expect(within(previewAside).getByText(/Cubid ID: cubid_me/)).toBeInTheDocument(); + } + + const previewImage = screen.getByAltText(/Profile photo preview/i) as HTMLImageElement; + expect(previewImage.src).toContain("https://example.com/photo.png"); + }); + + it("surfaces a warning when the photo URL cannot load", async () => { + const user = userEvent.setup(); + + render(); + + const photoInput = screen.getByLabelText(/Photo URL/i); + await user.clear(photoInput); + await user.type(photoInput, "https://example.com/broken.png"); + + const previewImage = screen.getByAltText(/Profile photo preview/i); + fireEvent.error(previewImage); + + expect(screen.getByText(/We couldn’t load this image/i)).toBeInTheDocument(); + expect(screen.getByText("!")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/scan-camera-page.test.tsx b/frontend/__tests__/scan-camera-page.test.tsx index 546c1d7..7c421fa 100644 --- a/frontend/__tests__/scan-camera-page.test.tsx +++ b/frontend/__tests__/scan-camera-page.test.tsx @@ -1,17 +1,26 @@ import type { Session } from "@supabase/supabase-js"; 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import CameraPage from "../src/app/(routes)/scan/camera/page"; import { useScanStore } from "../src/lib/scanStore"; import { useUserStore } from "../src/lib/store"; -const { pushMock, requestQrChallengeMock, verifyQrChallengeMock, ensureWalletMock } = vi.hoisted(() => ({ +const { + pushMock, + requestQrChallengeMock, + verifyQrChallengeMock, + ensureWalletMock, + getUserMediaMock, + notifyHandshakeCompleteMock, +} = vi.hoisted(() => ({ pushMock: vi.fn(), requestQrChallengeMock: vi.fn(), verifyQrChallengeMock: vi.fn(), ensureWalletMock: vi.fn(), + getUserMediaMock: vi.fn(), + notifyHandshakeCompleteMock: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -29,12 +38,28 @@ vi.mock("../src/lib/wallet", () => ({ ensureWallet: ensureWalletMock, })); +vi.mock("../src/lib/handshake", () => ({ + notifyHandshakeComplete: notifyHandshakeCompleteMock, +})); + +const originalPlay = HTMLMediaElement.prototype.play; + +beforeAll(() => { + HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined); +}); + +afterAll(() => { + HTMLMediaElement.prototype.play = originalPlay; +}); + describe("CameraPage", () => { beforeEach(() => { pushMock.mockReset(); requestQrChallengeMock.mockReset(); verifyQrChallengeMock.mockReset(); ensureWalletMock.mockReset(); + getUserMediaMock.mockReset(); + notifyHandshakeCompleteMock.mockReset(); act(() => { useUserStore.getState().reset(); useScanStore.getState().reset(); @@ -51,6 +76,15 @@ describe("CameraPage", () => { walletAddress: null, }); + getUserMediaMock.mockResolvedValue({ + getTracks: () => [{ stop: vi.fn() }], + } as unknown as MediaStream); + + Object.defineProperty(window.navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: getUserMediaMock }, + }); + const ethereumRequest = vi.fn(); (window as typeof window & { ethereum?: { request: ReturnType } }).ethereum = { request: ethereumRequest, @@ -59,10 +93,12 @@ describe("CameraPage", () => { afterEach(() => { delete (window as typeof window & { ethereum?: unknown }).ethereum; + delete (window.navigator as Navigator & { mediaDevices?: MediaDevices }).mediaDevices; }); it("completes the QR verification happy path", async () => { const user = userEvent.setup(); + const handshakeChannel = "handshake-channel"; const ethereum = (window as typeof window & { ethereum?: { request: ReturnType }; }).ethereum; @@ -86,12 +122,20 @@ describe("CameraPage", () => { ], }); + notifyHandshakeCompleteMock.mockResolvedValueOnce(undefined); + render(); + await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled()); + const devSummary = await screen.findByText(/for devs only/i); + await user.click(devSummary); + const payloadTextarea = screen.getByPlaceholderText(/Paste JSON like/); const targetAddress = "0x000000000000000000000000000000000000dEaD"; fireEvent.change(payloadTextarea, { - target: { value: `{"cubidId":"cubid_peer","address":"${targetAddress}"}` }, + target: { + value: `{"cubidId":"cubid_peer","channel":"${handshakeChannel}","address":"${targetAddress}"}`, + }, }); await user.click(screen.getByRole("button", { name: /parse qr payload/i })); @@ -138,6 +182,19 @@ describe("CameraPage", () => { ), ); + await waitFor(() => + expect(notifyHandshakeCompleteMock).toHaveBeenCalledWith({ + channel: handshakeChannel, + challengeId: "challenge-1", + expiresAt: 1700001200, + overlaps: [ + { issuer: "0xIssuer", trustLevel: 4, circle: null, freshnessSeconds: 42 }, + ], + targetCubid: "cubid_peer", + viewerCubid: "cubid_me", + }), + ); + await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/results")); const result = useScanStore.getState().lastResult; @@ -150,9 +207,13 @@ describe("CameraPage", () => { render(); + await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled()); + const devSummary = await screen.findByText(/for devs only/i); + await user.click(devSummary); + const payloadTextarea = screen.getByPlaceholderText(/Paste JSON like/); fireEvent.change(payloadTextarea, { - target: { value: '{"address":"0xPeer"}' }, + target: { value: '{"address":"0xPeer","channel":"handshake"}' }, }); await user.click(screen.getByRole("button", { name: /parse qr payload/i })); diff --git a/frontend/__tests__/scan-qr-page.test.tsx b/frontend/__tests__/scan-qr-page.test.tsx new file mode 100644 index 0000000..9174a7b --- /dev/null +++ b/frontend/__tests__/scan-qr-page.test.tsx @@ -0,0 +1,108 @@ +import type { Session } from "@supabase/supabase-js"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import MyQrPage from "../src/app/(routes)/scan/my-qr/page"; +import type { HandshakeCompletion } from "../src/lib/handshake"; +import { useScanStore } from "../src/lib/scanStore"; +import { useUserStore } from "../src/lib/store"; + +const { pushMock, subscribeToHandshakeMock, randomUuidMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), + subscribeToHandshakeMock: vi.fn(), + randomUuidMock: vi.fn(() => "handshake-channel"), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + }), +})); + +vi.mock("../src/lib/handshake", () => ({ + subscribeToHandshake: subscribeToHandshakeMock, +})); + +describe("MyQrPage", () => { + const originalCrypto = globalThis.crypto; + + beforeEach(() => { + pushMock.mockReset(); + subscribeToHandshakeMock.mockReset(); + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { + ...originalCrypto, + randomUUID: randomUuidMock, + }, + }); + act(() => { + useUserStore.getState().reset(); + useScanStore.getState().reset(); + }); + + const session = { access_token: "token", user: { id: "user-1" } } as unknown as Session; + useUserStore.setState({ + session, + user: { + user_id: "user-1", + cubid_id: "cubid_me", + display_name: "Casey Rivers", + }, + }); + }); + + afterEach(() => { + subscribeToHandshakeMock.mockReset(); + randomUuidMock.mockClear(); + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }); + }); + + it("renders the QR code and profile name", async () => { + subscribeToHandshakeMock.mockResolvedValue(() => undefined); + + render(); + + await waitFor(() => expect(subscribeToHandshakeMock).toHaveBeenCalledWith("handshake-channel", expect.any(Function))); + + expect(screen.getByRole("img", { name: /scan this within ninety seconds/i })).toBeInTheDocument(); + expect(screen.getByText("Casey Rivers")).toBeInTheDocument(); + expect(screen.getByText(/Cubid ID: cubid_me/i)).toBeInTheDocument(); + }); + + it("redirects to results when a handshake completes", async () => { + let handler: ((payload: HandshakeCompletion) => void) | null = null; + subscribeToHandshakeMock.mockImplementation(async (_channel, callback) => { + handler = callback; + return vi.fn(); + }); + + render(); + + await waitFor(() => expect(handler).toBeInstanceOf(Function)); + + const payload: HandshakeCompletion = { + channel: "handshake-channel", + challengeId: "challenge-1", + expiresAt: 1700001200, + overlaps: [], + targetCubid: "cubid_me", + viewerCubid: "cubid_peer", + }; + + await act(async () => { + handler?.(payload); + }); + + await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/results")); + + expect(screen.getByText(/Handshake completed/i)).toBeInTheDocument(); + const result = useScanStore.getState().lastResult; + expect(result?.challengeId).toBe("challenge-1"); + expect(result?.viewerCubid).toBe("cubid_peer"); + expect(result?.targetCubid).toBe("cubid_me"); + }); +}); diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index a83736a..a1f619c 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -9,6 +9,7 @@ const eslintConfig = defineConfig([ rules: { "react-hooks/purity": "off", "react-hooks/set-state-in-effect": "off", + "@next/next/no-img-element": "off", }, }, // Override default ignores of eslint-config-next. diff --git a/frontend/package.json b/frontend/package.json index 95d56d6..a187749 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.80.0", + "qrcode.react": "^4.2.0", "next": "16.0.1", "react": "19.2.0", "react-dom": "19.2.0", diff --git a/frontend/src/app/(routes)/new-user/page.tsx b/frontend/src/app/(routes)/new-user/page.tsx index 0af7f2c..a1a3b9f 100644 --- a/frontend/src/app/(routes)/new-user/page.tsx +++ b/frontend/src/app/(routes)/new-user/page.tsx @@ -29,10 +29,10 @@ export default function NewUserPage() { router.replace("/(routes)/signin"); return; } - if (profile?.cubid_id && profile?.display_name && profile?.evm_address) { - router.replace("/(routes)/circle"); + if (profile?.cubid_id) { + router.replace("/(routes)/profile"); } - }, [profile, router, session]); + }, [profile?.cubid_id, router, session]); useEffect(() => { setForm({ @@ -105,7 +105,7 @@ export default function NewUserPage() { return (
-

Welcome to Peer Mapper

+

Welcome to Trust Me Bro

Confirm your Cubid identity, pick a display name, and connect your Nova/EVM wallet to start vouching and scanning.

@@ -119,7 +119,7 @@ export default function NewUserPage() { setForm((prev) => ({ ...prev, displayName: event.target.value }))} - placeholder="Casey Mapper" + placeholder="Casey Rivers" value={form.displayName} /> diff --git a/frontend/src/app/(routes)/profile/page.tsx b/frontend/src/app/(routes)/profile/page.tsx index bf60276..74c4b4e 100644 --- a/frontend/src/app/(routes)/profile/page.tsx +++ b/frontend/src/app/(routes)/profile/page.tsx @@ -1,7 +1,8 @@ "use client"; + import { useRouter } from "next/navigation"; -import { type FormEvent, useEffect, useState } from "react"; +import { type FormEvent, useEffect, useMemo, useState } from "react"; import { upsertMyProfile } from "../../../lib/profile"; import { useUserStore } from "../../../lib/store"; @@ -12,10 +13,23 @@ export default function ProfilePage() { const profile = useUserStore((state) => state.user); const setUser = useUserStore((state) => state.setUser); - const [displayName, setDisplayName] = useState(""); - const [photoUrl, setPhotoUrl] = useState(""); + const [displayName, setDisplayName] = useState(profile?.display_name ?? ""); + const [photoUrl, setPhotoUrl] = useState(profile?.photo_url ?? ""); const [status, setStatus] = useState(null); const [error, setError] = useState(null); + const [imageError, setImageError] = useState(false); + + const photoPreviewUrl = useMemo(() => photoUrl || profile?.photo_url || "", [photoUrl, profile?.photo_url]); + const previewName = displayName || profile?.display_name || "Your chosen name"; + + useEffect(() => { + setDisplayName(profile?.display_name ?? ""); + setPhotoUrl(profile?.photo_url ?? ""); + }, [profile?.display_name, profile?.photo_url]); + + useEffect(() => { + setImageError(false); + }, [photoPreviewUrl]); useEffect(() => { if (!session) { @@ -51,7 +65,9 @@ export default function ProfilePage() {

My profile

-

Review your Cubid ID and refresh your display information.

+

+ This name can be a nickname. Your name, picture, and Cubid ID are what you share with anyone you invite to connect. +

@@ -59,36 +75,75 @@ export default function ProfilePage() {

Wallet: {profile?.evm_address ?? "—"}

-
- - - -
+
+
+ + + + {status ?

{status}

: null} + {error ?

{error}

: null} +
- {status ?

{status}

: null} - {error ?

{error}

: null} + +
); } diff --git a/frontend/src/app/(routes)/scan/camera/page.tsx b/frontend/src/app/(routes)/scan/camera/page.tsx index 3b5db37..ac03103 100644 --- a/frontend/src/app/(routes)/scan/camera/page.tsx +++ b/frontend/src/app/(routes)/scan/camera/page.tsx @@ -5,12 +5,14 @@ import { type FormEvent, useState } from "react"; import QRScanner from "@/components/QRScanner"; import { requestQrChallenge, verifyQrChallenge } from "@/lib/api"; +import { type HandshakeCompletion, notifyHandshakeComplete } from "@/lib/handshake"; import { useScanStore } from "@/lib/scanStore"; import { useUserStore } from "@/lib/store"; import { ensureWallet } from "@/lib/wallet"; type ParsedQrPayload = { cubidId: string; + channel: string; address?: string; }; @@ -39,6 +41,9 @@ export default function CameraPage() { if (!payload.cubidId) { throw new Error("QR payload missing cubidId"); } + if (!payload.channel) { + throw new Error("QR payload missing channel"); + } return payload; } @@ -156,15 +161,34 @@ export default function CameraPage() { session.access_token, ); - setResult({ + const completion: HandshakeCompletion = { + channel: parsed.channel, targetCubid: parsed.cubidId, viewerCubid: profile.cubid_id, challengeId: result.challengeId, expiresAt: result.expiresAt, overlaps: result.overlaps, + }; + + setResult({ + targetCubid: completion.targetCubid, + viewerCubid: completion.viewerCubid, + challengeId: completion.challengeId, + expiresAt: completion.expiresAt, + overlaps: completion.overlaps, verifiedAt: Date.now(), }); - setStatus("Overlap ready"); + setStatus("Sharing overlaps with your peer…"); + + try { + await notifyHandshakeComplete(completion); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to notify peer of handshake"; + setError(message); + setStatus(null); + return; + } + router.push("/results"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to verify challenge"; @@ -184,87 +208,108 @@ export default function CameraPage() {

-
-
+
+
-