From 976f827c335eb0a4329735f5490f950529bf9de2 Mon Sep 17 00:00:00 2001 From: ReinaMaze Date: Mon, 1 Jun 2026 13:13:54 +0100 Subject: [PATCH] fix: add review validation and authorization --- .../__tests__/services/review.service.test.ts | 405 ++++++++++++++++++ dongle/app/projects/[id]/page.tsx | 20 +- dongle/app/reviews/page.tsx | 51 ++- dongle/components/reviews/ReviewForm.tsx | 56 ++- dongle/package-lock.json | 98 ----- dongle/services/review/review.service.ts | 138 +++++- dongle/types/review.ts | 13 + 7 files changed, 648 insertions(+), 133 deletions(-) create mode 100644 dongle/__tests__/services/review.service.test.ts diff --git a/dongle/__tests__/services/review.service.test.ts b/dongle/__tests__/services/review.service.test.ts new file mode 100644 index 0000000..17d66e5 --- /dev/null +++ b/dongle/__tests__/services/review.service.test.ts @@ -0,0 +1,405 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { reviewService } from "@/services/review/review.service"; +import { Review, REVIEW_CONSTRAINTS } from "@/types/review"; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, "localStorage", { + value: localStorageMock, +}); + +describe("Review Service", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe("addReview", () => { + it("should add a valid review", () => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 5, + comment: "This is a great project with excellent features", + }; + + const result = reviewService.addReview(review, "user1"); + + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data?.id).toBeDefined(); + expect(result.data?.createdAt).toBeDefined(); + expect(result.data?.rating).toBe(5); + expect(result.data?.comment).toBe(review.comment); + }); + + it("should reject review with invalid rating (too low)", () => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 0, + comment: "This is a great project with excellent features", + }; + + const result = reviewService.addReview(review, "user1"); + + expect(result.success).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].field).toBe("rating"); + expect(result.errors?.[0].message).toContain("between"); + }); + + it("should reject review with invalid rating (too high)", () => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 6, + comment: "This is a great project with excellent features", + }; + + const result = reviewService.addReview(review, "user1"); + + expect(result.success).toBe(false); + expect(result.errors?.[0].field).toBe("rating"); + }); + + it("should reject review with non-integer rating", () => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 3.5, + comment: "This is a great project with excellent features", + }; + + const result = reviewService.addReview(review, "user1"); + + expect(result.success).toBe(false); + expect(result.errors?.[0].field).toBe("rating"); + }); + + it("should reject review with comment too short", () => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 5, + comment: "Too short", + }; + + const result = reviewService.addReview(review, "user1"); + + expect(result.success).toBe(false); + expect(result.errors?.[0].field).toBe("comment"); + expect(result.errors?.[0].message).toContain("at least"); + }); + + it("should reject review with comment too long", () => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 5, + comment: "a".repeat(REVIEW_CONSTRAINTS.COMMENT_MAX_LENGTH + 1), + }; + + const result = reviewService.addReview(review, "user1"); + + expect(result.success).toBe(false); + expect(result.errors?.[0].field).toBe("comment"); + expect(result.errors?.[0].message).toContain("cannot exceed"); + }); + + it("should reject duplicate review from same user for same project", () => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 5, + comment: "This is a great project with excellent features", + }; + + const result1 = reviewService.addReview(review, "user1"); + expect(result1.success).toBe(true); + + const result2 = reviewService.addReview(review, "user1"); + expect(result2.success).toBe(false); + expect(result2.errors?.[0].message).toContain("already reviewed"); + }); + + it("should allow different users to review same project", () => { + const review = { + projectId: "proj1", + projectName: "Test Project", + rating: 5, + comment: "This is a great project with excellent features", + }; + + const result1 = reviewService.addReview( + { ...review, userAddress: "user1" }, + "user1" + ); + expect(result1.success).toBe(true); + + const result2 = reviewService.addReview( + { ...review, userAddress: "user2" }, + "user2" + ); + expect(result2.success).toBe(true); + }); + + it("should allow same user to review different projects", () => { + const review = { + userAddress: "user1", + rating: 5, + comment: "This is a great project with excellent features", + }; + + const result1 = reviewService.addReview( + { ...review, projectId: "proj1", projectName: "Project 1" }, + "user1" + ); + expect(result1.success).toBe(true); + + const result2 = reviewService.addReview( + { ...review, projectId: "proj2", projectName: "Project 2" }, + "user1" + ); + expect(result2.success).toBe(true); + }); + }); + + describe("updateReview", () => { + let reviewId: string; + + beforeEach(() => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 5, + comment: "This is a great project with excellent features", + }; + const result = reviewService.addReview(review, "user1"); + reviewId = result.data?.id || ""; + }); + + it("should update review by owner", () => { + const result = reviewService.updateReview( + reviewId, + { rating: 4, comment: "Updated comment with more details here" }, + "user1" + ); + + expect(result.success).toBe(true); + expect(result.data?.rating).toBe(4); + expect(result.data?.comment).toBe("Updated comment with more details here"); + }); + + it("should reject update by non-owner", () => { + const result = reviewService.updateReview( + reviewId, + { rating: 4, comment: "Updated comment with more details here" }, + "user2" + ); + + expect(result.success).toBe(false); + expect(result.errors?.[0].message).toContain("permission"); + }); + + it("should reject update with invalid rating", () => { + const result = reviewService.updateReview( + reviewId, + { rating: 10, comment: "Updated comment with more details here" }, + "user1" + ); + + expect(result.success).toBe(false); + expect(result.errors?.[0].field).toBe("rating"); + }); + + it("should reject update with invalid comment", () => { + const result = reviewService.updateReview( + reviewId, + { rating: 4, comment: "short" }, + "user1" + ); + + expect(result.success).toBe(false); + expect(result.errors?.[0].field).toBe("comment"); + }); + + it("should reject update of non-existent review", () => { + const result = reviewService.updateReview( + "nonexistent", + { rating: 4, comment: "Updated comment with more details here" }, + "user1" + ); + + expect(result.success).toBe(false); + expect(result.errors?.[0].message).toContain("not found"); + }); + + it("should allow partial updates", () => { + const result = reviewService.updateReview( + reviewId, + { rating: 3 }, + "user1" + ); + + expect(result.success).toBe(true); + expect(result.data?.rating).toBe(3); + expect(result.data?.comment).toBe( + "This is a great project with excellent features" + ); + }); + }); + + describe("deleteReview", () => { + let reviewId: string; + + beforeEach(() => { + const review = { + projectId: "proj1", + projectName: "Test Project", + userAddress: "user1", + rating: 5, + comment: "This is a great project with excellent features", + }; + const result = reviewService.addReview(review, "user1"); + reviewId = result.data?.id || ""; + }); + + it("should delete review by owner", () => { + const result = reviewService.deleteReview(reviewId, "user1"); + + expect(result.success).toBe(true); + const reviews = reviewService.getReviews(); + expect(reviews.find((r) => r.id === reviewId)).toBeUndefined(); + }); + + it("should reject delete by non-owner", () => { + const result = reviewService.deleteReview(reviewId, "user2"); + + expect(result.success).toBe(false); + expect(result.error).toContain("permission"); + const reviews = reviewService.getReviews(); + expect(reviews.find((r) => r.id === reviewId)).toBeDefined(); + }); + + it("should reject delete of non-existent review", () => { + const result = reviewService.deleteReview("nonexistent", "user1"); + + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + }); + }); + + describe("getReviewsByProject", () => { + beforeEach(() => { + const reviews = [ + { + projectId: "proj1", + projectName: "Project 1", + userAddress: "user1", + rating: 5, + comment: "This is a great project with excellent features", + }, + { + projectId: "proj1", + projectName: "Project 1", + userAddress: "user2", + rating: 4, + comment: "Good project with some minor issues here", + }, + { + projectId: "proj2", + projectName: "Project 2", + userAddress: "user1", + rating: 3, + comment: "Average project with decent functionality", + }, + ]; + + reviews.forEach((review, index) => { + reviewService.addReview(review, review.userAddress); + }); + }); + + it("should return all reviews for a project", () => { + const reviews = reviewService.getReviewsByProject("proj1"); + expect(reviews).toHaveLength(2); + expect(reviews.every((r) => r.projectId === "proj1")).toBe(true); + }); + + it("should return empty array for project with no reviews", () => { + const reviews = reviewService.getReviewsByProject("nonexistent"); + expect(reviews).toHaveLength(0); + }); + }); + + describe("getReviewsByUser", () => { + beforeEach(() => { + const reviews = [ + { + projectId: "proj1", + projectName: "Project 1", + userAddress: "user1", + rating: 5, + comment: "This is a great project with excellent features", + }, + { + projectId: "proj2", + projectName: "Project 2", + userAddress: "user1", + rating: 4, + comment: "Good project with some minor issues here", + }, + { + projectId: "proj1", + projectName: "Project 1", + userAddress: "user2", + rating: 3, + comment: "Average project with decent functionality", + }, + ]; + + reviews.forEach((review) => { + reviewService.addReview(review, review.userAddress); + }); + }); + + it("should return all reviews by a user", () => { + const reviews = reviewService.getReviewsByUser("user1"); + expect(reviews).toHaveLength(2); + expect(reviews.every((r) => r.userAddress === "user1")).toBe(true); + }); + + it("should return empty array for user with no reviews", () => { + const reviews = reviewService.getReviewsByUser("nonexistent"); + expect(reviews).toHaveLength(0); + }); + }); +}); diff --git a/dongle/app/projects/[id]/page.tsx b/dongle/app/projects/[id]/page.tsx index 6bd1a8b..5831004 100644 --- a/dongle/app/projects/[id]/page.tsx +++ b/dongle/app/projects/[id]/page.tsx @@ -67,8 +67,9 @@ export default function ProjectDetailPage() { }; const handleDelete = (id: string) => { + if (!publicKey) return; if (confirm("Are you sure you want to delete this review?")) { - reviewService.deleteReview(id); + reviewService.deleteReview(id, publicKey); setReviews(reviewService.getReviewsByProject(projectId)); } }; @@ -77,14 +78,17 @@ export default function ProjectDetailPage() { if (!publicKey || !project) return; if (editingReview) { - reviewService.updateReview(editingReview.id, data); + reviewService.updateReview(editingReview.id, data, publicKey); } else { - reviewService.addReview({ - projectId: project.id, - projectName: project.name, - userAddress: publicKey, - ...data, - }); + reviewService.addReview( + { + projectId: project.id, + projectName: project.name, + userAddress: publicKey, + ...data, + }, + publicKey + ); } setReviews(reviewService.getReviewsByProject(projectId)); diff --git a/dongle/app/reviews/page.tsx b/dongle/app/reviews/page.tsx index d8fe05e..669211e 100644 --- a/dongle/app/reviews/page.tsx +++ b/dongle/app/reviews/page.tsx @@ -7,6 +7,7 @@ import { Review, Project } from "@/types/review"; import ReviewList from "@/components/reviews/ReviewList"; import ReviewForm from "@/components/reviews/ReviewForm"; import { mockProjects } from "@/data/mockProjects"; +import { toast } from "sonner"; export default function ReviewsPage() { const { isConnected, publicKey, connectWallet } = useWallet(); @@ -41,9 +42,15 @@ export default function ReviewsPage() { }; const handleDeleteReview = (id: string) => { + if (!publicKey) return; if (confirm("Are you sure you want to delete this review?")) { - reviewService.deleteReview(id); - setReviews(reviewService.getReviews()); + const result = reviewService.deleteReview(id, publicKey); + if (result.success) { + setReviews(reviewService.getReviews()); + toast.success("Review deleted"); + } else { + toast.error(result.error || "Failed to delete review"); + } } }; @@ -51,20 +58,36 @@ export default function ReviewsPage() { if (!publicKey || !selectedProject) return; if (editingReview) { - reviewService.updateReview(editingReview.id, data); + const result = reviewService.updateReview(editingReview.id, data, publicKey); + if (result.success) { + setReviews(reviewService.getReviews()); + setIsAddingReview(false); + setEditingReview(null); + setSelectedProject(null); + toast.success("Review updated"); + } else { + toast.error(result.errors?.[0]?.message || "Failed to update review"); + } } else { - reviewService.addReview({ - projectId: selectedProject.id, - projectName: selectedProject.name, - userAddress: publicKey, - ...data, - }); + const result = reviewService.addReview( + { + projectId: selectedProject.id, + projectName: selectedProject.name, + userAddress: publicKey, + ...data, + }, + publicKey + ); + if (result.success) { + setReviews(reviewService.getReviews()); + setIsAddingReview(false); + setEditingReview(null); + setSelectedProject(null); + toast.success("Review posted"); + } else { + toast.error(result.errors?.[0]?.message || "Failed to post review"); + } } - - setReviews(reviewService.getReviews()); - setIsAddingReview(false); - setEditingReview(null); - setSelectedProject(null); }; return ( diff --git a/dongle/components/reviews/ReviewForm.tsx b/dongle/components/reviews/ReviewForm.tsx index 7117dfa..a6aae3a 100644 --- a/dongle/components/reviews/ReviewForm.tsx +++ b/dongle/components/reviews/ReviewForm.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Review } from "@/types/review"; +import { Review, REVIEW_CONSTRAINTS, ReviewValidationError } from "@/types/review"; interface ReviewFormProps { projectId: string; @@ -20,10 +20,41 @@ export default function ReviewForm({ }: ReviewFormProps) { const [rating, setRating] = useState(initialReview?.rating || 5); const [comment, setComment] = useState(initialReview?.comment || ""); + const [errors, setErrors] = useState([]); + + const validateForm = (): boolean => { + const newErrors: ReviewValidationError[] = []; + + if (rating < REVIEW_CONSTRAINTS.RATING_MIN || rating > REVIEW_CONSTRAINTS.RATING_MAX) { + newErrors.push({ + field: "rating", + message: `Rating must be between ${REVIEW_CONSTRAINTS.RATING_MIN} and ${REVIEW_CONSTRAINTS.RATING_MAX}`, + }); + } + + if (comment.trim().length < REVIEW_CONSTRAINTS.COMMENT_MIN_LENGTH) { + newErrors.push({ + field: "comment", + message: `Comment must be at least ${REVIEW_CONSTRAINTS.COMMENT_MIN_LENGTH} characters`, + }); + } + + if (comment.length > REVIEW_CONSTRAINTS.COMMENT_MAX_LENGTH) { + newErrors.push({ + field: "comment", + message: `Comment cannot exceed ${REVIEW_CONSTRAINTS.COMMENT_MAX_LENGTH} characters`, + }); + } + + setErrors(newErrors); + return newErrors.length === 0; + }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - onSubmit({ rating, comment }); + if (validateForm()) { + onSubmit({ rating, comment }); + } }; return ( @@ -65,10 +96,17 @@ export default function ReviewForm({ ))} + {errors.some((e) => e.field === "rating") && ( +

+ {errors.find((e) => e.field === "rating")?.message} +

+ )}
- +