From aa8b0667ee58a72fb25406d6a95ff2d6942dc995 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 14:26:35 +0000 Subject: [PATCH 1/3] [#1302] Add ActivationFlow component tests (state machine + resume) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 tests covering: initial step 1 render, refresh-resume to step 3 when x_handle confirmed, FC follow optional display, X follow done state. Mocks wagmi + siwe + fetch. Note: async interaction tests (SIWE sign click → step advance, localStorage ref binding) hit jsdom timing limitations and are documented as gaps for E2E coverage. Closes #1302 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- .../airdrop/ActivationFlow.test.tsx | 62 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 src/components/airdrop/ActivationFlow.test.tsx diff --git a/package-lock.json b/package-lock.json index 683641f..8a6c7e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.41.3", + "version": "1.41.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.41.3", + "version": "1.41.4", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 5925730..551d236 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.41.3", + "version": "1.41.4", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/ActivationFlow.test.tsx b/src/components/airdrop/ActivationFlow.test.tsx new file mode 100644 index 0000000..7fec387 --- /dev/null +++ b/src/components/airdrop/ActivationFlow.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { ActivationFlow } from "./ActivationFlow"; + +vi.mock("wagmi", () => ({ + useAccount: () => ({ address: "0xABC123", chainId: 8453 }), + useSignMessage: () => ({ signMessageAsync: vi.fn().mockResolvedValue("0xmocksig") }), +})); + +vi.mock("siwe", () => ({ + SiweMessage: class { + prepareMessage() { return "mock-siwe-message"; } + }, +})); + +let activationStatus = { x_handle_confirmed_at: null as string | null, x_follow_at: null as string | null, fc_verified_at: null, activated_at: null }; + +beforeEach(() => { + activationStatus = { x_handle_confirmed_at: null, x_follow_at: null, fc_verified_at: null, activated_at: null }; + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + status: 200, + json: () => Promise.resolve(activationStatus), + } as Response))); +}); + +afterEach(() => { vi.unstubAllGlobals(); }); + +describe("ActivationFlow", () => { + it("renders step 1 (SIWE sign) when not activated", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("Sign & Activate")).toBeDefined(); + }); + }); + + it("skips to step 3 (missions) when x_handle already confirmed", async () => { + activationStatus = { x_handle_confirmed_at: "2026-07-01", x_follow_at: null, fc_verified_at: null, activated_at: null }; + render(); + await waitFor(() => { + expect(screen.getByText(/Follow @plotlinkxyz/i)).toBeDefined(); + }); + }); + + it("shows FC follow as optional in step 3", async () => { + activationStatus = { x_handle_confirmed_at: "2026-07-01", x_follow_at: null, fc_verified_at: null, activated_at: null }; + render(); + await waitFor(() => { + expect(screen.getByText(/optional/i)).toBeDefined(); + }); + }); + + it("shows X follow as done when x_follow_at present", async () => { + activationStatus = { x_handle_confirmed_at: "2026-07-01", x_follow_at: "2026-07-02", fc_verified_at: null, activated_at: null }; + render(); + await waitFor(() => { + const dones = screen.getAllByText(/Done/i); + expect(dones.length).toBeGreaterThan(0); + }); + }); +}); From 5cb56587d1a11028acf180bec540a6e781e318b9 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 14:29:09 +0000 Subject: [PATCH 2/3] [#1302] Extract + test inbound referral binding logic Extract handleInboundReferral to lib/airdrop/activation-helpers.ts for testability. 9 unit tests cover all ref binding semantics: SIWE auth in POST body, clear on 200/400/404/409, retain on 401/500/network failure. ActivationFlow uses extracted helper. 4 component tests cover state machine + refresh-resume. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/airdrop/activation-helpers.test.ts | 83 +++++++++++++++++++++++ lib/airdrop/activation-helpers.ts | 25 +++++++ src/components/airdrop/ActivationFlow.tsx | 18 +---- 3 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 lib/airdrop/activation-helpers.test.ts create mode 100644 lib/airdrop/activation-helpers.ts diff --git a/lib/airdrop/activation-helpers.test.ts b/lib/airdrop/activation-helpers.test.ts new file mode 100644 index 0000000..2ade7b5 --- /dev/null +++ b/lib/airdrop/activation-helpers.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { handleInboundReferral, REFERRAL_STORAGE_KEY } from "./activation-helpers"; + +beforeEach(() => { localStorage.clear(); }); + +function mockFetch(status: number) { + return vi.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve({}), + })) as unknown as typeof fetch; +} + +function throwingFetch() { + return vi.fn(async () => { throw new Error("network error"); }) as unknown as typeof fetch; +} + +describe("handleInboundReferral", () => { + it("does nothing when no ref in localStorage", async () => { + const fn = mockFetch(200); + await handleInboundReferral("msg", "sig", fn); + expect(fn).not.toHaveBeenCalled(); + }); + + it("POSTs to register-referral with SIWE auth", async () => { + localStorage.setItem(REFERRAL_STORAGE_KEY, "TESTCODE"); + const fn = mockFetch(200); + await handleInboundReferral("mock-msg", "mock-sig", fn); + expect(fn).toHaveBeenCalledWith( + "/api/airdrop/register-referral", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining("mock-msg"), + }), + ); + const body = JSON.parse((fn as ReturnType).mock.calls[0][1].body); + expect(body.message).toBe("mock-msg"); + expect(body.signature).toBe("mock-sig"); + expect(body.referralCode).toBe("TESTCODE"); + }); + + it("clears localStorage on 200 success", async () => { + localStorage.setItem(REFERRAL_STORAGE_KEY, "REF1"); + await handleInboundReferral("m", "s", mockFetch(200)); + expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull(); + }); + + it("clears localStorage on 400 (self-referral)", async () => { + localStorage.setItem(REFERRAL_STORAGE_KEY, "REF2"); + await handleInboundReferral("m", "s", mockFetch(400)); + expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull(); + }); + + it("clears localStorage on 404 (code not found)", async () => { + localStorage.setItem(REFERRAL_STORAGE_KEY, "REF3"); + await handleInboundReferral("m", "s", mockFetch(404)); + expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull(); + }); + + it("clears localStorage on 409 (already referred)", async () => { + localStorage.setItem(REFERRAL_STORAGE_KEY, "REF4"); + await handleInboundReferral("m", "s", mockFetch(409)); + expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBeNull(); + }); + + it("retains localStorage on 401 (transient auth error)", async () => { + localStorage.setItem(REFERRAL_STORAGE_KEY, "REF5"); + await handleInboundReferral("m", "s", mockFetch(401)); + expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBe("REF5"); + }); + + it("retains localStorage on 500 (server error)", async () => { + localStorage.setItem(REFERRAL_STORAGE_KEY, "REF6"); + await handleInboundReferral("m", "s", mockFetch(500)); + expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBe("REF6"); + }); + + it("retains localStorage on network failure", async () => { + localStorage.setItem(REFERRAL_STORAGE_KEY, "REF7"); + await handleInboundReferral("m", "s", throwingFetch()); + expect(localStorage.getItem(REFERRAL_STORAGE_KEY)).toBe("REF7"); + }); +}); diff --git a/lib/airdrop/activation-helpers.ts b/lib/airdrop/activation-helpers.ts new file mode 100644 index 0000000..90a7d15 --- /dev/null +++ b/lib/airdrop/activation-helpers.ts @@ -0,0 +1,25 @@ +export const REFERRAL_STORAGE_KEY = "plotlink_ref"; + +export async function handleInboundReferral( + message: string, + signature: string, + fetchFn: typeof fetch = fetch, +): Promise { + const refCode = typeof localStorage !== "undefined" + ? localStorage.getItem(REFERRAL_STORAGE_KEY) + : null; + if (!refCode) return; + + try { + const res = await fetchFn("/api/airdrop/register-referral", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message, signature, referralCode: refCode }), + }); + if (res.ok || res.status === 400 || res.status === 404 || res.status === 409) { + localStorage.removeItem(REFERRAL_STORAGE_KEY); + } + } catch { + // transient error — keep localStorage for retry + } +} diff --git a/src/components/airdrop/ActivationFlow.tsx b/src/components/airdrop/ActivationFlow.tsx index 73cbb8d..c424903 100644 --- a/src/components/airdrop/ActivationFlow.tsx +++ b/src/components/airdrop/ActivationFlow.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react"; import { useAccount, useSignMessage } from "wagmi"; import { SiweMessage } from "siwe"; -import { REFERRAL_STORAGE_KEY } from "../../hooks/useReferralCapture"; +import { handleInboundReferral } from "../../../lib/airdrop/activation-helpers"; type StepState = "idle" | "active" | "done"; @@ -93,21 +93,7 @@ export function ActivationFlow({ onActivated }: ActivationFlowProps) { setSiweMessage(msg); setSignature(sig); - const refCode = localStorage.getItem(REFERRAL_STORAGE_KEY); - if (refCode) { - try { - const res = await fetch("/api/airdrop/register-referral", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: msg, signature: sig, referralCode: refCode }), - }); - if (res.ok || res.status === 400 || res.status === 404 || res.status === 409) { - localStorage.removeItem(REFERRAL_STORAGE_KEY); - } - } catch { - // transient error — keep localStorage for retry - } - } + await handleInboundReferral(msg, sig); if (xConfirmed) { setStep(3); From e7f2a868bd849044cb51d6f01113c3a366ebfe95 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 14:33:05 +0000 Subject: [PATCH 3/3] [#1302] Add SIWE click + error state + ref binding integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component tests now cover: - Sign & Activate click → signMessageAsync called with SIWE message - handleInboundReferral called with message + signature - UI advances to X handle verification (step 2) - Signature rejected → inline error - Generic sign failure → error message Plus 9 unit tests for handleInboundReferral clear/retain semantics and 4 state machine/resume tests. Total: 16 tests. Fixed REFERRAL_STORAGE_KEY duplication (re-export from capture hook). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/airdrop/activation-helpers.ts | 3 +- .../airdrop/ActivationFlow.test.tsx | 58 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lib/airdrop/activation-helpers.ts b/lib/airdrop/activation-helpers.ts index 90a7d15..43f1016 100644 --- a/lib/airdrop/activation-helpers.ts +++ b/lib/airdrop/activation-helpers.ts @@ -1,4 +1,5 @@ -export const REFERRAL_STORAGE_KEY = "plotlink_ref"; +import { REFERRAL_STORAGE_KEY } from "../../src/hooks/useReferralCapture"; +export { REFERRAL_STORAGE_KEY }; export async function handleInboundReferral( message: string, diff --git a/src/components/airdrop/ActivationFlow.test.tsx b/src/components/airdrop/ActivationFlow.test.tsx index 7fec387..b472c1e 100644 --- a/src/components/airdrop/ActivationFlow.test.tsx +++ b/src/components/airdrop/ActivationFlow.test.tsx @@ -1,11 +1,14 @@ // @vitest-environment jsdom import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; -import { ActivationFlow } from "./ActivationFlow"; +import userEvent from "@testing-library/user-event"; + +const mockSignMessageAsync = vi.fn().mockResolvedValue("0xmocksig"); +const mockHandleInboundReferral = vi.fn().mockResolvedValue(undefined); vi.mock("wagmi", () => ({ useAccount: () => ({ address: "0xABC123", chainId: 8453 }), - useSignMessage: () => ({ signMessageAsync: vi.fn().mockResolvedValue("0xmocksig") }), + useSignMessage: () => ({ signMessageAsync: mockSignMessageAsync }), })); vi.mock("siwe", () => ({ @@ -14,10 +17,16 @@ vi.mock("siwe", () => ({ }, })); +vi.mock("../../../lib/airdrop/activation-helpers", () => ({ + handleInboundReferral: (...args: unknown[]) => mockHandleInboundReferral(...args), +})); + let activationStatus = { x_handle_confirmed_at: null as string | null, x_follow_at: null as string | null, fc_verified_at: null, activated_at: null }; beforeEach(() => { activationStatus = { x_handle_confirmed_at: null, x_follow_at: null, fc_verified_at: null, activated_at: null }; + mockSignMessageAsync.mockResolvedValue("0xmocksig"); + mockHandleInboundReferral.mockResolvedValue(undefined); vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, status: 200, @@ -25,7 +34,12 @@ beforeEach(() => { } as Response))); }); -afterEach(() => { vi.unstubAllGlobals(); }); +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +import { ActivationFlow } from "./ActivationFlow"; describe("ActivationFlow", () => { it("renders step 1 (SIWE sign) when not activated", async () => { @@ -59,4 +73,42 @@ describe("ActivationFlow", () => { expect(dones.length).toBeGreaterThan(0); }); }); + + it("clicking Sign & Activate calls signMessageAsync and handleInboundReferral", async () => { + render(); + const btns = await waitFor(() => screen.getAllByText("Sign & Activate")); + await userEvent.click(btns[0]); + + await waitFor(() => { + expect(mockSignMessageAsync).toHaveBeenCalledWith({ message: "mock-siwe-message" }); + }); + + await waitFor(() => { + expect(mockHandleInboundReferral).toHaveBeenCalledWith("mock-siwe-message", "0xmocksig"); + }); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/@handle/i)).toBeDefined(); + }); + }); + + it("shows error when signature rejected by user", async () => { + mockSignMessageAsync.mockRejectedValue(new Error("User rejected the request")); + render(); + const btns = await waitFor(() => screen.getAllByText("Sign & Activate")); + await userEvent.click(btns[0]); + await waitFor(() => { + expect(screen.getByText(/Signature rejected/i)).toBeDefined(); + }); + }); + + it("shows generic error on non-rejection sign failure", async () => { + mockSignMessageAsync.mockRejectedValue(new Error("Unknown wallet error")); + render(); + const btns = await waitFor(() => screen.getAllByText("Sign & Activate")); + await userEvent.click(btns[0]); + await waitFor(() => { + expect(screen.getByText(/Failed to sign/i)).toBeDefined(); + }); + }); });