diff --git a/package-lock.json b/package-lock.json index 5bc95d8..3ad95d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.41.1", + "version": "1.41.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.41.1", + "version": "1.41.2", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 17ce46e..4f86885 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.41.1", + "version": "1.41.2", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/leaderboard/route.test.ts b/src/app/api/airdrop/leaderboard/route.test.ts new file mode 100644 index 0000000..d58ff9d --- /dev/null +++ b/src/app/api/airdrop/leaderboard/route.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockRpc = vi.fn(); +const mockUsers = vi.fn(); + +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + rpc: mockRpc, + from: (table: string) => { + if (table === "pl_referral_codes") return { select: () => ({ in: mockUsers }) }; + return {}; + }, + }), +})); +vi.mock("../../../../../lib/airdrop/config", () => ({ + getAirdropConfig: () => ({ + CAMPAIGN_START: new Date("2026-07-01"), + CAMPAIGN_END: new Date("2026-10-01"), + MIN_REFERRAL_THRESHOLD: 50, + REFERRAL_MULTIPLIER_PER_REF: 0.2, + REFERRAL_MULTIPLIER_CAP: 3.0, + }), +})); + +import { GET } from "./route"; + +describe("GET /api/airdrop/leaderboard", () => { + it("returns entries ordered by weighted_spend DESC", async () => { + mockRpc.mockResolvedValue({ + data: [ + { address: "alice", weighted_spend: 200, buy_volume: 100, qualified_refs: 1, has_fc_bonus: 1, multiplier: 2, community_total: 500 }, + { address: "bob", weighted_spend: 300, buy_volume: 150, qualified_refs: 2, has_fc_bonus: 0, multiplier: 1.4, community_total: 500 }, + ], + error: null, + }); + mockUsers.mockResolvedValue({ data: [] }); + + const req = new NextRequest("http://localhost/api/airdrop/leaderboard"); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.entries[0].address).toBe("bob"); + expect(data.entries[1].address).toBe("alice"); + expect(data.entries[0].totalPoints).toBe(300); + }); + + it("returns empty when no eligible wallets", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + mockUsers.mockResolvedValue({ data: [] }); + + const req = new NextRequest("http://localhost/api/airdrop/leaderboard"); + const res = await GET(req); + const data = await res.json(); + expect(data.entries).toHaveLength(0); + expect(data.totalParticipants).toBe(0); + }); + + it("passes correct config params to weighted_spend RPC", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + mockUsers.mockResolvedValue({ data: [] }); + + const req = new NextRequest("http://localhost/api/airdrop/leaderboard"); + await GET(req); + expect(mockRpc).toHaveBeenCalledWith("weighted_spend", expect.objectContaining({ + p_campaign_start: expect.any(String), + p_campaign_end: expect.any(String), + p_min_referral_threshold: 50, + p_multiplier_per_ref: 0.2, + p_multiplier_cap: 3.0, + })); + }); +}); diff --git a/src/app/api/airdrop/points/route.test.ts b/src/app/api/airdrop/points/route.test.ts new file mode 100644 index 0000000..e9edb77 --- /dev/null +++ b/src/app/api/airdrop/points/route.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockPoints = vi.fn(); +const mockAllPoints = vi.fn(); +const mockStreak = vi.fn(); +const mockRefCode = vi.fn(); +const mockReferredBy = vi.fn(); +const mockRefCount = vi.fn(); + +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + from: (table: string) => { + if (table === "pl_points") { + return { + select: (cols: string) => { + if (cols.includes("action")) return { eq: () => mockPoints() }; + return mockAllPoints(); + }, + }; + } + if (table === "pl_streaks") return { select: () => ({ eq: () => ({ single: mockStreak }) }) }; + if (table === "pl_referral_codes") return { select: () => ({ eq: () => ({ single: mockRefCode }) }) }; + if (table === "pl_referrals") { + return { + select: (cols: string, opts?: { count?: string; head?: boolean }) => { + if (opts?.count) return { eq: mockRefCount }; + return { eq: () => ({ single: mockReferredBy }) }; + }, + }; + } + return {}; + }, + }), +})); +vi.mock("../../../../../lib/airdrop/config", () => ({ + getAirdropConfig: () => ({ + CAMPAIGN_START: new Date("2026-07-01"), + CAMPAIGN_END: new Date("2026-10-01"), + POOL_AMOUNT: 200_000, + MILESTONES: { + BRONZE: { mcap: 100_000, pct: 10 }, + SILVER: { mcap: 1_000_000, pct: 30 }, + GOLD: { mcap: 5_000_000, pct: 50 }, + DIAMOND: { mcap: 10_000_000, pct: 100 }, + }, + }), +})); + +import { GET } from "./route"; + +describe("GET /api/airdrop/points", () => { + it("returns deprecated shape with buy_volume_plot + deprecation headers", async () => { + mockPoints.mockResolvedValue({ data: [{ action: "buy", points: 100 }] }); + mockAllPoints.mockResolvedValue({ data: [{ points: 100 }, { points: 200 }] }); + mockStreak.mockResolvedValue({ data: null }); + mockRefCode.mockResolvedValue({ data: { code: "mycode", is_farcaster_username: false } }); + mockReferredBy.mockResolvedValue({ data: null }); + mockRefCount.mockResolvedValue({ count: 3 }); + + const req = new NextRequest("http://localhost/api/airdrop/points?address=0xabc"); + const res = await GET(req); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.address).toBe("0xabc"); + expect(data.buy_volume_plot).toBeDefined(); + expect(data.fetched_at).toBeDefined(); + expect(data.totalPoints).toBeDefined(); + expect(data.referral.code).toBe("mycode"); + + expect(res.headers.get("Deprecation")).toBe("true"); + expect(res.headers.get("Link")).toContain("/api/airdrop/projection"); + }); + + it("handles non-existent address gracefully", async () => { + mockPoints.mockResolvedValue({ data: [] }); + mockAllPoints.mockResolvedValue({ data: [] }); + mockStreak.mockResolvedValue({ data: null }); + mockRefCode.mockResolvedValue({ data: null }); + mockReferredBy.mockResolvedValue({ data: null }); + mockRefCount.mockResolvedValue({ count: 0 }); + + const req = new NextRequest("http://localhost/api/airdrop/points?address=0xnone"); + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.totalPoints).toBe(0); + expect(data.buy_volume_plot).toBe(0); + }); + + it("returns 400 when address missing", async () => { + const req = new NextRequest("http://localhost/api/airdrop/points"); + const res = await GET(req); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/airdrop/status/route.test.ts b/src/app/api/airdrop/status/route.test.ts new file mode 100644 index 0000000..c1e68d5 --- /dev/null +++ b/src/app/api/airdrop/status/route.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; + +const mockPriceSingle = vi.fn(); +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + from: (table: string) => { + if (table === "pl_daily_prices") { + return { select: () => ({ order: () => ({ limit: () => ({ single: mockPriceSingle }) }) }) }; + } + if (table === "pl_activations") { + const allCount = 15; + const eligibleCount = 12; + return { + select: () => ({ + not: () => { + const base = Promise.resolve({ count: allCount }); + return Object.assign(base, { + eq: () => Promise.resolve({ count: eligibleCount }), + }); + }, + }), + }; + } + return {}; + }, + }), +})); +vi.mock("../../../../../lib/airdrop/config", () => ({ + getAirdropConfig: () => ({ + CAMPAIGN_START: new Date("2026-07-01"), + CAMPAIGN_END: new Date("2026-10-01"), + POOL_AMOUNT: 200_000, + MILESTONES: { + BRONZE: { mcap: 100_000, pct: 10 }, + SILVER: { mcap: 1_000_000, pct: 30 }, + GOLD: { mcap: 5_000_000, pct: 50 }, + DIAMOND: { mcap: 10_000_000, pct: 100 }, + }, + LOCKER_TX: null, + SIWE_DOMAIN: "plotlink.xyz", + SIWE_URI: "https://plotlink.xyz/airdrop", + SIWE_STATEMENT: "PlotLink Buy-Back Sprint activation", + SIWE_CHAIN_ID: 8453, + PLOTLINK_X_HANDLE: "plotlinkxyz", + PLOTLINK_FC_FID: 0, + }), +})); +vi.mock("../../../../../lib/usd-price", () => ({ getPlotUsdPrice: () => Promise.resolve(0.037) })); + +import { GET } from "./route"; + + +describe("GET /api/airdrop/status", () => { + it("returns v5 shape with milestones + activation counts + env_check", async () => { + mockPriceSingle.mockResolvedValue({ data: { price_usd: 0.037, mcap_usd: 37000 } }); + + const res = await GET(); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.milestones.bronze.mcap).toBe(100_000); + expect(data.milestones.diamond.mcap).toBe(10_000_000); + expect(data.poolAmount).toBe(200_000); + expect(data.activation_count).toBe(15); + expect(data.eligible_activation_count).toBe(12); + expect(typeof data.env_check.all_present).toBe("boolean"); + }); + + it("env_check.all_present is false when required env vars missing", async () => { + mockPriceSingle.mockResolvedValue({ data: null }); + + const res = await GET(); + const data = await res.json(); + expect(data.env_check.all_present).toBe(false); + }); +});