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
83 changes: 83 additions & 0 deletions lib/airdrop/activation-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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");
});
});
26 changes: 26 additions & 0 deletions lib/airdrop/activation-helpers.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "1.41.3",
"version": "1.41.4",
"private": true,
"workspaces": [
"packages/*"
Expand Down
114 changes: 114 additions & 0 deletions src/components/airdrop/ActivationFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ActivationFlow />);
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(<ActivationFlow />);
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(<ActivationFlow />);
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(<ActivationFlow />);
await waitFor(() => {
const dones = screen.getAllByText(/Done/i);
expect(dones.length).toBeGreaterThan(0);
});
});

it("clicking Sign & Activate calls signMessageAsync and handleInboundReferral", async () => {
render(<ActivationFlow />);
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(<ActivationFlow />);
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(<ActivationFlow />);
const btns = await waitFor(() => screen.getAllByText("Sign & Activate"));
await userEvent.click(btns[0]);
await waitFor(() => {
expect(screen.getByText(/Failed to sign/i)).toBeDefined();
});
});
});
18 changes: 2 additions & 16 deletions src/components/airdrop/ActivationFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
Loading