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
133 changes: 133 additions & 0 deletions __tests__/api/reviews.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
57 changes: 57 additions & 0 deletions app/api/reviews/[userId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
69 changes: 69 additions & 0 deletions app/api/reviews/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
19 changes: 19 additions & 0 deletions scripts/009-reviews-schema.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading