From e0766756cf81723e1d72a75f26cfa607d4255ee8 Mon Sep 17 00:00:00 2001 From: sevencat2004 <187867736+sevencat2004@users.noreply.github.com> Date: Fri, 29 May 2026 12:04:27 +0800 Subject: [PATCH 1/4] fix(zaps): clamp history pagination params --- src/app/api/zaps/history/route.test.ts | 107 +++++++++++++++++++++++++ src/app/api/zaps/history/route.ts | 16 +++- 2 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 src/app/api/zaps/history/route.test.ts diff --git a/src/app/api/zaps/history/route.test.ts b/src/app/api/zaps/history/route.test.ts new file mode 100644 index 00000000..eea964df --- /dev/null +++ b/src/app/api/zaps/history/route.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockGetAuthContext = vi.fn(); +const mockCreateServiceClient = vi.fn(); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args), +})); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: () => mockCreateServiceClient(), +})); + +import { GET } from "./route"; + +function makeRequest(query = "") { + return { url: `http://localhost/api/zaps/history${query}` } as any; +} + +function makeAdmin(zaps: any[] | null = [{ id: "zap-1", sender_id: "sender-1", recipient_id: "user-1" }]) { + const range = vi.fn().mockResolvedValue({ + data: zaps, + count: zaps?.length ?? 0, + }); + const order = vi.fn().mockReturnValue({ range }); + const eq = vi.fn().mockReturnValue({ order }); + const selectZaps = vi.fn().mockReturnValue({ eq }); + + const inProfiles = vi.fn().mockResolvedValue({ + data: [ + { + id: "sender-1", + username: "sender", + full_name: "Sender User", + avatar_url: "https://example.com/avatar.png", + }, + ], + }); + const selectProfiles = vi.fn().mockReturnValue({ in: inProfiles }); + + const from = vi.fn((table: string) => { + if (table === "profiles") return { select: selectProfiles }; + return { select: selectZaps }; + }); + + return { admin: { from }, range, inProfiles, from }; +} + +describe("GET /api/zaps/history", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("requires authentication", async () => { + mockGetAuthContext.mockResolvedValue(null); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(401); + }); + + it("clamps invalid pagination values before querying", async () => { + const { admin, range } = makeAdmin(); + mockCreateServiceClient.mockReturnValue(admin); + mockGetAuthContext.mockResolvedValue({ user: { id: "user-1" } }); + + const res = await GET(makeRequest("?limit=0&offset=-5")); + + expect(res.status).toBe(200); + expect(range).toHaveBeenCalledWith(0, 49); + }); + + it("caps large limits at 100", async () => { + const { admin, range } = makeAdmin(); + mockCreateServiceClient.mockReturnValue(admin); + mockGetAuthContext.mockResolvedValue({ user: { id: "user-1" } }); + + const res = await GET(makeRequest("?limit=500&offset=10")); + + expect(res.status).toBe(200); + expect(range).toHaveBeenCalledWith(10, 109); + }); + + it("uses valid pagination values as provided", async () => { + const { admin, range } = makeAdmin(); + mockCreateServiceClient.mockReturnValue(admin); + mockGetAuthContext.mockResolvedValue({ user: { id: "user-1" } }); + + const res = await GET(makeRequest("?limit=12&offset=24")); + + expect(res.status).toBe(200); + expect(range).toHaveBeenCalledWith(24, 35); + }); + + it("does not query profiles when there are no zaps", async () => { + const { admin, inProfiles } = makeAdmin([]); + mockCreateServiceClient.mockReturnValue(admin); + mockGetAuthContext.mockResolvedValue({ user: { id: "user-1" } }); + + const res = await GET(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ zaps: [], total: 0 }); + expect(inProfiles).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/zaps/history/route.ts b/src/app/api/zaps/history/route.ts index 8cde2888..f71fe56e 100644 --- a/src/app/api/zaps/history/route.ts +++ b/src/app/api/zaps/history/route.ts @@ -2,6 +2,16 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; import { createServiceClient } from "@/lib/supabase/service"; +function parsePositiveInt(value: string | null, fallback: number): number { + const parsed = Number.parseInt(value || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseNonNegativeInt(value: string | null, fallback: number): number { + const parsed = Number.parseInt(value || "", 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + /** * GET /api/zaps/history?direction=sent|received&limit=50&offset=0 */ @@ -14,8 +24,8 @@ export async function GET(request: NextRequest) { const url = new URL(request.url); const direction = url.searchParams.get("direction") || "received"; - const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 100); - const offset = parseInt(url.searchParams.get("offset") || "0"); + const limit = Math.min(parsePositiveInt(url.searchParams.get("limit"), 50), 100); + const offset = parseNonNegativeInt(url.searchParams.get("offset"), 0); const admin = createServiceClient(); const userId = auth.user.id; @@ -30,7 +40,7 @@ export async function GET(request: NextRequest) { .order("created_at", { ascending: false }) .range(offset, offset + limit - 1) as any; - if (!zaps) { + if (!zaps || zaps.length === 0) { return NextResponse.json({ zaps: [], total: 0 }); } From c6faed0df8dfec8bf190bde120a50848eafb4cae Mon Sep 17 00:00:00 2001 From: sevencat2004 <11336110@qq.com> Date: Fri, 29 May 2026 12:13:57 +0800 Subject: [PATCH 2/4] test(zaps): cover empty history page totals --- src/app/api/zaps/history/route.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/api/zaps/history/route.test.ts b/src/app/api/zaps/history/route.test.ts index eea964df..8c6e6233 100644 --- a/src/app/api/zaps/history/route.test.ts +++ b/src/app/api/zaps/history/route.test.ts @@ -17,10 +17,13 @@ function makeRequest(query = "") { return { url: `http://localhost/api/zaps/history${query}` } as any; } -function makeAdmin(zaps: any[] | null = [{ id: "zap-1", sender_id: "sender-1", recipient_id: "user-1" }]) { +function makeAdmin( + zaps: any[] | null = [{ id: "zap-1", sender_id: "sender-1", recipient_id: "user-1" }], + count = zaps?.length ?? 0 +) { const range = vi.fn().mockResolvedValue({ data: zaps, - count: zaps?.length ?? 0, + count, }); const order = vi.fn().mockReturnValue({ range }); const eq = vi.fn().mockReturnValue({ order }); @@ -93,7 +96,7 @@ describe("GET /api/zaps/history", () => { }); it("does not query profiles when there are no zaps", async () => { - const { admin, inProfiles } = makeAdmin([]); + const { admin, inProfiles } = makeAdmin([], 3); mockCreateServiceClient.mockReturnValue(admin); mockGetAuthContext.mockResolvedValue({ user: { id: "user-1" } }); @@ -101,7 +104,7 @@ describe("GET /api/zaps/history", () => { const body = await res.json(); expect(res.status).toBe(200); - expect(body).toEqual({ zaps: [], total: 0 }); + expect(body).toEqual({ zaps: [], total: 3 }); expect(inProfiles).not.toHaveBeenCalled(); }); }); From 0a60762c45885b1c4b8cfa331418c04da2b12b99 Mon Sep 17 00:00:00 2001 From: sevencat2004 <11336110@qq.com> Date: Fri, 29 May 2026 12:14:44 +0800 Subject: [PATCH 3/4] fix(zaps): preserve empty-page history total --- src/app/api/zaps/history/route.ts | 116 +++++++++++++++--------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/src/app/api/zaps/history/route.ts b/src/app/api/zaps/history/route.ts index f71fe56e..53da0c81 100644 --- a/src/app/api/zaps/history/route.ts +++ b/src/app/api/zaps/history/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; import { createServiceClient } from "@/lib/supabase/service"; @@ -16,67 +16,67 @@ function parseNonNegativeInt(value: string | null, fallback: number): number { * GET /api/zaps/history?direction=sent|received&limit=50&offset=0 */ export async function GET(request: NextRequest) { - try { - const auth = await getAuthContext(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + try { + const auth = await getAuthContext(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } const url = new URL(request.url); const direction = url.searchParams.get("direction") || "received"; const limit = Math.min(parsePositiveInt(url.searchParams.get("limit"), 50), 100); const offset = parseNonNegativeInt(url.searchParams.get("offset"), 0); - - const admin = createServiceClient(); - const userId = auth.user.id; - - const column = direction === "sent" ? "sender_id" : "recipient_id"; - const otherColumn = direction === "sent" ? "recipient_id" : "sender_id"; - - const { data: zaps, count } = await admin - .from("zaps" as any) - .select("id, sender_id, recipient_id, amount_sats, fee_sats, target_type, target_id, note, created_at", { count: "exact" }) - .eq(column, userId) - .order("created_at", { ascending: false }) - .range(offset, offset + limit - 1) as any; - + + const admin = createServiceClient(); + const userId = auth.user.id; + + const column = direction === "sent" ? "sender_id" : "recipient_id"; + const otherColumn = direction === "sent" ? "recipient_id" : "sender_id"; + + const { data: zaps, count } = await admin + .from("zaps" as any) + .select("id, sender_id, recipient_id, amount_sats, fee_sats, target_type, target_id, note, created_at", { count: "exact" }) + .eq(column, userId) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1) as any; + if (!zaps || zaps.length === 0) { - return NextResponse.json({ zaps: [], total: 0 }); + return NextResponse.json({ zaps: [], total: count || 0 }); } - - // Fetch profiles for the other party - const otherIds = [...new Set(zaps.map((z: any) => z[otherColumn]))] as string[]; - const { data: profiles } = await admin - .from("profiles") - .select("id, username, full_name, avatar_url") - .in("id", otherIds) as any; - - const profileMap = new Map((profiles || []).map((p: any) => [p.id, p])) as Map; - - // Enrich zaps with profile info and target context - const enriched = zaps.map((z: any) => { - const otherId = z[otherColumn]; - const profile = profileMap.get(otherId) as any; - return { - id: z.id, - amount_sats: z.amount_sats, - fee_sats: z.fee_sats, - target_type: z.target_type, - target_id: z.target_id, - note: z.note, - created_at: z.created_at, - user: profile ? { - id: profile.id, - username: profile.username, - name: profile.full_name, - avatar_url: profile.avatar_url, - } : { id: otherId, username: null, name: "Unknown", avatar_url: null }, - }; - }); - - return NextResponse.json({ zaps: enriched, total: count || 0 }); - } catch (err) { - console.error("Zap history error:", err); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } -} + + // Fetch profiles for the other party + const otherIds = [...new Set(zaps.map((z: any) => z[otherColumn]))] as string[]; + const { data: profiles } = await admin + .from("profiles") + .select("id, username, full_name, avatar_url") + .in("id", otherIds) as any; + + const profileMap = new Map((profiles || []).map((p: any) => [p.id, p])) as Map; + + // Enrich zaps with profile info and target context + const enriched = zaps.map((z: any) => { + const otherId = z[otherColumn]; + const profile = profileMap.get(otherId) as any; + return { + id: z.id, + amount_sats: z.amount_sats, + fee_sats: z.fee_sats, + target_type: z.target_type, + target_id: z.target_id, + note: z.note, + created_at: z.created_at, + user: profile ? { + id: profile.id, + username: profile.username, + name: profile.full_name, + avatar_url: profile.avatar_url, + } : { id: otherId, username: null, name: "Unknown", avatar_url: null }, + }; + }); + + return NextResponse.json({ zaps: enriched, total: count || 0 }); + } catch (err) { + console.error("Zap history error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} From 66232a4a13d06d510e909616f8dd3337b20284ce Mon Sep 17 00:00:00 2001 From: sevencat2004 <11336110@qq.com> Date: Fri, 29 May 2026 12:23:33 +0800 Subject: [PATCH 4/4] chore(zaps): normalize history route diff --- src/app/api/zaps/history/route.ts | 114 +++++++++++++++--------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/src/app/api/zaps/history/route.ts b/src/app/api/zaps/history/route.ts index 53da0c81..cc5ba233 100644 --- a/src/app/api/zaps/history/route.ts +++ b/src/app/api/zaps/history/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; import { createServiceClient } from "@/lib/supabase/service"; @@ -16,67 +16,67 @@ function parseNonNegativeInt(value: string | null, fallback: number): number { * GET /api/zaps/history?direction=sent|received&limit=50&offset=0 */ export async function GET(request: NextRequest) { - try { - const auth = await getAuthContext(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + try { + const auth = await getAuthContext(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } const url = new URL(request.url); const direction = url.searchParams.get("direction") || "received"; const limit = Math.min(parsePositiveInt(url.searchParams.get("limit"), 50), 100); const offset = parseNonNegativeInt(url.searchParams.get("offset"), 0); - - const admin = createServiceClient(); - const userId = auth.user.id; - - const column = direction === "sent" ? "sender_id" : "recipient_id"; - const otherColumn = direction === "sent" ? "recipient_id" : "sender_id"; - - const { data: zaps, count } = await admin - .from("zaps" as any) - .select("id, sender_id, recipient_id, amount_sats, fee_sats, target_type, target_id, note, created_at", { count: "exact" }) - .eq(column, userId) - .order("created_at", { ascending: false }) - .range(offset, offset + limit - 1) as any; - + + const admin = createServiceClient(); + const userId = auth.user.id; + + const column = direction === "sent" ? "sender_id" : "recipient_id"; + const otherColumn = direction === "sent" ? "recipient_id" : "sender_id"; + + const { data: zaps, count } = await admin + .from("zaps" as any) + .select("id, sender_id, recipient_id, amount_sats, fee_sats, target_type, target_id, note, created_at", { count: "exact" }) + .eq(column, userId) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1) as any; + if (!zaps || zaps.length === 0) { return NextResponse.json({ zaps: [], total: count || 0 }); } - - // Fetch profiles for the other party - const otherIds = [...new Set(zaps.map((z: any) => z[otherColumn]))] as string[]; - const { data: profiles } = await admin - .from("profiles") - .select("id, username, full_name, avatar_url") - .in("id", otherIds) as any; - - const profileMap = new Map((profiles || []).map((p: any) => [p.id, p])) as Map; - - // Enrich zaps with profile info and target context - const enriched = zaps.map((z: any) => { - const otherId = z[otherColumn]; - const profile = profileMap.get(otherId) as any; - return { - id: z.id, - amount_sats: z.amount_sats, - fee_sats: z.fee_sats, - target_type: z.target_type, - target_id: z.target_id, - note: z.note, - created_at: z.created_at, - user: profile ? { - id: profile.id, - username: profile.username, - name: profile.full_name, - avatar_url: profile.avatar_url, - } : { id: otherId, username: null, name: "Unknown", avatar_url: null }, - }; - }); - - return NextResponse.json({ zaps: enriched, total: count || 0 }); - } catch (err) { - console.error("Zap history error:", err); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } -} + + // Fetch profiles for the other party + const otherIds = [...new Set(zaps.map((z: any) => z[otherColumn]))] as string[]; + const { data: profiles } = await admin + .from("profiles") + .select("id, username, full_name, avatar_url") + .in("id", otherIds) as any; + + const profileMap = new Map((profiles || []).map((p: any) => [p.id, p])) as Map; + + // Enrich zaps with profile info and target context + const enriched = zaps.map((z: any) => { + const otherId = z[otherColumn]; + const profile = profileMap.get(otherId) as any; + return { + id: z.id, + amount_sats: z.amount_sats, + fee_sats: z.fee_sats, + target_type: z.target_type, + target_id: z.target_id, + note: z.note, + created_at: z.created_at, + user: profile ? { + id: profile.id, + username: profile.username, + name: profile.full_name, + avatar_url: profile.avatar_url, + } : { id: otherId, username: null, name: "Unknown", avatar_url: null }, + }; + }); + + return NextResponse.json({ zaps: enriched, total: count || 0 }); + } catch (err) { + console.error("Zap history error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +}