Browse Source

Test change and unmark best answer.

Rafał Pitoń 7 years ago
parent
commit
df2ec175c2

+ 9 - 1
misago/threads/api/threadendpoints/patch.py

@@ -214,7 +214,7 @@ def patch_best_answer(request, thread, value):
     allow_mark_as_best_answer(request.user, post)
 
     if post.is_best_answer:
-        raise PermissionDenied(_("This post is already marked as best answer."))
+        raise PermissionDenied(_("This post is already marked as thread's best answer."))
 
     if thread.best_answer_id:
         allow_change_best_answer(request.user, thread)
@@ -241,6 +241,14 @@ def patch_unmark_best_answer(request, thread, value):
     except (TypeError, ValueError):
         raise PermissionDenied(_("A valid integer is required."))
 
+    post = get_object_or_404(thread.post_set, id=post_id)
+    post.category = thread.category
+    post.thread = thread
+
+    if not post.is_best_answer:
+        raise PermissionDenied(
+            _("This post can't be unmarked because it's not currently marked as best answer."))
+
     allow_unmark_best_answer(request.user, thread)
     thread.clear_best_answer()
     thread.save()

+ 31 - 24
misago/threads/permissions/bestanswers.py

@@ -191,32 +191,39 @@ def allow_change_best_answer(user, target):
     if not category_acl['can_change_marked_answers']:
         raise PermissionDenied(
             _(
-                'You don\'t have permission to change this thread\' marked answer because it\'s '
+                'You don\'t have permission to change this thread\'s marked answer because it\'s '
                 'in the "%(category)s" category.'
             ) % {
                 'category': target.category,
             }
         )
 
-    if (category_acl['can_change_marked_answers'] == 1 and
-            not has_time_to_change_answer(user, target)):
-        raise PermissionDenied(
-            ungettext(
-                (
-                    "You don't have permission to change best answer that was marked for more "
-                    "than %(minutes)s minute."
-                ),
-                (
-                    "You don't have permission to change best answer that was marked for more "
-                    "than %(minutes)s minutes."
-                ),
-                category_acl['answer_change_time'],
-            ) % {
-                'minutes': category_acl['answer_change_time'],
-            }
-        )
+    if category_acl['can_change_marked_answers'] == 1:
+        if target.starter_id != user.id:
+            raise PermissionDenied(
+                _(
+                    "You don't have permission to change this thread's marked answer because you "
+                    "are not a thread starter."
+                )
+            )
+        if not has_time_to_change_answer(user, target):
+            raise PermissionDenied(
+                ungettext(
+                    (
+                        "You don't have permission to change best answer that was marked for more "
+                        "than %(minutes)s minute."
+                    ),
+                    (
+                        "You don't have permission to change best answer that was marked for more "
+                        "than %(minutes)s minutes."
+                    ),
+                    category_acl['best_answer_change_time'],
+                ) % {
+                    'minutes': category_acl['best_answer_change_time'],
+                }
+            )
 
-    if target.thread.best_answer_is_protected and not category_acl['can_protect_posts']:
+    if target.best_answer_is_protected and not category_acl['can_protect_posts']:
         raise PermissionDenied(
             _(
                 "You don't have permission to change this thread's best answer because "
@@ -252,7 +259,7 @@ def allow_unmark_best_answer(user, target):
         )
 
     if category_acl['can_change_marked_answers'] == 1:
-        if target.starter != user:
+        if target.starter_id != user.id:
             raise PermissionDenied(
                 _(
                     "You don't have permission to unmark this best answer because you are not a "
@@ -270,9 +277,9 @@ def allow_unmark_best_answer(user, target):
                         "You don't have permission to unmark best answer that was marked for more "
                         "than %(minutes)s minutes."
                     ),
-                    category_acl['answer_change_time'],
+                    category_acl['best_answer_change_time'],
                 ) % {
-                    'minutes': category_acl['answer_change_time'],
+                    'minutes': category_acl['best_answer_change_time'],
                 }
             )
         
@@ -328,7 +335,7 @@ def allow_mark_as_best_answer(user, target):
             }
         )
 
