From 9d568024b16b4fe72f917056ab65bf52cfb64a97 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 13:54:08 +0000 Subject: [PATCH 1/3] [#1300] Add route tests for status, leaderboard, points endpoints 8 tests across 3 files: - status: v5 shape with milestones/activation_counts/env_check, env_check.all_present false when env vars missing - leaderboard: entries ordered by weighted_spend DESC, empty case, correct config params passed to weighted_spend RPC - points: deprecated shape with buy_volume_plot + Deprecation/Link headers, non-existent address, missing param 400 Closes #1300 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- src/app/api/airdrop/leaderboard/route.test.ts | 74 ++++++++++++++ src/app/api/airdrop/points/route.test.ts | 98 +++++++++++++++++++ src/app/api/airdrop/status/route.test.ts | 75 ++++++++++++++ 5 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 src/app/api/airdrop/leaderboard/route.test.ts create mode 100644 src/app/api/airdrop/points/route.test.ts create mode 100644 src/app/api/airdrop/status/route.test.ts 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..ebd7ea9 --- /dev/null +++ b/src/app/api/airdrop/status/route.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; + +const mockPriceSingle = vi.fn(); +const mockActivationCount = vi.fn(); +const mockEligibleCount = 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") { + return { + select: (_cols: string, opts?: { count?: string; head?: boolean }) => { + if (opts?.count) { + return { not: () => ({ eq: mockEligibleCount, count: mockActivationCount.mockReturnValue({ count: 10 }) }) }; + } + return {}; + }, + }; + } + 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 } }); + mockActivationCount.mockResolvedValue({ count: 15 }); + mockEligibleCount.mockResolvedValue({ count: 12 }); + + const res = await GET(); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.milestones).toBeDefined(); + expect(data.milestones.bronze).toBeDefined(); + expect(data.milestones.diamond).toBeDefined(); + expect(data.poolAmount).toBe(200_000); + 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 }); + mockActivationCount.mockResolvedValue({ count: 0 }); + mockEligibleCount.mockResolvedValue({ count: 0 }); + + const res = await GET(); + const data = await res.json(); + expect(data.env_check.all_present).toBe(false); + }); +}); From 58a50bfca2b2b27f5eb1f75270ca76e1249d13fe Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 13:57:08 +0000 Subject: [PATCH 2/3] [#1300] Fix status test mock chain + assert activation counts Mock .not() returns thenable + .eq() for both count queries. Assert activation_count=15 and eligible_activation_count=12. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/airdrop/status/route.test.ts | 39 +++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/app/api/airdrop/status/route.test.ts b/src/app/api/airdrop/status/route.test.ts index ebd7ea9..c28c36c 100644 --- a/src/app/api/airdrop/status/route.test.ts +++ b/src/app/api/airdrop/status/route.test.ts @@ -1,22 +1,28 @@ // @vitest-environment node -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; const mockPriceSingle = vi.fn(); -const mockActivationCount = vi.fn(); -const mockEligibleCount = vi.fn(); +let activationCallIdx = 0; vi.mock("../../../../../lib/supabase", () => ({ createServerClient: () => ({ from: (table: string) => { - if (table === "pl_daily_prices") return { select: () => ({ order: () => ({ limit: () => ({ single: mockPriceSingle }) }) }) }; + if (table === "pl_daily_prices") { + return { select: () => ({ order: () => ({ limit: () => ({ single: mockPriceSingle }) }) }) }; + } if (table === "pl_activations") { + const idx = activationCallIdx++; + const allCount = 15; + const eligibleCount = 12; return { - select: (_cols: string, opts?: { count?: string; head?: boolean }) => { - if (opts?.count) { - return { not: () => ({ eq: mockEligibleCount, count: mockActivationCount.mockReturnValue({ count: 10 }) }) }; - } - return {}; - }, + select: () => ({ + not: () => { + const base = Promise.resolve({ count: allCount }); + return Object.assign(base, { + eq: () => Promise.resolve({ count: eligibleCount }), + }); + }, + }), }; } return {}; @@ -47,26 +53,25 @@ vi.mock("../../../../../lib/usd-price", () => ({ getPlotUsdPrice: () => Promise. import { GET } from "./route"; +beforeEach(() => { activationCallIdx = 0; }); + 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 } }); - mockActivationCount.mockResolvedValue({ count: 15 }); - mockEligibleCount.mockResolvedValue({ count: 12 }); const res = await GET(); expect(res.status).toBe(200); const data = await res.json(); - expect(data.milestones).toBeDefined(); - expect(data.milestones.bronze).toBeDefined(); - expect(data.milestones.diamond).toBeDefined(); + 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 }); - mockActivationCount.mockResolvedValue({ count: 0 }); - mockEligibleCount.mockResolvedValue({ count: 0 }); const res = await GET(); const data = await res.json(); From ea94ec39355d9c0372821d667350d9e830558fc7 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 13:58:31 +0000 Subject: [PATCH 3/3] [#1300] Remove unused activationCallIdx + beforeEach Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/airdrop/status/route.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app/api/airdrop/status/route.test.ts b/src/app/api/airdrop/status/route.test.ts index c28c36c..c1e68d5 100644 --- a/src/app/api/airdrop/status/route.test.ts +++ b/src/app/api/airdrop/status/route.test.ts @@ -1,9 +1,7 @@ // @vitest-environment node -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi } from "vitest"; const mockPriceSingle = vi.fn(); -let activationCallIdx = 0; - vi.mock("../../../../../lib/supabase", () => ({ createServerClient: () => ({ from: (table: string) => { @@ -11,7 +9,6 @@ vi.mock("../../../../../lib/supabase", () => ({ return { select: () => ({ order: () => ({ limit: () => ({ single: mockPriceSingle }) }) }) }; } if (table === "pl_activations") { - const idx = activationCallIdx++; const allCount = 15; const eligibleCount = 12; return { @@ -53,7 +50,6 @@ vi.mock("../../../../../lib/usd-price", () => ({ getPlotUsdPrice: () => Promise. import { GET } from "./route"; -beforeEach(() => { activationCallIdx = 0; }); describe("GET /api/airdrop/status", () => { it("returns v5 shape with milestones + activation counts + env_check", async () => {