Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/app/api/users/[username]/reviews/route.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
129 changes: 74 additions & 55 deletions src/app/api/users/[username]/reviews/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
Loading