-    if category_acl['can_mark_best_answers'] == 1 and target.thread.starter != user:
+    if category_acl['can_mark_best_answers'] == 1 and target.thread.starter_id != user.id:
         raise PermissionDenied(
             _(
                 "You don't have permission to mark best answer in this thread because you "
@@ -382,7 +389,7 @@ def has_time_to_change_answer(user, target):
     change_time = category_acl.get('best_answer_change_time', 0)
 
     if change_time:
-        diff = timezone.now() - target.best_answer_set_on
+        diff = timezone.now() - target.best_answer_marked_on
         diff_minutes = int(diff.total_seconds() / 60)
         return diff_minutes < change_time
     else:

+ 676 - 2
misago/threads/tests/test_thread_patch_api.py

@@ -143,8 +143,8 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
         """api cleans, validates and rejects too short title"""
         self.override_acl({'thread_edit_time': 1, 'can_edit_threads': 1})
 
-        self.thread.starter = self.user
         self.thread.started_on = timezone.now() - timedelta(minutes=10)
+        self.thread.starter = self.user
         self.thread.save()
 
         response = self.patch(
@@ -1129,8 +1129,8 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
             'thread_edit_time': 1,
         })
 
-        self.thread.starter = self.user
         self.thread.started_on = timezone.now() - timedelta(minutes=5)
+        self.thread.starter = self.user
         self.thread.save()
 
         response = self.patch(
@@ -1853,3 +1853,677 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
             ]
         )
         self.assertEqual(response.status_code, 200)
