diff --git a/src/app/api/users/[username]/followers/route.test.ts b/src/app/api/users/[username]/followers/route.test.ts new file mode 100644 index 00000000..0b42b4f3 --- /dev/null +++ b/src/app/api/users/[username]/followers/route.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "./route"; + +const mockFrom = vi.fn(); + +const supabaseClient = { + from: mockFrom, +}; + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(() => Promise.resolve(supabaseClient)), +})); + +function makeRequest(searchParams?: Record) { + let url = "http://localhost/api/users/testuser/followers"; + if (searchParams) { + url += `?${new URLSearchParams(searchParams).toString()}`; + } + return new NextRequest(url, { method: "GET" }); +} + +const routeParams = { params: Promise.resolve({ username: "testuser" }) }; + +function profileChain(result: { data: unknown; error: unknown }) { + const chain: Record> = {}; + for (const method of ["select", "eq", "single"]) { + chain[method] = vi.fn().mockReturnValue(chain); + } + chain.single.mockResolvedValue(result); + return chain; +} + +function followsChain(result: { data: unknown[]; error: unknown; count: number }) { + const chain: Record> = {}; + for (const method of ["select", "eq", "order", "range"]) { + chain[method] = vi.fn().mockReturnValue(chain); + } + chain.range.mockResolvedValue(result); + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/users/[username]/followers", () => { + it("returns 404 when user is not found", async () => { + mockFrom.mockReturnValue(profileChain({ data: null, error: { message: "not found" } })); + + const res = await GET(makeRequest(), routeParams); + + expect(res.status).toBe(404); + }); + + it("falls back for invalid pagination params", async () => { + const targetProfile = profileChain({ data: { id: "user-123" }, error: null }); + const follows = followsChain({ data: [], error: null, count: 0 }); + + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + return callCount === 1 ? targetProfile : follows; + }); + + const res = await GET( + makeRequest({ limit: "0", offset: "-10" }), + routeParams + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(follows.range).toHaveBeenCalledWith(0, 19); + expect(json.pagination.limit).toBe(20); + expect(json.pagination.offset).toBe(0); + }); + + it("caps large limits", async () => { + const targetProfile = profileChain({ data: { id: "user-123" }, error: null }); + const follows = followsChain({ data: [], error: null, count: 0 }); + + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + return callCount === 1 ? targetProfile : follows; + }); + + await GET(makeRequest({ limit: "500", offset: "5" }), routeParams); + + expect(follows.range).toHaveBeenCalledWith(5, 104); + }); + + it("keeps valid pagination params", async () => { + const targetProfile = profileChain({ data: { id: "user-123" }, error: null }); + const follows = followsChain({ data: [], error: null, count: 0 }); + + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + return callCount === 1 ? targetProfile : follows; + }); + + await GET(makeRequest({ limit: "10", offset: "30" }), routeParams); + + expect(follows.range).toHaveBeenCalledWith(30, 39); + }); +}); diff --git a/src/app/api/users/[username]/followers/route.ts b/src/app/api/users/[username]/followers/route.ts index ba6c35aa..b9a4adfc 100644 --- a/src/app/api/users/[username]/followers/route.ts +++ b/src/app/api/users/[username]/followers/route.ts @@ -1,76 +1,92 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; - -// GET /api/users/[username]/followers — list a user's followers -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ username: string }> } -) { - try { - const { username } = await params; - const supabase = await createClient(); - const searchParams = request.nextUrl.searchParams; - const limit = Math.min(Number(searchParams.get("limit")) || 20, 100); - const offset = Number(searchParams.get("offset")) || 0; - - // Look up target user - const { data: targetProfile, error: profileError } = await supabase - .from("profiles") - .select("id") - .eq("username", username) - .single(); - - if (profileError || !targetProfile) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - // Get followers with profile details - const { data: follows, error, count } = await supabase - .from("follows") - .select( - ` - id, - created_at, - follower:profiles!follower_id ( - id, - username, - full_name, - avatar_url, - bio, - is_available, - account_type, - verified, - verification_type - ) - `, - { count: "exact" } - ) - .eq("following_id", targetProfile.id) - .order("created_at", { ascending: false }) - .range(offset, offset + limit - 1); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - // Flatten: extract the profile from each follow record - const followers = (follows || []).map((f) => ({ - ...f.follower, - followed_at: f.created_at, - })); - - return NextResponse.json({ - data: followers, - pagination: { - total: count || 0, - limit, - offset, - }, - }); - } catch { - return NextResponse.json( - { error: "An unexpected error occurred" }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +function parsePositiveInt(value: string | null, fallback: number, max: number): number { + const parsed = Number.parseInt(value ?? "", 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.min(parsed, max); +} + +function parseNonNegativeInt(value: string | null, fallback: number): number { + const parsed = Number.parseInt(value ?? "", 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallback; + } + return parsed; +} + +// GET /api/users/[username]/followers - list a user's followers +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ username: string }> } +) { + try { + const { username } = await params; + const supabase = await createClient(); + const searchParams = request.nextUrl.searchParams; + const limit = parsePositiveInt(searchParams.get("limit"), 20, 100); + const offset = parseNonNegativeInt(searchParams.get("offset"), 0); + + // Look up target user + const { data: targetProfile, error: profileError } = await supabase + .from("profiles") + .select("id") + .eq("username", username) + .single(); + + if (profileError || !targetProfile) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Get followers with profile details + const { data: follows, error, count } = await supabase + .from("follows") + .select( + ` + id, + created_at, + follower:profiles!follower_id ( + id, + username, + full_name, + avatar_url, + bio, + is_available, + account_type, + verified, + verification_type + ) + `, + { count: "exact" } + ) + .eq("following_id", targetProfile.id) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + // Flatten: extract the profile from each follow record + const followers = (follows || []).map((f) => ({ + ...f.follower, + followed_at: f.created_at, + })); + + return NextResponse.json({ + data: followers, + pagination: { + total: count || 0, + limit, + offset, + }, + }); + } catch { + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/users/[username]/following/route.test.ts b/src/app/api/users/[username]/following/route.test.ts new file mode 100644 index 00000000..ecab1833 --- /dev/null +++ b/src/app/api/users/[username]/following/route.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "./route"; + +const mockFrom = vi.fn(); + +const supabaseClient = { + from: mockFrom, +}; + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(() => Promise.resolve(supabaseClient)), +})); + +function makeRequest(searchParams?: Record) { + let url = "http://localhost/api/users/testuser/following"; + if (searchParams) { + url += `?${new URLSearchParams(searchParams).toString()}`; + } + return new NextRequest(url, { method: "GET" }); +} + +const routeParams = { params: Promise.resolve({ username: "testuser" }) }; + +function profileChain(result: { data: unknown; error: unknown }) { + const chain: Record> = {}; + for (const method of ["select", "eq", "single"]) { + chain[method] = vi.fn().mockReturnValue(chain); + } + chain.single.mockResolvedValue(result); + return chain; +} + +function followsChain(result: { data: unknown[]; error: unknown; count: number }) { + const chain: Record> = {}; + for (const method of ["select", "eq", "order", "range"]) { + chain[method] = vi.fn().mockReturnValue(chain); + } + chain.range.mockResolvedValue(result); + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/users/[username]/following", () => { + it("returns 404 when user is not found", async () => { + mockFrom.mockReturnValue(profileChain({ data: null, error: { message: "not found" } })); + + const res = await GET(makeRequest(), routeParams); + + expect(res.status).toBe(404); + }); + + it("falls back for invalid pagination params", async () => { + const targetProfile = profileChain({ data: { id: "user-123" }, error: null }); + const follows = followsChain({ data: [], error: null, count: 0 }); + + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + return callCount === 1 ? targetProfile : follows; + }); + + const res = await GET( + makeRequest({ limit: "-5", offset: "-10" }), + routeParams + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(follows.range).toHaveBeenCalledWith(0, 19); + expect(json.pagination.limit).toBe(20); + expect(json.pagination.offset).toBe(0); + }); + + it("caps large limits", async () => { + const targetProfile = profileChain({ data: { id: "user-123" }, error: null }); + const follows = followsChain({ data: [], error: null, count: 0 }); + + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + return callCount === 1 ? targetProfile : follows; + }); + + await GET(makeRequest({ limit: "500", offset: "5" }), routeParams); + + expect(follows.range).toHaveBeenCalledWith(5, 104); + }); + + it("keeps valid pagination params", async () => { + const targetProfile = profileChain({ data: { id: "user-123" }, error: null }); + const follows = followsChain({ data: [], error: null, count: 0 }); + + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + return callCount === 1 ? targetProfile : follows; + }); + + await GET(makeRequest({ limit: "10", offset: "30" }), routeParams); + + expect(follows.range).toHaveBeenCalledWith(30, 39); + }); +}); diff --git a/src/app/api/users/[username]/following/route.ts b/src/app/api/users/[username]/following/route.ts index fa5fdaf4..855d4ef7 100644 --- a/src/app/api/users/[username]/following/route.ts +++ b/src/app/api/users/[username]/following/route.ts @@ -1,76 +1,92 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; - -// GET /api/users/[username]/following — list who a user follows -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ username: string }> } -) { - try { - const { username } = await params; - const supabase = await createClient(); - const searchParams = request.nextUrl.searchParams; - const limit = Math.min(Number(searchParams.get("limit")) || 20, 100); - const offset = Number(searchParams.get("offset")) || 0; - - // Look up target user - const { data: targetProfile, error: profileError } = await supabase - .from("profiles") - .select("id") - .eq("username", username) - .single(); - - if (profileError || !targetProfile) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - // Get following with profile details - const { data: follows, error, count } = await supabase - .from("follows") - .select( - ` - id, - created_at, - following:profiles!following_id ( - id, - username, - full_name, - avatar_url, - bio, - is_available, - account_type, - verified, - verification_type - ) - `, - { count: "exact" } - ) - .eq("follower_id", targetProfile.id) - .order("created_at", { ascending: false }) - .range(offset, offset + limit - 1); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - // Flatten: extract the profile from each follow record - const following = (follows || []).map((f) => ({ - ...f.following, - followed_at: f.created_at, - })); - - return NextResponse.json({ - data: following, - pagination: { - total: count || 0, - limit, - offset, - }, - }); - } catch { - return NextResponse.json( - { error: "An unexpected error occurred" }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +function parsePositiveInt(value: string | null, fallback: number, max: number): number { + const parsed = Number.parseInt(value ?? "", 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.min(parsed, max); +} + +function parseNonNegativeInt(value: string | null, fallback: number): number { + const parsed = Number.parseInt(value ?? "", 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallback; + } + return parsed; +} + +// GET /api/users/[username]/following - list who a user follows +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ username: string }> } +) { + try { + const { username } = await params; + const supabase = await createClient(); + const searchParams = request.nextUrl.searchParams; + const limit = parsePositiveInt(searchParams.get("limit"), 20, 100); + const offset = parseNonNegativeInt(searchParams.get("offset"), 0); + + // Look up target user + const { data: targetProfile, error: profileError } = await supabase + .from("profiles") + .select("id") + .eq("username", username) + .single(); + + if (profileError || !targetProfile) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Get following with profile details + const { data: follows, error, count } = await supabase + .from("follows") + .select( + ` + id, + created_at, + following:profiles!following_id ( + id, + username, + full_name, + avatar_url, + bio, + is_available, + account_type, + verified, + verification_type + ) + `, + { count: "exact" } + ) + .eq("follower_id", targetProfile.id) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + // Flatten: extract the profile from each follow record + const following = (follows || []).map((f) => ({ + ...f.following, + followed_at: f.created_at, + })); + + return NextResponse.json({ + data: following, + pagination: { + total: count || 0, + limit, + offset, + }, + }); + } catch { + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +}