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..43f1016 --- /dev/null +++ b/lib/airdrop/activation-helpers.ts @@ -0,0 +1,26 @@ +import { REFERRAL_STORAGE_KEY } from "../../src/hooks/useReferralCapture"; +export { REFERRAL_STORAGE_KEY }; + +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/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..b472c1e --- /dev/null +++ b/src/components/airdrop/ActivationFlow.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +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: mockSignMessageAsync }), +})); + +vi.mock("siwe", () => ({ + SiweMessage: class { + prepareMessage() { return "mock-siwe-message"; } + }, +})); + +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, + json: () => Promise.resolve(activationStatus), + } as Response))); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +import { ActivationFlow } from "./ActivationFlow"; + +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); + }); + }); + + 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(); + }); + }); +}); 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);