diff --git a/backend/src/controllers/ratings.ts b/backend/src/controllers/ratings.ts new file mode 100644 index 00000000..1fd4c30a --- /dev/null +++ b/backend/src/controllers/ratings.ts @@ -0,0 +1,164 @@ +import { PrismaClient } from '@prisma/client'; +import type { AuthenticatedRequest } from '../middleware/auth.ts'; +import type { Response } from 'express'; + +const prisma = new PrismaClient(); + +export const createRating = async (req: AuthenticatedRequest, res: Response) => { + const timestamp = new Date().toISOString(); + try { + if (!req.user) { + return res.status(401).json({ message: 'User not authenticated', timestamp, endpoint: '/api/ratings' }); + } + const stars = parseInt(req.body.stars, 10); + if (stars < 0 || stars > 5) { + return res.status(400).json({ message: 'Stars must be between 0 and 5', timestamp, endpoint: '/api/ratings' }); + } + const newRatingData = { + userId: req.user.id, + movieId: req.body.movieId, + stars, + comment: req.body.comment?.trim(), + tags: req.body.tags || [], + date: new Date(), + votes: 0, + }; + const newRating = await prisma.rating.create({ data: newRatingData }); + return res.status(201).json({ + message: 'Rating created successfully', + rating: newRating, + timestamp, + endpoint: '/api/ratings', + }); + } catch (error) { + console.error(`[${timestamp}] createRating error:`, error); + return res.status(500).json({ + message: 'Internal server error while creating rating', + error: error instanceof Error ? error.message : 'Unknown error', + timestamp, + endpoint: '/api/ratings', + }); + } +}; + +export const getRatings = async (req: AuthenticatedRequest, res: Response) => { + const timestamp = new Date().toISOString(); + try { + if (!req.user) { + return res.status(401).json({ message: 'User not authenticated', timestamp, endpoint: '/api/ratings' }); + } + const ratings = await prisma.rating.findMany({ + where: { userId: req.user.id }, + orderBy: { date: 'desc' }, + }); + return res.status(200).json({ + message: 'Ratings retrieved successfully', + ratings, + timestamp, + endpoint: '/api/ratings', + }); + } catch (error) { + console.error(`[${timestamp}] getRatings error:`, error); + return res.status(500).json({ + message: 'Internal server error while retrieving ratings', + error: error instanceof Error ? error.message : 'Unknown error', + timestamp, + endpoint: '/api/ratings', + }); + } +}; + +export const getRatingById = async (req: AuthenticatedRequest, res: Response) => { + const timestamp = new Date().toISOString(); + try { + if (!req.user) { + return res.status(401).json({ message: 'User not authenticated', timestamp, endpoint: '/api/ratings' }); + } + const rating = await prisma.rating.findUnique({ + where: { id: req.params.id }, + }); + if (!rating || rating.userId !== req.user.id) { + return res.status(404).json({ message: 'Rating not found', timestamp, endpoint: '/api/ratings' }); + } + return res.status(200).json({ + message: 'Rating retrieved successfully', + rating, + timestamp, + endpoint: `/api/ratings/${req.params.id}`, + }); + } catch (error) { + console.error(`[${timestamp}] getRatingById error:`, error); + return res.status(500).json({ + message: 'Internal server error while retrieving rating', + error: error instanceof Error ? error.message : 'Unknown error', + timestamp, + endpoint: `/api/ratings/${req.params.id}`, + }); + } +}; + +export const updateRating = async (req: AuthenticatedRequest, res: Response) => { + const timestamp = new Date().toISOString(); + try { + if (!req.user) { + return res.status(401).json({ message: 'User not authenticated', timestamp, endpoint: '/api/ratings' }); + } + const existing = await prisma.rating.findUnique({ where: { id: req.params.id } }); + if (!existing || existing.userId !== req.user.id) { + return res.status(404).json({ message: 'Rating not found', timestamp, endpoint: '/api/ratings' }); + } + const stars = parseInt(req.body.stars, 10); + if (isNaN(stars) || stars < 0 || stars > 5) { + return res.status(400).json({ message: 'Stars must be between 0 and 5', timestamp, endpoint: '/api/ratings' }); + } + const updated = await prisma.rating.update({ + where: { id: req.params.id }, + data: { + stars, + comment: req.body.comment?.trim(), + tags: req.body.tags || [], + }, + }); + return res.status(200).json({ + message: 'Rating updated successfully', + rating: updated, + timestamp, + endpoint: `/api/ratings/${req.params.id}`, + }); + } catch (error) { + console.error(`[${timestamp}] updateRating error:`, error); + return res.status(500).json({ + message: 'Internal server error while updating rating', + error: error instanceof Error ? error.message : 'Unknown error', + timestamp, + endpoint: `/api/ratings/${req.params.id}`, + }); + } +}; + +export const deleteRating = async (req: AuthenticatedRequest, res: Response) => { + const timestamp = new Date().toISOString(); + try { + if (!req.user) { + return res.status(401).json({ message: 'User not authenticated', timestamp, endpoint: '/api/ratings' }); + } + const existing = await prisma.rating.findUnique({ where: { id: req.params.id } }); + if (!existing || existing.userId !== req.user.id) { + return res.status(404).json({ message: 'Rating not found', timestamp, endpoint: '/api/ratings' }); + } + await prisma.rating.delete({ where: { id: req.params.id } }); + return res.status(200).json({ + message: 'Rating deleted', + timestamp, + endpoint: `/api/ratings/${req.params.id}`, + }); + } catch (error) { + console.error(`[${timestamp}] deleteRating error:`, error); + return res.status(500).json({ + message: 'Internal server error while deleting rating', + error: error instanceof Error ? error.message : 'Unknown error', + timestamp, + endpoint: `/api/ratings/${req.params.id}`, + }); + } +}; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 786d901f..c2d58a7b 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -11,6 +11,7 @@ import { authenticateUser } from '../middleware/auth'; import { protect } from "../controllers/protected"; import { getLocalEvent, createLocalEvent, updateLocalEvent, deleteLocalEvent } from "../controllers/local-events" import { followUser, unfollowUser, getFollowers, getFollowing } from "../controllers/userFollows"; +import { createRating, getRatings, getRatingById, deleteRating, updateRating } from "../controllers/ratings"; const router = Router(); @@ -45,6 +46,13 @@ router.get("/movies/cinecircle/:movieId", getMovieById); router.put("/movies/cinecircle/:movieId", updateMovie); router.delete("/movies/:movieId", deleteMovie); +// Ratings routes +router.post('/api/ratings', createRating); +router.get('/api/ratings', getRatings); +router.get('/api/ratings/:id', getRatingById); +router.put('/api/ratings/:id', updateRating); +router.delete('/api/ratings/:id', deleteRating); + // Local events routes router.get("/api/local-event/:id", getLocalEvent); router.post("/api/local-event", createLocalEvent); diff --git a/backend/src/tests/api/rating.api.test.ts b/backend/src/tests/api/rating.api.test.ts new file mode 100644 index 00000000..78ff4451 --- /dev/null +++ b/backend/src/tests/api/rating.api.test.ts @@ -0,0 +1,196 @@ +import request from "supertest"; +import express, { NextFunction } from "express"; +import { createApp } from "../../app"; +import { prisma } from "../../services/db"; +import jwt from "jsonwebtoken"; +import { HTTP_STATUS } from "../helpers/constants"; +import { AuthenticatedRequest } from "../../middleware/auth"; + +jest.mock('../../middleware/auth', () => ({ + authenticateUser: (req: AuthenticatedRequest, res: any, next: NextFunction) => { + // Check for Authorization header + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ message: 'User not authenticated' }); + } + + req.user = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'testuser@example.com', + role: 'USER', + }; + next(); + }, +})); + +describe("Ratings API Tests", () => { + let app: express.Express; + const TEST_USER_ID = "123e4567-e89b-12d3-a456-426614174000"; + const TEST_MOVIE_ID = "movie-1"; + + const generateToken = () => { + return jwt.sign( + { id: TEST_USER_ID, email: "testuser@example.com", role: "USER" }, + process.env.JWT_SECRET || "test-secret", + { expiresIn: "1h" } + ); + }; + + const authHeader = () => ({ + Authorization: `Bearer ${generateToken()}`, + }); + + beforeAll(async () => { + app = createApp(); + await prisma.rating.deleteMany({ where: { userId: TEST_USER_ID } }); + }); + + afterAll(async () => { + await prisma.rating.deleteMany({ where: { userId: TEST_USER_ID } }); + await prisma.$disconnect(); + }); + + describe("POST /api/ratings", () => { + it("should create a rating for authenticated user", async () => { + const payload = { + movieId: TEST_MOVIE_ID, + stars: 5, + comment: "Great movie!", + tags: ["action", "thriller"], + votes: 0, + }; + + const res = await request(app) + .post("/api/ratings") + .send(payload) + .set(authHeader()) + .expect(HTTP_STATUS.CREATED) + .expect("Content-Type", /json/); + + expect(res.body).toHaveProperty("message", "Rating created successfully"); + expect(res.body.rating).toMatchObject({ + userId: TEST_USER_ID, + movieId: TEST_MOVIE_ID, + stars: 5, + comment: "Great movie!", + tags: ["action", "thriller"], + votes: 0, + }); + }); + + it("should return 401 if user is not authenticated", async () => { + const payload = { + movieId: TEST_MOVIE_ID, + stars: 4, + comment: "Nice!", + votes: 0, + }; + + const res = await request(app) + .post("/api/ratings") + .send(payload) + .expect(HTTP_STATUS.UNAUTHORIZED); + + expect(res.body).toHaveProperty("message", "User not authenticated"); + }); + + it("should return 400 for invalid stars value", async () => { + const payload = { + movieId: TEST_MOVIE_ID, + stars: 10, + comment: "Invalid stars", + votes: 0, + }; + + const res = await request(app) + .post("/api/ratings") + .send(payload) + .set(authHeader()) + .expect(HTTP_STATUS.BAD_REQUEST); + + expect(res.body).toHaveProperty("message", "Stars must be between 0 and 5"); + }); + }); + + describe("GET /api/ratings", () => { + beforeAll(async () => { + await prisma.rating.createMany({ + data: [ + { + id: 'rating-1', + userId: TEST_USER_ID, + movieId: "movie-2", + stars: 4, + date: new Date(), + votes: 0, + }, + { + id: 'rating-2', + userId: TEST_USER_ID, + movieId: "movie-3", + stars: 3, + date: new Date(), + votes: 0, + }, + ], + }); + }); + + afterAll(async () => { + await prisma.rating.deleteMany({ where: { userId: TEST_USER_ID } }); + }); + + it("should return ratings for authenticated user", async () => { + const res = await request(app) + .get("/api/ratings") + .set(authHeader()) + .expect(HTTP_STATUS.OK); + + expect(res.body).toHaveProperty("message"); + expect(res.body.ratings).toBeInstanceOf(Array); + expect(res.body.ratings.length).toBeGreaterThan(0); + res.body.ratings.forEach((rating: any) => { + expect(rating.userId).toBe(TEST_USER_ID); + }); + }); + }); + + describe("DELETE /api/ratings/:id", () => { + let ratingId: string; + + beforeAll(async () => { + const rating = await prisma.rating.create({ + data: { + userId: TEST_USER_ID, + movieId: "movie-4", + stars: 2, + comment: "Not my favorite", + date: new Date(), + votes: 0, + }, + }); + ratingId = rating.id; + }); + + it("should delete a rating for authenticated user", async () => { + const res = await request(app) + .delete(`/api/ratings/${ratingId}`) + .set(authHeader()) + .expect(HTTP_STATUS.OK); + + expect(res.body).toHaveProperty("message", "Rating deleted"); + + const deleted = await prisma.rating.findUnique({ where: { id: ratingId } }); + expect(deleted).toBeNull(); + }); + + it("should return 404 for non-existent rating", async () => { + const res = await request(app) + .delete(`/api/ratings/non-existent-id`) + .set(authHeader()) + .expect(HTTP_STATUS.NOT_FOUND); + + expect(res.body).toHaveProperty("message", "Rating not found"); + }); + }); +}); diff --git a/backend/src/tests/api/users.api.test.ts b/backend/src/tests/api/users.api.test.ts index 4ff60dbc..46cafd36 100644 --- a/backend/src/tests/api/users.api.test.ts +++ b/backend/src/tests/api/users.api.test.ts @@ -7,7 +7,13 @@ import { HTTP_STATUS } from "../helpers/constants"; import { AuthenticatedRequest } from "../../middleware/auth"; jest.mock('../../middleware/auth', () => ({ - authenticateUser: (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + authenticateUser: (req: AuthenticatedRequest, res: any, next: NextFunction) => { + // Check for Authorization header + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ message: 'User not authenticated' }); + } + req.user = { id: '123e4567-e89b-12d3-a456-426614174000', email: 'testuser@example.com',