Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/__tests__/app-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe("AppHeader", () => {

render(<AppHeader />);

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");
Expand Down
82 changes: 82 additions & 0 deletions frontend/__tests__/profile-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ProfilePage />);

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(<ProfilePage />);

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();
});
});
69 changes: 65 additions & 4 deletions frontend/__tests__/scan-camera-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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();
Expand All @@ -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<typeof vi.fn> } }).ethereum = {
request: ethereumRequest,
Expand All @@ -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<typeof vi.fn> };
}).ethereum;
Expand All @@ -86,12 +122,20 @@ describe("CameraPage", () => {
],
});

notifyHandshakeCompleteMock.mockResolvedValueOnce(undefined);

render(<CameraPage />);

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 }));
Expand Down Expand Up @@ -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;
Expand All @@ -150,9 +207,13 @@ describe("CameraPage", () => {

render(<CameraPage />);

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 }));
Expand Down
108 changes: 108 additions & 0 deletions frontend/__tests__/scan-qr-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MyQrPage />);

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(<MyQrPage />);

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");
});
});
1 change: 1 addition & 0 deletions frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/app/(routes)/new-user/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -105,7 +105,7 @@ export default function NewUserPage() {
return (
<section className="space-y-6">
<header className="space-y-2">
<h1 className="text-3xl font-semibold">Welcome to Peer Mapper</h1>
<h1 className="text-3xl font-semibold">Welcome to Trust Me Bro</h1>
<p className="text-muted-foreground">
Confirm your Cubid identity, pick a display name, and connect your Nova/EVM wallet to start vouching and scanning.
</p>
Expand All @@ -119,7 +119,7 @@ export default function NewUserPage() {
<input
className="rounded border border-neutral-300 px-3 py-2 text-base shadow-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
onChange={(event) => setForm((prev) => ({ ...prev, displayName: event.target.value }))}
placeholder="Casey Mapper"
placeholder="Casey Rivers"
value={form.displayName}
/>
</label>
Expand Down
Loading
Loading