From af0a0f2a301c87209a470563844030d958dd2eee Mon Sep 17 00:00:00 2001 From: Justice Date: Wed, 24 Jun 2026 18:41:55 +0100 Subject: [PATCH] feat: implement Review and Rating API - Add database schema migration for reviews with contract uniqueness constraints - Create POST /api/reviews endpoint to submit and validate freelancer reviews - Create GET /api/reviews/[userId] endpoint with pagination support - Author unit tests to verify constraint violations and paginated fetching logic --- __tests__/api/reviews.test.ts | 133 ++++++++++++++++++++++++++++++ app/api/reviews/[userId]/route.ts | 57 +++++++++++++ app/api/reviews/route.ts | 69 ++++++++++++++++ scripts/009-reviews-schema.sql | 19 +++++ 4 files changed, 278 insertions(+) create mode 100644 __tests__/api/reviews.test.ts create mode 100644 app/api/reviews/[userId]/route.ts create mode 100644 app/api/reviews/route.ts create mode 100644 scripts/009-reviews-schema.sql diff --git a/__tests__/api/reviews.test.ts b/__tests__/api/reviews.test.ts new file mode 100644 index 0000000..5924d88 --- /dev/null +++ b/__tests__/api/reviews.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST } from '@/app/api/reviews/route'; +import { GET } from '@/app/api/reviews/[userId]/route'; +import { sql } from '@/lib/db'; + +// Mock the db module +vi.mock('@/lib/db', () => { + return { + sql: vi.fn(), + }; +}); + +describe('Reviews API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('POST /api/reviews', () => { + it('returns 400 for invalid rating', async () => { + const req = new Request('http://localhost/api/reviews', { + method: 'POST', + body: JSON.stringify({ + contractId: 1, + reviewerId: 2, + freelancerId: 3, + rating: 6, // Invalid rating + }), + }); + + const response = await POST(req); + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe('Invalid input data'); + }); + + it('returns 404 if contract not found', async () => { + // Mock contract not found + (sql as any).mockResolvedValueOnce([]); + + const req = new Request('http://localhost/api/reviews', { + method: 'POST', + body: JSON.stringify({ + contractId: 99, + reviewerId: 2, + freelancerId: 3, + rating: 5, + }), + }); + + const response = await POST(req); + expect(response.status).toBe(404); + const data = await response.json(); + expect(data.error).toBe('Contract not found'); + }); + + it('successfully creates a verified review if contract is completed', async () => { + // Mock contract status query (completed) + (sql as any).mockResolvedValueOnce([{ status: 'completed', client_id: 2 }]); + // Mock insert query + (sql as any).mockResolvedValueOnce([{ id: 1, contract_id: 1, verified: true }]); + + const req = new Request('http://localhost/api/reviews', { + method: 'POST', + body: JSON.stringify({ + contractId: 1, + reviewerId: 2, + freelancerId: 3, + rating: 5, + comment: 'Great work!', + }), + }); + + const response = await POST(req); + expect(response.status).toBe(201); + const data = await response.json(); + expect(data.verified).toBe(true); + expect(sql).toHaveBeenCalledTimes(2); + }); + + it('returns 409 on unique constraint violation', async () => { + // Mock contract status query + (sql as any).mockResolvedValueOnce([{ status: 'completed', client_id: 2 }]); + // Mock insert query throwing unique violation + const error: any = new Error('Unique constraint'); + error.code = '23505'; + (sql as any).mockRejectedValueOnce(error); + + const req = new Request('http://localhost/api/reviews', { + method: 'POST', + body: JSON.stringify({ + contractId: 1, + reviewerId: 2, + freelancerId: 3, + rating: 5, + }), + }); + + const response = await POST(req); + expect(response.status).toBe(409); + const data = await response.json(); + expect(data.error).toBe('One review allowed per contract.'); + }); + }); + + describe('GET /api/reviews/[userId]', () => { + it('returns 400 for invalid userId', async () => { + const req = new Request('http://localhost/api/reviews/invalid'); + const response = await GET(req, { params: Promise.resolve({ userId: 'invalid' }) }); + expect(response.status).toBe(400); + }); + + it('returns paginated reviews', async () => { + // Mock fetching reviews + const mockRows = [ + { id: 2, contract_id: 2, rating: 4, total_count: '2' }, + { id: 1, contract_id: 1, rating: 5, total_count: '2' }, + ]; + (sql as any).mockResolvedValueOnce(mockRows); + + const req = new Request('http://localhost/api/reviews/3?page=1&limit=2'); + const response = await GET(req, { params: Promise.resolve({ userId: '3' }) }); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.data).toHaveLength(2); + expect(data.data[0]).not.toHaveProperty('total_count'); // ensures mapping worked + expect(data.meta.totalCount).toBe(2); + expect(data.meta.page).toBe(1); + expect(data.meta.totalPages).toBe(1); + expect(sql).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/api/reviews/[userId]/route.ts b/app/api/reviews/[userId]/route.ts new file mode 100644 index 0000000..a690376 --- /dev/null +++ b/app/api/reviews/[userId]/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/lib/db"; + +export async function GET( + req: Request, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const { userId: userIdStr } = await params; + const userId = parseInt(userIdStr, 10); + + if (isNaN(userId) || userId <= 0) { + return NextResponse.json( + { error: "Invalid userId" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10)); + const limit = Math.max(1, parseInt(searchParams.get("limit") || "10", 10)); + const offset = (page - 1) * limit; + + // Use a single query with COUNT(*) OVER() to fetch both data and total count efficiently + const reviews = (await sql` + SELECT + id, contract_id, reviewer_id, freelancer_id, rating, comment, verified, created_at, + COUNT(*) OVER() AS total_count + FROM reviews + WHERE freelancer_id = ${userId} + ORDER BY created_at DESC + LIMIT ${limit} OFFSET ${offset} + `) as any[]; + + const totalCount = reviews.length > 0 ? parseInt(reviews[0].total_count, 10) : 0; + + // Map over reviews to remove the total_count property from each row + const data = reviews.map(({ total_count, ...review }) => review); + + return NextResponse.json({ + data, + meta: { + totalCount, + page, + limit, + totalPages: Math.ceil(totalCount / limit) + } + }); + + } catch (error) { + console.error("Error fetching reviews:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/reviews/route.ts b/app/api/reviews/route.ts new file mode 100644 index 0000000..683284a --- /dev/null +++ b/app/api/reviews/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { sql } from "@/lib/db"; + +const reviewSchema = z.object({ + contractId: z.number().int().positive(), + reviewerId: z.number().int().positive(), + freelancerId: z.number().int().positive(), + rating: z.number().int().min(1).max(5), + comment: z.string().optional(), +}); + +export async function POST(req: Request) { + try { + const body = await req.json(); + + // Validate request body + const result = reviewSchema.safeParse(body); + if (!result.success) { + return NextResponse.json( + { error: "Invalid input data", details: result.error.errors }, + { status: 400 } + ); + } + + const { contractId, reviewerId, freelancerId, rating, comment } = result.data; + + // Check if contract exists and get its status + const contractResult = (await sql` + SELECT status, client_id FROM contracts WHERE id = ${contractId} + `) as any[]; + + if (contractResult.length === 0) { + return NextResponse.json( + { error: "Contract not found" }, + { status: 404 } + ); + } + + const contract = contractResult[0]; + + // Determine if the review is verified (e.g. linked to a completed contract) + const verified = contract.status === "completed"; + + // Insert the review + const insertResult = (await sql` + INSERT INTO reviews (contract_id, reviewer_id, freelancer_id, rating, comment, verified) + VALUES (${contractId}, ${reviewerId}, ${freelancerId}, ${rating}, ${comment || null}, ${verified}) + RETURNING * + `) as any[]; + + return NextResponse.json(insertResult[0], { status: 201 }); + + } catch (error: any) { + // Check for Postgres unique constraint violation + if (error.code === '23505') { + return NextResponse.json( + { error: "One review allowed per contract." }, + { status: 409 } + ); + } + + console.error("Error creating review:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/scripts/009-reviews-schema.sql b/scripts/009-reviews-schema.sql new file mode 100644 index 0000000..986f771 --- /dev/null +++ b/scripts/009-reviews-schema.sql @@ -0,0 +1,19 @@ +-- TaskChain Database Schema Update: Reviews API +-- This migration drops the preliminary reviews table and creates a structured one based on Issue #123. + +DROP TABLE IF EXISTS reviews CASCADE; + +CREATE TABLE reviews ( + id SERIAL PRIMARY KEY, + contract_id INTEGER NOT NULL REFERENCES contracts(id) ON DELETE CASCADE, + reviewer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + freelancer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + verified BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- Enforce uniqueness: Only one review allowed per contract + CONSTRAINT uq_reviews_contract UNIQUE (contract_id) +); + +CREATE INDEX idx_reviews_freelancer ON reviews(freelancer_id);