+
+
+class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
+    def setUp(self):
+        super(ThreadChangeBestAnswerApiTests, self).setUp()
+
+        self.best_answer = testutils.reply_thread(self.thread)
+        self.thread.set_best_answer(self.user, self.best_answer)
+        self.thread.save()
+
+    def test_change_best_answer(self):
+        """api makes it possible to change best answer"""
+        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ['ok'],
+
+            'best_answer': best_answer.id,
+            'best_answer_is_protected': False,
+            'best_answer_marked_on': response.json()['best_answer_marked_on'],
+            'best_answer_marked_by': self.user.id,
+            'best_answer_marked_by_name': self.user.username,
+            'best_answer_marked_by_slug': self.user.slug,
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], best_answer.id)
+        self.assertEqual(thread_json['best_answer_is_protected'], False)
+        self.assertEqual(
+            thread_json['best_answer_marked_on'], response.json()['best_answer_marked_on'])
+        self.assertEqual(thread_json['best_answer_marked_by'], self.user.id)
+        self.assertEqual(thread_json['best_answer_marked_by_name'], self.user.username)
+        self.assertEqual(thread_json['best_answer_marked_by_slug'], self.user.slug)
+
+    def test_change_best_answer_same_post(self):
+        """api validates if new best answer is same as current one"""
+        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["This post is already marked as thread's best answer."],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+    def test_change_best_answer_no_permission_to_mark(self):
+        """api validates permission to mark best answers before allowing answer change"""
+        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                'You don\'t have permission to mark best answers in the "First category" category.'
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+    def test_change_best_answer_no_permission(self):
+        """api validates permission to change best answers"""
+        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 0})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                'You don\'t have permission to change this thread\'s marked answer because it\'s '
+                'in the "First category" category.'
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+    def test_change_best_answer_not_starter(self):
+        """api validates permission to change best answers"""
+        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 1})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You don't have permission to change this thread's marked answer because you are "
+                "not a thread starter."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+        # passing scenario is possible
+        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 1})
+        
+        self.thread.starter = self.user
+        self.thread.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_change_best_answer_timelimit(self):
+        """api validates permission for starter to change best answers within timelimit"""
+        self.override_acl({
+            'can_mark_best_answers': 2,
+            'can_change_marked_answers': 1,
+            'best_answer_change_time': 5,
+        })
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=6)
+        self.thread.starter = self.user
+        self.thread.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You don't have permission to change best answer that was marked for more than "
+                "5 minutes."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+        # passing scenario is possible
+        self.override_acl({
+            'can_mark_best_answers': 2,
+            'can_change_marked_answers': 1,
+            'best_answer_change_time': 10,
+        })
+        
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_change_best_answer_protected(self):
+        """api validates permission to change protected best answers"""
+        self.override_acl({
+            'can_mark_best_answers': 2,
+            'can_change_marked_answers': 2,
+            'can_protect_posts': 0,
+        })
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        self.thread.best_answer_is_protected = True
+        self.thread.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You don't have permission to change this thread's best answer because a "
+                "moderator has protected it."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+        # passing scenario is possible
+        self.override_acl({
+            'can_mark_best_answers': 2,
+            'can_change_marked_answers': 2,
+            'can_protect_posts': 1,
+        })
+        
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_change_best_answer_post_validation(self):
+        """api validates new post'"""
+        self.override_acl({
+            'can_mark_best_answers': 2,
+            'can_change_marked_answers': 2,
+        })
+
+        best_answer = testutils.reply_thread(self.thread, is_hidden=True)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+
+class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
+    def setUp(self):
+        super(ThreadUnmarkBestAnswerApiTests, self).setUp()
+
+        self.best_answer = testutils.reply_thread(self.thread)
+        self.thread.set_best_answer(self.user, self.best_answer)
+        self.thread.save()
+
+    def test_unmark_best_answer(self):
+        """api makes it possible to unmark best answer"""
+        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ['ok'],
+
+            'best_answer': None,
+            'best_answer_is_protected': False,
+            'best_answer_marked_on': None,
+            'best_answer_marked_by': None,
+            'best_answer_marked_by_name': None,
+            'best_answer_marked_by_slug': None,
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+        self.assertFalse(thread_json['best_answer_is_protected'])
+        self.assertIsNone(thread_json['best_answer_marked_on'])
+        self.assertIsNone(thread_json['best_answer_marked_by'])
+        self.assertIsNone(thread_json['best_answer_marked_by_name'])
+        self.assertIsNone(thread_json['best_answer_marked_by_slug'])
+
+    def test_unmark_best_answer_invalid_post_id(self):
+        """api validates that post id is int"""
+        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': 'd7sd89a7d98sa',
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["A valid integer is required."],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+    def test_unmark_best_answer_post_not_found(self):
+        """api validates that post to unmark exists"""
+        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id + 1,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["NOT FOUND"],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+        
+    def test_unmark_best_answer_wrong_post(self):
+        """api validates if post given to unmark is best answer"""
+        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "This post can't be unmarked because it's not currently marked as best answer."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+    def test_unmark_best_answer_no_permission(self):
+        """api validates if user has permission to unmark best answers"""
+        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                'You don\'t have permission to unmark threads answers in the "First category" '
+                'category.'
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+    def test_unmark_best_answer_not_starter(self):
+        """api validates if starter has permission to unmark best answers"""
+        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You don't have permission to unmark this best answer because you are not a "
+                "thread starter."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+        # passing scenario is possible
+        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 1})
+
+        self.thread.starter = self.user
+        self.thread.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_unmark_best_answer_timelimit(self):
+        """api validates if starter has permission to unmark best answer within time limit"""
+        self.override_acl({
+            'can_mark_best_answers': 0,
+            'can_change_marked_answers': 1,
+            'best_answer_change_time': 5,
+        })
+
+        self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=6)
+        self.thread.starter = self.user
+        self.thread.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You don't have permission to unmark best answer that was marked for more than "
+                "5 minutes."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+        # passing scenario is possible
+        self.override_acl({
+            'can_mark_best_answers': 0,
+            'can_change_marked_answers': 1,
+            'best_answer_change_time': 10,
+        })
+        
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_unmark_best_answer_closed_category(self):
+        """api validates if user has permission to unmark best answer in closed category"""
+        self.override_acl({
+            'can_mark_best_answers': 0,
+            'can_change_marked_answers': 2,
+            'can_close_threads': 0,
+        })
+
+        self.category.is_closed = True
+        self.category.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                'You don\'t have permission to unmark this best answer because its category '
+                '"First category" is closed.'
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+        # passing scenario is possible
+        self.override_acl({
+            'can_mark_best_answers': 0,
+            'can_change_marked_answers': 2,
+            'can_close_threads': 1,
+        })
+        
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_unmark_best_answer_closed_thread(self):
+        """api validates if user has permission to unmark best answer in closed thread"""
+        self.override_acl({
+            'can_mark_best_answers': 0,
+            'can_change_marked_answers': 2,
+            'can_close_threads': 0,
+        })
+
+        self.thread.is_closed = True
+        self.thread.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You can't unmark this thread's best answer because it's closed and you don't "
+                "have permission to open it."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+        # passing scenario is possible
+        self.override_acl({
+            'can_mark_best_answers': 0,
+            'can_change_marked_answers': 2,
+            'can_close_threads': 1,
+        })
+        
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_unmark_best_answer_protected(self):
+        """api validates permission to unmark protected best answers"""
+        self.override_acl({
+            'can_mark_best_answers': 0,
+            'can_change_marked_answers': 2,
+            'can_protect_posts': 0,
+        })
+
+        self.thread.best_answer_is_protected = True
+        self.thread.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You don't have permission to unmark this thread's best answer because a "
+                "moderator has protected it."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], self.best_answer.id)
+
+        # passing scenario is possible
+        self.override_acl({
+            'can_mark_best_answers': 0,
+            'can_change_marked_answers': 2,
+            'can_protect_posts': 1,
+        })
+        
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'best-answer',
+                    'value': self.best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)