diff --git a/components/homepage/homeLoggedIn.tsx b/components/homepage/homeLoggedIn.tsx index 227237a7f..138906fe5 100644 --- a/components/homepage/homeLoggedIn.tsx +++ b/components/homepage/homeLoggedIn.tsx @@ -18,8 +18,8 @@ import Card from '../cards/card'; import ChapterSelectCard from '../cards/chapterSelectCard'; import LevelSelect from '../cards/levelSelect'; import LoadingCard from '../cards/loadingCard'; -import FormattedReview from '../formatted/formattedReview'; import FormattedUser from '../formatted/formattedUser'; +import FormattedReview from '../level/reviews/formattedReview'; import LoadingSpinner from '../page/loadingSpinner'; import MultiSelectUser from '../page/multiSelectUser'; import RecommendedLevel from './recommendedLevel'; diff --git a/components/level/info/formattedLevelInfo.tsx b/components/level/info/formattedLevelInfo.tsx index 58871cb38..88820766e 100644 --- a/components/level/info/formattedLevelInfo.tsx +++ b/components/level/info/formattedLevelInfo.tsx @@ -1,7 +1,7 @@ import { Tab } from '@headlessui/react'; import FormattedDate from '@root/components/formatted/formattedDate'; -import FormattedLevelReviews from '@root/components/formatted/formattedLevelReviews'; import Solved from '@root/components/level/info/solved'; +import FormattedLevelReviews from '@root/components/level/reviews/formattedLevelReviews'; import Image from 'next/image'; import React, { useContext, useState } from 'react'; import toast from 'react-hot-toast'; diff --git a/components/formatted/formattedLevelReviews.tsx b/components/level/reviews/formattedLevelReviews.tsx similarity index 73% rename from components/formatted/formattedLevelReviews.tsx rename to components/level/reviews/formattedLevelReviews.tsx index 67a2a0872..01a85f781 100644 --- a/components/formatted/formattedLevelReviews.tsx +++ b/components/level/reviews/formattedLevelReviews.tsx @@ -1,8 +1,7 @@ import React, { useContext, useEffect, useState } from 'react'; -import { AppContext } from '../../contexts/appContext'; -import { LevelContext } from '../../contexts/levelContext'; -import ReviewForm from '../forms/reviewForm'; -import FormattedReview from './formattedReview'; +import { AppContext } from '../../../contexts/appContext'; +import { LevelContext } from '../../../contexts/levelContext'; +import ReviewForm from './reviewForm'; interface FormattedLevelReviewsProps { hideReviews?: boolean; @@ -34,20 +33,7 @@ export default function FormattedLevelReviews({ inModal, hideReviews: hideOtherR if (hideOtherReviews) { continue; } reviewDivs.push( -
- -
-
+ ); } } @@ -60,7 +46,7 @@ export default function FormattedLevelReviews({ inModal, hideReviews: hideOtherR <>{levelContext.reviews.length} review{levelContext.reviews.length !== 1 && 's'} }
)} - + {hideReviews === undefined ? null : hideReviews ?
diff --git a/components/level/reviews/reviewDropdown.tsx b/components/level/reviews/reviewDropdown.tsx index f2a90dbc4..08c684960 100644 --- a/components/level/reviews/reviewDropdown.tsx +++ b/components/level/reviews/reviewDropdown.tsx @@ -1,4 +1,5 @@ import { Menu, Transition } from '@headlessui/react'; +import { AppContext } from '@root/contexts/appContext'; import { LevelContext } from '@root/contexts/levelContext'; import { PageContext } from '@root/contexts/pageContext'; import React, { Fragment, useContext, useEffect, useState } from 'react'; @@ -6,21 +7,23 @@ import DeleteReviewModal from '../../modal/deleteReviewModal'; interface ReviewDropdownProps { onEditClick: () => void; + userId: string; } -export default function ReviewDropdown({ onEditClick }: ReviewDropdownProps) { +export default function ReviewDropdown({ onEditClick, userId }: ReviewDropdownProps) { const [isDeleteReviewOpen, setIsDeleteReviewOpen] = useState(false); const levelContext = useContext(LevelContext); const { setPreventKeyDownEvent } = useContext(PageContext); + const { user } = useContext(AppContext); useEffect(() => { setPreventKeyDownEvent(isDeleteReviewOpen); }, [isDeleteReviewOpen, setPreventKeyDownEvent]); return (<> - + - + @@ -33,11 +36,10 @@ export default function ReviewDropdown({ onEditClick }: ReviewDropdownProps) { leaveFrom='transform opacity-100 scale-100' leaveTo='transform opacity-0 scale-95' > -
@@ -82,6 +84,7 @@ export default function ReviewDropdown({ onEditClick }: ReviewDropdownProps) { levelContext?.getReviews(); }} isOpen={isDeleteReviewOpen} + userId={userId} /> ); } diff --git a/components/forms/reviewForm.tsx b/components/level/reviews/reviewForm.tsx similarity index 66% rename from components/forms/reviewForm.tsx rename to components/level/reviews/reviewForm.tsx index 61c4f482a..88ba64ecf 100644 --- a/components/forms/reviewForm.tsx +++ b/components/level/reviews/reviewForm.tsx @@ -1,40 +1,30 @@ import classNames from 'classnames'; -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useState } from 'react'; import toast from 'react-hot-toast'; import { Rating } from 'react-simple-star-rating'; import TextareaAutosize from 'react-textarea-autosize'; -import Theme from '../../constants/theme'; -import { AppContext } from '../../contexts/appContext'; -import { LevelContext } from '../../contexts/levelContext'; -import { PageContext } from '../../contexts/pageContext'; -import Review from '../../models/db/review'; -import FormattedReview, { Star } from '../formatted/formattedReview'; -import DeleteReviewModal from '../modal/deleteReviewModal'; -import ProfileAvatar from '../profile/profileAvatar'; -import isNotFullAccountToast from '../toasts/isNotFullAccountToast'; +import Theme from '../../../constants/theme'; +import { AppContext } from '../../../contexts/appContext'; +import { LevelContext } from '../../../contexts/levelContext'; +import { PageContext } from '../../../contexts/pageContext'; +import { ReviewWithStats } from '../../../models/db/review'; +import ProfileAvatar from '../../profile/profileAvatar'; +import isNotFullAccountToast from '../../toasts/isNotFullAccountToast'; +import FormattedReview, { Star } from './formattedReview'; interface ReviewFormProps { inModal?: boolean; - userReview?: Review; + review?: ReviewWithStats; } -export default function ReviewForm({ inModal, userReview }: ReviewFormProps) { - const [isDeleteReviewOpen, setIsDeleteReviewOpen] = useState(false); +export default function ReviewForm({ inModal, review }: ReviewFormProps) { + const [isEditing, setIsEditing] = useState(!review); const [isUpdating, setIsUpdating] = useState(false); const levelContext = useContext(LevelContext); - const [rating, setRating] = useState(userReview?.score || 0); - const [reviewBody, setReviewBody] = useState(userReview?.text || ''); + const [rating, setRating] = useState(review?.score || 0); + const [reviewBody, setReviewBody] = useState(review?.text || ''); const { setPreventKeyDownEvent } = useContext(PageContext); - const [showUserReview, setShowUserReview] = useState(!!userReview); - const { theme, user } = useContext(AppContext); - - // only prevent keydown when the delete modal is the first modal open - // (not opened from within the review modal) - useEffect(() => { - if (!inModal) { - setPreventKeyDownEvent(isDeleteReviewOpen); - } - }, [inModal, isDeleteReviewOpen, setPreventKeyDownEvent]); + const { theme, user: reqUser } = useContext(AppContext); function onUpdateReview() { setIsUpdating(true); @@ -43,14 +33,15 @@ export default function ReviewForm({ inModal, userReview }: ReviewFormProps) { toast.loading('Saving...'); fetch('/api/review/' + levelContext?.level._id, { - method: userReview ? 'PUT' : 'POST', + method: review ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ score: rating, text: reviewBody, - }) + userId: review?.userId._id.toString(), + }), }).then(res => { if (res.status === 401) { isNotFullAccountToast('Reviewing'); @@ -61,7 +52,7 @@ export default function ReviewForm({ inModal, userReview }: ReviewFormProps) { toast.success('Saved'); levelContext?.getReviews(); - setShowUserReview(true); + setIsEditing(false); } }).catch(async err => { console.error(err); @@ -72,19 +63,21 @@ export default function ReviewForm({ inModal, userReview }: ReviewFormProps) { }); } + const user = review?.userId ?? reqUser; + if (!user) { return null; } - if (showUserReview && userReview) { + if (!isEditing && review) { return ( <> setShowUserReview(false)} - review={userReview} - user={userReview.userId} + onEditClick={() => setIsEditing(true)} + review={review} + user={user} />
- { - setIsDeleteReviewOpen(false); - levelContext?.getReviews(); - }} - isOpen={isDeleteReviewOpen} - /> ); } @@ -108,7 +94,7 @@ export default function ReviewForm({ inModal, userReview }: ReviewFormProps) {
-

{`${userReview ? 'Edit' : 'Add a'} review`}

+

{`${review ? 'Edit' : 'Add a'} review`}

{ // restore the pre-edit user review if available, otherwise reset - setShowUserReview(!!userReview); - setRating(userReview?.score || 0); - setReviewBody(userReview?.text || ''); + setIsEditing(!review); + setRating(review?.score || 0); + setReviewBody(review?.text || ''); }}> Cancel diff --git a/components/modal/deleteReviewModal.tsx b/components/modal/deleteReviewModal.tsx index 0d7c4973d..8cc6e3c41 100644 --- a/components/modal/deleteReviewModal.tsx +++ b/components/modal/deleteReviewModal.tsx @@ -6,16 +6,17 @@ import Modal from '.'; interface DeleteReviewModalProps { closeModal: () => void; isOpen: boolean; + userId: string; } -export default function DeleteReviewModal({ closeModal, isOpen }: DeleteReviewModalProps) { +export default function DeleteReviewModal({ closeModal, isOpen, userId }: DeleteReviewModalProps) { const levelContext = useContext(LevelContext); function onConfirm() { toast.dismiss(); toast.loading('Deleting review...'); - fetch(`/api/review/${levelContext?.level._id}`, { + fetch(`/api/review/${levelContext?.level._id}?userId=${userId}`, { method: 'DELETE', credentials: 'include', }).then(res => { @@ -26,10 +27,10 @@ export default function DeleteReviewModal({ closeModal, isOpen }: DeleteReviewMo } else { throw res.text(); } - }).catch(err => { + }).catch(async err => { console.error(err); toast.dismiss(); - toast.error('Error deleting review'); + toast.error(JSON.parse(await err)?.error || 'Error deleting review'); }); } diff --git a/components/modal/postGameModal.tsx b/components/modal/postGameModal.tsx index 6d3eaf27b..3973b4a7a 100644 --- a/components/modal/postGameModal.tsx +++ b/components/modal/postGameModal.tsx @@ -5,39 +5,11 @@ import Level, { EnrichedLevel } from '@root/models/db/level'; import User from '@root/models/db/user'; import Link from 'next/link'; import React, { useEffect, useState } from 'react'; -import { - EmailIcon, - EmailShareButton, - FacebookIcon, - FacebookMessengerIcon, - FacebookShareButton, - HatenaIcon, - InstapaperIcon, - LineIcon, - LinkedinIcon, - LivejournalIcon, - MailruIcon, - OKIcon, - PinterestIcon, - PocketIcon, - RedditIcon, - RedditShareButton, - TelegramIcon, - TelegramShareButton, - TumblrIcon, - TwitterIcon, - TwitterShareButton, - ViberIcon, - VKIcon, - WeiboIcon, - WhatsappIcon, - WorkplaceIcon -} from 'react-share'; import Card from '../cards/card'; import ChapterSelectCard from '../cards/chapterSelectCard'; import { getDifficultyFromValue } from '../formatted/formattedDifficulty'; -import FormattedLevelReviews from '../formatted/formattedLevelReviews'; import RecommendedLevel from '../homepage/recommendedLevel'; +import FormattedLevelReviews from '../level/reviews/formattedLevelReviews'; import LoadingSpinner from '../page/loadingSpinner'; import ShareBar from '../social/shareBar'; import Modal from '.'; @@ -105,7 +77,6 @@ export default function PostGameModal({ chapter, closeModal, collection, isOpen, const hrefOverride = nextLevel ? `/level/${nextLevel.slug}?${queryParams}` : undefined; const url = `https://pathology.gg/level/${level.slug}`; - const quote = 'Just completed Pathology.gg puzzle "' + level.name + '" (Difficulty: ' + getDifficultyFromValue(level.calc_difficulty_estimate).name + ')'; return ( diff --git a/components/notification/formattedNotification.tsx b/components/notification/formattedNotification.tsx index b9ff071f0..387ea9d50 100644 --- a/components/notification/formattedNotification.tsx +++ b/components/notification/formattedNotification.tsx @@ -15,8 +15,8 @@ import User from '../../models/db/user'; import FormattedCollectionLink from '../formatted/formattedCollectedLink'; import FormattedDate from '../formatted/formattedDate'; import FormattedLevelLink from '../formatted/formattedLevelLink'; -import { Stars } from '../formatted/formattedReview'; import FormattedUser from '../formatted/formattedUser'; +import { Stars } from '../level/reviews/formattedReview'; interface NotificationMessageProps { notification: Notification; diff --git a/components/social/shareBar.tsx b/components/social/shareBar.tsx index 85783a8f9..11d557da2 100644 --- a/components/social/shareBar.tsx +++ b/components/social/shareBar.tsx @@ -19,7 +19,7 @@ interface ShareBarProps { } const ShareBar: FC = ({ platforms = ['Generic', 'Copy', 'Facebook', 'Linkedin', 'Twitter', 'Reddit'], url, quote, size = 32 }) => { - const copyToClipboard = (e: any) => { + const copyToClipboard = (e: React.MouseEvent) => { navigator.clipboard.writeText(url); toast.success('Copied to clipboard!'); e.preventDefault(); diff --git a/pages/api/internal-jobs/worker/index.ts b/pages/api/internal-jobs/worker/index.ts index d3e660ac0..d735ff4c7 100644 --- a/pages/api/internal-jobs/worker/index.ts +++ b/pages/api/internal-jobs/worker/index.ts @@ -61,7 +61,7 @@ export async function queuePushNotification(notificationId: Types.ObjectId, opti ]); } -export async function queueRefreshAchievements(userId: Types.ObjectId, categories: AchievementCategory[], options?: QueryOptions) { +export async function queueRefreshAchievements(userId: string | Types.ObjectId, categories: AchievementCategory[], options?: QueryOptions) { await queue( userId.toString() + '-refresh-achievements-' + new Types.ObjectId().toString(), QueueMessageType.REFRESH_ACHIEVEMENTS, diff --git a/pages/api/review/[id].ts b/pages/api/review/[id].ts index 31ff0dccb..0ce545b72 100644 --- a/pages/api/review/[id].ts +++ b/pages/api/review/[id].ts @@ -1,4 +1,5 @@ import { AchievementCategory } from '@root/constants/achievements/achievementInfo'; +import isCurator from '@root/helpers/isCurator'; import isFullAccount from '@root/helpers/isFullAccount'; import { Types } from 'mongoose'; import type { NextApiResponse } from 'next'; @@ -9,7 +10,6 @@ import queueDiscordWebhook from '../../../helpers/discordWebhook'; import { TimerUtil } from '../../../helpers/getTs'; import { logger } from '../../../helpers/logger'; import { clearNotifications, createNewReviewOnYourLevelNotification } from '../../../helpers/notificationHelper'; -import dbConnect from '../../../lib/dbConnect'; import withAuth, { NextApiRequestWithAuth } from '../../../lib/withAuth'; import Level from '../../../models/db/level'; import Review from '../../../models/db/review'; @@ -62,6 +62,7 @@ export default withAuth({ body: { score: ValidNumber(true, 0, 5, 0.5), text: ValidType('string', false), + userId: ValidObjectId(true), }, query: { id: ValidObjectId(), @@ -69,7 +70,8 @@ export default withAuth({ }, DELETE: { query: { - id: ValidObjectId(), + id: ValidObjectId(true), + userId: ValidObjectId(true), }, }, }, async (req: NextApiRequestWithAuth, res: NextApiResponse) => { @@ -83,7 +85,6 @@ export default withAuth({ try { const { id } = req.query; const { score, text }: { score: number, text?: string } = req.body; - const trimmedText = text?.trim(); if (score === 0 && (!trimmedText || trimmedText.length === 0)) { @@ -92,8 +93,6 @@ export default withAuth({ }); } - await dbConnect(); - const levels = await LevelModel.aggregate([ { $match: { @@ -197,7 +196,7 @@ export default withAuth({ } const level = levels[0]; - const { score, text } = req.body; + const { score, text, userId } = req.body; const trimmedText = text?.trim(); if (score === 0 && (!trimmedText || trimmedText.length === 0)) { @@ -206,6 +205,12 @@ export default withAuth({ }); } + if (!isCurator(req.user) && userId !== req.userId) { + return res.status(403).json({ + error: 'Not authorized to edit this review', + }); + } + const ts = TimerUtil.getTs(); // NB: setting text to undefined isn't enough to delete it from the db; @@ -227,8 +232,8 @@ export default withAuth({ try { const review = await ReviewModel.findOneAndUpdate({ - levelId: id, - userId: req.userId, + levelId: new Types.ObjectId(id as string), + userId: new Types.ObjectId(userId), }, update, { runValidators: true }); if (!review) { @@ -237,13 +242,18 @@ export default withAuth({ }); } - await Promise.all([ - queueRefreshAchievements(req.user._id, [AchievementCategory.REVIEWER]), + const promises = [ + queueRefreshAchievements(userId, [AchievementCategory.REVIEWER]), queueRefreshAchievements(level.userId._id, [AchievementCategory.REVIEWER]), - generateDiscordWebhook(review.ts, level, req, score, trimmedText, ts), queueRefreshIndexCalcs(new Types.ObjectId(id?.toString())), - createNewReviewOnYourLevelNotification(level.userId, req.userId, level._id, String(score), !!trimmedText), - ]); + createNewReviewOnYourLevelNotification(level.userId, userId, level._id, String(score), !!trimmedText), + ]; + + if (userId === req.userId) { + promises.push(generateDiscordWebhook(review.ts, level, req, score, trimmedText, ts)); + } + + await Promise.all(promises); return res.status(200).json(review); } catch (err) { @@ -254,9 +264,8 @@ export default withAuth({ }); } } else if (req.method === 'DELETE') { - const { id } = req.query; + const { id, userId } = req.query as { id: string, userId: string }; - await dbConnect(); // delete all notifications around this type const level = await LevelModel.findById(id); @@ -266,17 +275,23 @@ export default withAuth({ }); } + if (!isCurator(req.user) && userId !== req.userId) { + return res.status(403).json({ + error: 'Not authorized to delete this review', + }); + } + try { await ReviewModel.deleteOne({ - levelId: id, - userId: req.userId, + levelId: new Types.ObjectId(id as string), + userId: new Types.ObjectId(userId), }); await Promise.all([ - queueRefreshAchievements(req.user._id, [AchievementCategory.REVIEWER]), + queueRefreshAchievements(userId, [AchievementCategory.REVIEWER]), queueRefreshAchievements(level.userId._id, [AchievementCategory.REVIEWER]), queueRefreshIndexCalcs(new Types.ObjectId(id?.toString())), - clearNotifications(level.userId._id, req.userId, level._id, NotificationType.NEW_REVIEW_ON_YOUR_LEVEL), + clearNotifications(level.userId._id, userId, level._id, NotificationType.NEW_REVIEW_ON_YOUR_LEVEL), ]); return res.status(200).json({ success: true }); diff --git a/pages/profile/[name]/[[...tab]]/index.tsx b/pages/profile/[name]/[[...tab]]/index.tsx index 9ff7b9666..4745ee1da 100644 --- a/pages/profile/[name]/[[...tab]]/index.tsx +++ b/pages/profile/[name]/[[...tab]]/index.tsx @@ -24,8 +24,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import FollowButton from '../../../../components/buttons/followButton'; import Select from '../../../../components/cards/select'; import SelectFilter from '../../../../components/cards/selectFilter'; -import FormattedReview from '../../../../components/formatted/formattedReview'; import CommentWall from '../../../../components/level/reviews/commentWall'; +import FormattedReview from '../../../../components/level/reviews/formattedReview'; import AddCollectionModal from '../../../../components/modal/addCollectionModal'; import MultiSelectUser from '../../../../components/page/multiSelectUser'; import Page from '../../../../components/page/page'; diff --git a/tests/pages/api/review/review.test.ts b/tests/pages/api/review/review.test.ts index 22b66f02b..c7a266f27 100644 --- a/tests/pages/api/review/review.test.ts +++ b/tests/pages/api/review/review.test.ts @@ -399,6 +399,7 @@ describe('Reviewing levels should work correctly', () => { body: { text: 't'.repeat(100), score: 3.5, + userId: TestId.USER, }, headers: { 'content-type': 'application/json', @@ -481,8 +482,7 @@ describe('Reviewing levels should work correctly', () => { jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger)); jest.spyOn(ReviewModel, 'findOneAndUpdate').mockImplementation(() => { throw new Error('Test DB error'); - } - ); + }); await testApiHandler({ handler: async (_, res) => { @@ -497,6 +497,7 @@ describe('Reviewing levels should work correctly', () => { body: { text: 'bad game', score: 2, + userId: TestId.USER, }, headers: { 'content-type': 'application/json', @@ -533,7 +534,8 @@ describe('Reviewing levels should work correctly', () => { }, body: { score: 5, - text: 'bad game' + text: 'bad game', + userId: TestId.USER, }, headers: { 'content-type': 'application/json', @@ -565,7 +567,6 @@ describe('Reviewing levels should work correctly', () => { }, }); }); - test('Testing editing review score and text', async () => { await testApiHandler({ handler: async (_, res) => { @@ -579,7 +580,8 @@ describe('Reviewing levels should work correctly', () => { }, body: { score: 5, - text: 'bad game' + text: 'bad game', + userId: TestId.USER, }, headers: { 'content-type': 'application/json', @@ -624,6 +626,7 @@ describe('Reviewing levels should work correctly', () => { }, body: { score: 3.3, + userId: TestId.USER, }, headers: { 'content-type': 'application/json', @@ -658,6 +661,7 @@ describe('Reviewing levels should work correctly', () => { }, body: { score: 5, + userId: TestId.USER, }, headers: { 'content-type': 'application/json', @@ -701,6 +705,7 @@ describe('Reviewing levels should work correctly', () => { score: 0, text: '', // missing score + userId: TestId.USER, }, headers: { 'content-type': 'application/json', @@ -721,13 +726,118 @@ describe('Reviewing levels should work correctly', () => { }, }); }); + test('Testing editing review without userId', async () => { + await testApiHandler({ + handler: async (_, res) => { + const req: NextApiRequestWithAuth = { + method: 'PUT', + cookies: { + token: getTokenCookieValue(TestId.USER), + }, + query: { + id: TestId.LEVEL_2, + }, + body: { + score: 5, + text: 'bad game', + }, + headers: { + 'content-type': 'application/json', + }, + } as unknown as NextApiRequestWithAuth; + + await reviewLevelHandler(req, res); + }, + test: async ({ fetch }) => { + const res = await fetch(); + const response = await res.json(); + + expect(res.status).toBe(400); + expect(response.error).toBe('Invalid body.userId'); + }, + }); + }); + test('PUT review by a different user', async () => { + await testApiHandler({ + handler: async (_, res) => { + const req: NextApiRequestWithAuth = { + method: 'PUT', + cookies: { + token: getTokenCookieValue(TestId.USER_B), + }, + query: { + id: TestId.LEVEL_2, + }, + body: { + score: 5, + text: 'bad game', + userId: TestId.USER, + }, + headers: { + 'content-type': 'application/json', + }, + } as unknown as NextApiRequestWithAuth; + + await reviewLevelHandler(req, res); + }, + test: async ({ fetch }) => { + const res = await fetch(); + const response = await res.json(); + + expect(res.status).toBe(403); + expect(response.error).toBe('Not authorized to edit this review'); + }, + }); + }); + test('PUT review by a curator', async () => { + await testApiHandler({ + handler: async (_, res) => { + const req: NextApiRequestWithAuth = { + method: 'PUT', + cookies: { + token: getTokenCookieValue(TestId.USER_C), + }, + query: { + id: TestId.LEVEL_2, + }, + body: { + score: 0.5, + text: 'curator was here', + userId: TestId.USER, + }, + headers: { + 'content-type': 'application/json', + }, + } as unknown as NextApiRequestWithAuth; + + await reviewLevelHandler(req, res); + }, + test: async ({ fetch }) => { + const res = await fetch(); + const response = await res.json(); + const processQueueRes = await processQueueMessages(); + + expect(processQueueRes).toBe('Processed 5 messages with no errors'); + + expect(response.error).toBeUndefined(); + expect(response.levelId.toString()).toBe(TestId.LEVEL_2); + expect(res.status).toBe(200); + + const review = await ReviewModel.findById(review_id); + + expect(review).toBeDefined(); + expect(review.text).toBe('curator was here'); + expect(review.score).toBe(0.5); + expect(review.levelId._id.toString()).toBe(TestId.LEVEL_2); + }, + }); + }); test('Testing deleting review when DB errors out', async () => { jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger)); jest.spyOn(ReviewModel, 'deleteOne').mockImplementation(() => { throw new Error('Test DB error'); - } - ); + }); await testApiHandler({ handler: async (_, res) => { @@ -738,8 +848,8 @@ describe('Reviewing levels should work correctly', () => { }, query: { id: TestId.LEVEL_2, + userId: TestId.USER, }, - headers: { 'content-type': 'application/json', }, @@ -769,8 +879,8 @@ describe('Reviewing levels should work correctly', () => { }, query: { id: new Types.ObjectId(), + userId: TestId.USER, }, - headers: { 'content-type': 'application/json', }, @@ -797,8 +907,71 @@ describe('Reviewing levels should work correctly', () => { }, query: { id: TestId.LEVEL_2, + userId: TestId.USER, + }, + headers: { + 'content-type': 'application/json', + }, + } as unknown as NextApiRequestWithAuth; + + await reviewLevelHandler(req, res); + }, + test: async ({ fetch }) => { + const res = await fetch(); + const response = await res.json(); + const processQueueRes = await processQueueMessages(); + + expect(processQueueRes).toBe('Processed 3 messages with no errors'); + expect(response.error).toBeUndefined(); + expect(response.success).toBe(true); + expect(res.status).toBe(200); + const lvl = await LevelModel.findById(TestId.LEVEL_2); + + expect(lvl.calc_reviews_count).toBe(0); + expect(lvl.calc_reviews_score_laplace.toFixed(2)).toBe('0.67'); // default + }, + }); + }); + test('DELETE review by a different user', async () => { + await testApiHandler({ + handler: async (_, res) => { + const req: NextApiRequestWithAuth = { + method: 'DELETE', + cookies: { + token: getTokenCookieValue(TestId.USER_B), + }, + query: { + id: TestId.LEVEL_2, + userId: TestId.USER, + }, + headers: { + 'content-type': 'application/json', }, + } as unknown as NextApiRequestWithAuth; + + await reviewLevelHandler(req, res); + }, + test: async ({ fetch }) => { + const res = await fetch(); + const response = await res.json(); + expect(response.error).toBe('Not authorized to delete this review'); + expect(res.status).toBe(403); + }, + }); + }); + test('DELETE review by a curator', async () => { + await testApiHandler({ + handler: async (_, res) => { + const req: NextApiRequestWithAuth = { + method: 'DELETE', + cookies: { + token: getTokenCookieValue(TestId.USER_C), + }, + query: { + id: TestId.LEVEL_2, + userId: TestId.USER, + }, headers: { 'content-type': 'application/json', },