diff --git a/src/app/api/users/[username]/reviews/route.test.ts b/src/app/api/users/[username]/reviews/route.test.ts new file mode 100644 index 00000000..e96a8dd3 --- /dev/null +++ b/src/app/api/users/[username]/reviews/route.test.ts @@ -0,0 +1,130 @@ +// @ts-nocheck - Supabase route mocks are intentionally minimal. +import { beforeEach, describe, expect, it, vi } 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)), +})); + +const routeParams = { params: Promise.resolve({ username: "testuser" }) }; + +function makeRequest(query = "") { + return new NextRequest(`http://localhost/api/users/testuser/reviews${query}`); +} + +function makeProfileChain(profile: { id: string } | null = { id: "user-1" }) { + const single = vi.fn().mockResolvedValue({ + data: profile, + error: null, + }); + const eq = vi.fn().mockReturnValue({ single }); + const select = vi.fn().mockReturnValue({ eq }); + + return { select, eq, single }; +} + +function makeReviewsChain() { + const range = vi.fn().mockResolvedValue({ + data: [{ id: "review-1", rating: 5 }], + error: null, + count: 1, + }); + const order = vi.fn().mockReturnValue({ range }); + const eq = vi.fn().mockReturnValue({ order }); + const select = vi.fn().mockReturnValue({ eq }); + + return { select, eq, order, range }; +} + +function makeRatingsChain(ratings = [{ rating: 5 }]) { + const eq = vi.fn().mockResolvedValue({ + data: ratings, + error: null, + }); + const select = vi.fn().mockReturnValue({ eq }); + + return { select, eq }; +} + +function mockReviewsRequest(profile = { id: "user-1" }) { + const profileChain = makeProfileChain(profile); + const ratingsChain = makeRatingsChain([ + { rating: 5 }, + { rating: 3 }, + ]); + const reviewsChain = makeReviewsChain(); + + let reviewsCallCount = 0; + mockFrom.mockImplementation((table: string) => { + if (table === "profiles") return profileChain; + + reviewsCallCount++; + return reviewsCallCount === 1 ? ratingsChain : reviewsChain; + }); + + return { profileChain, ratingsChain, reviewsChain }; +} + +describe("GET /api/users/[username]/reviews", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 404 when the profile is missing", async () => { + mockReviewsRequest(null); + + const res = await GET(makeRequest(), routeParams); + + expect(res.status).toBe(404); + }); + + it("clamps invalid pagination values before querying", async () => { + const { reviewsChain } = mockReviewsRequest(); + + const res = await GET(makeRequest("?limit=0&offset=-5"), routeParams); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(reviewsChain.range).toHaveBeenCalledWith(0, 9); + expect(body.pagination).toEqual({ + total: 1, + limit: 10, + offset: 0, + }); + expect(body.summary.average_rating).toBe(4); + }); + + it("caps large limits at 50", async () => { + const { reviewsChain } = mockReviewsRequest(); + + const res = await GET(makeRequest("?limit=500&offset=10"), routeParams); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(reviewsChain.range).toHaveBeenCalledWith(10, 59); + expect(body.pagination.limit).toBe(50); + expect(body.pagination.offset).toBe(10); + }); + + it("uses valid pagination values as provided", async () => { + const { reviewsChain } = mockReviewsRequest(); + + const res = await GET(makeRequest("?limit=12&offset=24"), routeParams); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(reviewsChain.range).toHaveBeenCalledWith(24, 35); + expect(body.pagination).toEqual({ + total: 1, + limit: 12, + offset: 24, + }); + }); +}); diff --git a/src/app/api/users/[username]/reviews/route.ts b/src/app/api/users/[username]/reviews/route.ts index 502670c9..a83435fd 100644 --- a/src/app/api/users/[username]/reviews/route.ts +++ b/src/app/api/users/[username]/reviews/route.ts @@ -1,80 +1,99 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; +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/users/[username]/reviews - Get reviews for a user export async function GET( request: NextRequest, { params }: { params: Promise<{ username: string }> } ) { - try { + try { const { username } = await params; const supabase = await createClient(); const { searchParams } = new URL(request.url); - const limit = Math.min(parseInt(searchParams.get("limit") || "10"), 50); - const offset = parseInt(searchParams.get("offset") || "0"); + const limit = Math.min(parsePositiveInt(searchParams.get("limit"), 10), 50); + const offset = parseNonNegativeInt(searchParams.get("offset"), 0); // Get user ID from username - const { data: profile } = await supabase - .from("profiles") - .select("id") - .eq("username", username) - .single(); - + const { data: profile } = await supabase + .from("profiles") + .select("id") + .eq("username", username) + .single(); + if (!profile) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } - // Fetch reviews where this user is the reviewee - const { data: reviews, error, count } = await supabase + const { data: allRatings, error: ratingsError } = await supabase .from("reviews") - .select( - ` - *, - reviewer:profiles!reviewer_id ( - id, - username, - full_name, - avatar_url - ), - gig:gigs ( - id, - title - ) - `, - { count: "exact" } - ) - .eq("reviewee_id", profile.id) - .order("created_at", { ascending: false }) - .range(offset, offset + limit - 1); + .select("rating") + .eq("reviewee_id", profile.id); - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }); + if (ratingsError) { + return NextResponse.json({ error: ratingsError.message }, { status: 400 }); } + // Fetch reviews where this user is the reviewee + const { data: reviews, error, count } = await supabase + .from("reviews") + .select( + ` + *, + reviewer:profiles!reviewer_id ( + id, + username, + full_name, + avatar_url + ), + gig:gigs ( + id, + title + ) + `, + { count: "exact" } + ) + .eq("reviewee_id", profile.id) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + // Calculate average rating from all reviews const totalReviews = count || 0; let averageRating = 0; - if (reviews && reviews.length > 0) { - const sumRatings = reviews.reduce((sum, r) => sum + r.rating, 0); - averageRating = totalReviews > 0 ? sumRatings / reviews.length : 0; + if (allRatings && allRatings.length > 0) { + const sumRatings = allRatings.reduce((sum, r) => sum + r.rating, 0); + averageRating = sumRatings / allRatings.length; } - - return NextResponse.json({ - data: reviews, - summary: { - average_rating: averageRating, - total_reviews: totalReviews, - }, - pagination: { - total: totalReviews, - limit, - offset, - }, - }); - } catch { - return NextResponse.json( - { error: "An unexpected error occurred" }, - { status: 500 } - ); - } -} + + return NextResponse.json({ + data: reviews, + summary: { + average_rating: averageRating, + total_reviews: totalReviews, + }, + pagination: { + total: totalReviews, + limit, + offset, + }, + }); + } catch { + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +}