Browse Source

Fix build after previous API iteration

Rafał Pitoń 7 years ago
parent
commit
f7fb28ca22
29 changed files with 588 additions and 620 deletions
  1. 2 2
      misago/api/exceptionhandler.py
  2. 1 1
      misago/api/patch.py
  3. 1 1
      misago/api/tests/test_patch_dispatch.py
  4. 15 7
      misago/threads/api/attachments.py
  5. 2 1
      misago/threads/api/postendpoints/edits.py
  6. 11 2
      misago/threads/api/threadendpoints/patch.py
  7. 0 6
      misago/threads/permissions/privatethreads.py
  8. 13 10
      misago/threads/serializers/__init__.py
  9. 0 3
      misago/threads/serializers/attachment.py
  10. 0 4
      misago/threads/serializers/feed.py
  11. 0 12
      misago/threads/serializers/moderation.py
  12. 0 7
      misago/threads/serializers/poll.py
  13. 0 6
      misago/threads/serializers/pollvote.py
  14. 0 2
      misago/threads/serializers/post.py
  15. 0 5
      misago/threads/serializers/postedit.py
  16. 0 5
      misago/threads/serializers/postlike.py
  17. 0 7
      misago/threads/serializers/thread.py
  18. 0 3
      misago/threads/serializers/threadparticipant.py
  19. 45 13
      misago/threads/tests/test_attachments_api.py
  20. 76 54
      misago/threads/tests/test_privatethread_patch_api.py
  21. 52 34
      misago/threads/tests/test_thread_merge_api.py
  22. 119 171
      misago/threads/tests/test_thread_patch_api.py
  23. 9 12
      misago/threads/tests/test_thread_postbulkpatch_api.py
  24. 3 3
      misago/threads/tests/test_thread_postedits_api.py
  25. 125 155
      misago/threads/tests/test_thread_postpatch_api.py
  26. 79 43
      misago/threads/tests/test_threads_bulkdelete_api.py
  27. 17 20
      misago/threads/tests/test_threads_bulkpatch_api.py
  28. 16 29
      misago/threads/tests/test_threads_merge_api.py
  29. 2 2
      misago/users/serializers/__init__.py

+ 2 - 2
misago/api/exceptionhandler.py

@@ -1,4 +1,4 @@
-from rest_framework.views import exception_handler as rest_exception_handler
+from rest_framework.views import exception_handler as drf_exception_handler
 
 
 from django.core.exceptions import PermissionDenied
 from django.core.exceptions import PermissionDenied
 from django.http import Http404
 from django.http import Http404
@@ -8,7 +8,7 @@ from misago.core.exceptions import Banned
 
 
 
 
 def handle_api_exception(exception, context):
 def handle_api_exception(exception, context):
-    response = rest_exception_handler(exception, context)
+    response = drf_exception_handler(exception, context)
     if response:
     if response:
         if isinstance(exception, Banned):
         if isinstance(exception, Banned):
             response.data = exception.ban.get_serialized_message()
             response.data = exception.ban.get_serialized_message()

+ 1 - 1
misago/api/patch.py

@@ -54,7 +54,7 @@ class ApiPatch(object):
     def dispatch(self, request, target):
     def dispatch(self, request, target):
         if not isinstance(request.data, list):
         if not isinstance(request.data, list):
             return Response({
             return Response({
-                'detail': _("PATCH request should be list of operations."),
+                'detail': _("PATCH request should be a list of operations."),
             }, status=400)
             }, status=400)
 
 
         response = {'id': target.pk}
         response = {'id': target.pk}

+ 1 - 1
misago/api/tests/test_patch_dispatch.py

@@ -47,7 +47,7 @@ class ApiPatchDispatchTests(TestCase):
         # dispatch requires list as an argument
         # dispatch requires list as an argument
         response = patch.dispatch(MockRequest({}), {})
         response = patch.dispatch(MockRequest({}), {})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.data['detail'], "PATCH request should be list of operations.")
+        self.assertEqual(response.data['detail'], "PATCH request should be a list of operations.")
 
 
         # valid dispatch
         # valid dispatch
         response = patch.dispatch(
         response = patch.dispatch(

+ 15 - 7
misago/threads/api/attachments.py

@@ -1,8 +1,9 @@
 from rest_framework import viewsets
 from rest_framework import viewsets
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
-from django.core.exceptions import PermissionDenied, ValidationError
+from django.core.exceptions import PermissionDenied
 from django.template.defaultfilters import filesizeformat
 from django.template.defaultfilters import filesizeformat
+from django.utils import six
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 
 
 from misago.acl import add_acl
 from misago.acl import add_acl
@@ -13,16 +14,23 @@ from misago.threads.serializers import AttachmentSerializer
 IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
 IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
 
 
 
 
+class UploadError(Exception):
+    pass
+
+
 class AttachmentViewSet(viewsets.ViewSet):
 class AttachmentViewSet(viewsets.ViewSet):
     def create(self, request):
     def create(self, request):
         if not request.user.acl_cache['max_attachment_size']:
         if not request.user.acl_cache['max_attachment_size']:
             raise PermissionDenied(_("You don't have permission to upload new files."))
             raise PermissionDenied(_("You don't have permission to upload new files."))
-        return self.create_attachment(request)
+        try:
+            return self.create_attachment(request)
+        except UploadError as e:
+            return Response({'upload': e.args}, status=400)
 
 
     def create_attachment(self, request):
     def create_attachment(self, request):
         upload = request.FILES.get('upload')
         upload = request.FILES.get('upload')
         if not upload:
         if not upload:
-            raise ValidationError(_("No file has been uploaded."))
+            raise UploadError(_("No file has been uploaded."))
 
 
         user_roles = set(r.pk for r in request.user.get_roles())
         user_roles = set(r.pk for r in request.user.get_roles())
         filetype = validate_filetype(upload, user_roles)
         filetype = validate_filetype(upload, user_roles)
@@ -43,7 +51,7 @@ class AttachmentViewSet(viewsets.ViewSet):
             try:
             try:
                 attachment.set_image(upload)
                 attachment.set_image(upload)
             except IOError:
             except IOError:
-                raise ValidationError(_("Uploaded image was corrupted or invalid."))
+                raise UploadError(_("Uploaded image was corrupted or invalid."))
         else:
         else:
             attachment.set_file(upload)
             attachment.set_file(upload)
 
 
@@ -74,13 +82,13 @@ def validate_filetype(upload, user_roles):
 
 
         return filetype
         return filetype
 
 
-    raise ValidationError(_("You can't upload files of this type."))
+    raise UploadError(_("You can't upload files of this type."))
 
 
 
 
 def validate_filesize(upload, filetype, hard_limit):
 def validate_filesize(upload, filetype, hard_limit):
     if upload.size > hard_limit * 1024:
     if upload.size > hard_limit * 1024:
         message = _("You can't upload files larger than %(limit)s (your file has %(upload)s).")
         message = _("You can't upload files larger than %(limit)s (your file has %(upload)s).")
-        raise ValidationError(
+        raise UploadError(
             message % {
             message % {
                 'upload': filesizeformat(upload.size).rstrip('.0'),
                 'upload': filesizeformat(upload.size).rstrip('.0'),
                 'limit': filesizeformat(hard_limit * 1024).rstrip('.0'),
                 'limit': filesizeformat(hard_limit * 1024).rstrip('.0'),
@@ -91,7 +99,7 @@ def validate_filesize(upload, filetype, hard_limit):
         message = _(
         message = _(
             "You can't upload files of this type larger than %(limit)s (your file has %(upload)s)."
             "You can't upload files of this type larger than %(limit)s (your file has %(upload)s)."
         )
         )
-        raise ValidationError(
+        raise UploadError(
             message % {
             message % {
                 'upload': filesizeformat(upload.size).rstrip('.0'),
                 'upload': filesizeformat(upload.size).rstrip('.0'),
                 'limit': filesizeformat(filetype.size_limit * 1024).rstrip('.0'),
                 'limit': filesizeformat(filetype.size_limit * 1024).rstrip('.0'),

+ 2 - 1
misago/threads/api/postendpoints/edits.py

@@ -2,6 +2,7 @@ from rest_framework.response import Response
 
 
 from django.core.exceptions import PermissionDenied
 from django.core.exceptions import PermissionDenied
 from django.db.models import F
 from django.db.models import F
+from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
@@ -86,7 +87,7 @@ def get_edit(post, pk=None):
 
 
     edit = post.edits_record.first()
     edit = post.edits_record.first()
     if not edit:
     if not edit:
-        raise Http404(_("Edits record is unavailable for this post."))
+        raise Http404()
     return edit
     return edit
 
 
 
 

+ 11 - 2
misago/threads/api/threadendpoints/patch.py

@@ -209,9 +209,18 @@ def patch_add_participant(request, thread, value):
     if participant in [p.user for p in thread.participants_list]:
     if participant in [p.user for p in thread.participants_list]:
         raise ValidationError(_("This user is already thread participant."))
         raise ValidationError(_("This user is already thread participant."))
 
 
-    allow_add_participant(request.user, participant)
-    add_participant(request, thread, participant)
+    max_participants = request.user.acl_cache['max_private_thread_participants']
+    if max_participants:
+        current_participants = len(thread.participants_list)
+        if current_participants >= max_participants:
+            raise ValidationError(_("You can't add any more new users to this thread."))
+    
+    try:
+        allow_add_participant(request.user, participant)
+    except PermissionDenied as e:
+        raise ValidationError(six.text_type(e))
 
 
+    add_participant(request, thread, participant)
     make_participants_aware(request.user, thread)
     make_participants_aware(request.user, thread)
     participants = ThreadParticipantSerializer(thread.participants_list, many=True)
     participants = ThreadParticipantSerializer(thread.participants_list, many=True)
 
 

+ 0 - 6
misago/threads/permissions/privatethreads.py

@@ -220,12 +220,6 @@ def allow_add_participants(user, target):
         if target.is_closed:
         if target.is_closed:
             raise PermissionDenied(_("Only moderators can add participants to closed threads."))
             raise PermissionDenied(_("Only moderators can add participants to closed threads."))
 
 
-    max_participants = user.acl_cache['max_private_thread_participants']
-    current_participants = len(target.participants_list) - 1
-
-    if current_participants >= max_participants:
-        raise PermissionDenied(_("You can't add any more new users to this thread."))
-
 
 
 can_add_participants = return_boolean(allow_add_participants)
 can_add_participants = return_boolean(allow_add_participants)
 
 

+ 13 - 10
misago/threads/serializers/__init__.py

@@ -1,10 +1,13 @@
-from .moderation import *
-from .threadparticipant import *
-from .thread import *
-from .post import *
-from .feed import *
-from .postedit import *
-from .postlike import *
-from .attachment import *
-from .poll import *
-from .pollvote import *
+from .moderation import (
+    DeletePostsSerializer, DeleteThreadsSerializer, MergePostsSerializer, MergeThreadSerializer,
+    MergeThreadsSerializer, MovePostsSerializer, NewThreadSerializer, SplitPostsSerializer
+)
+from .threadparticipant import ThreadParticipantSerializer
+from .thread import PrivateThreadSerializer, ThreadSerializer, ThreadsListSerializer
+from .post import PostSerializer
+from .feed import FeedSerializer
+from .postedit import PostEditSerializer
+from .postlike import PostLikeSerializer
+from .attachment import AttachmentSerializer
+from .poll import EditPollSerializer, NewPollSerializer, PollChoiceSerializer, PollSerializer
+from .pollvote import NewVoteSerializer, PollVoteSerializer

+ 0 - 3
misago/threads/serializers/attachment.py

@@ -5,9 +5,6 @@ from django.urls import reverse
 from misago.threads.models import Attachment
 from misago.threads.models import Attachment
 
 
 
 
-__all__ = ['AttachmentSerializer']
-
-
 class AttachmentSerializer(serializers.ModelSerializer):
 class AttachmentSerializer(serializers.ModelSerializer):
     post = serializers.PrimaryKeyRelatedField(read_only=True)
     post = serializers.PrimaryKeyRelatedField(read_only=True)
 
 

+ 0 - 4
misago/threads/serializers/feed.py

@@ -8,10 +8,6 @@ from misago.users.serializers import UserSerializer
 from .post import PostSerializer
 from .post import PostSerializer
 
 
 
 
-__all__ = [
-    'FeedSerializer',
-]
-
 FeedUserSerializer = UserSerializer.subset_fields(
 FeedUserSerializer = UserSerializer.subset_fields(
     'id',
     'id',
     'username',
     'username',

+ 0 - 12
misago/threads/serializers/moderation.py

@@ -25,18 +25,6 @@ POSTS_LIMIT = settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL
 THREADS_LIMIT = settings.MISAGO_THREADS_PER_PAGE + settings.MISAGO_THREADS_TAIL
 THREADS_LIMIT = settings.MISAGO_THREADS_PER_PAGE + settings.MISAGO_THREADS_TAIL
 
 
 
 
-__all__ = [
-    'DeletePostsSerializer',
-    'DeleteThreadsSerializer',
-    'MergePostsSerializer',
-    'MergeThreadSerializer',
-    'MergeThreadsSerializer',
-    'MovePostsSerializer',
-    'NewThreadSerializer',
-    'SplitPostsSerializer',
-]
-
-
 class DeletePostsSerializer(serializers.Serializer):
 class DeletePostsSerializer(serializers.Serializer):
     error_empty_or_required = ugettext_lazy("You have to specify at least one post to delete.")
     error_empty_or_required = ugettext_lazy("You have to specify at least one post to delete.")
 
 

+ 0 - 7
misago/threads/serializers/poll.py

@@ -8,13 +8,6 @@ from django.utils.translation import ungettext
 from misago.threads.models import Poll
 from misago.threads.models import Poll
 
 
 
 
-__all__ = [
-    'PollSerializer',
-    'EditPollSerializer',
-    'NewPollSerializer',
-    'PollChoiceSerializer',
-]
-
 MAX_POLL_OPTIONS = 16
 MAX_POLL_OPTIONS = 16
 
 
 
 

+ 0 - 6
misago/threads/serializers/pollvote.py

@@ -5,12 +5,6 @@ from django.utils.translation import ugettext as _
 from django.utils.translation import ungettext
 from django.utils.translation import ungettext
 
 
 
 
-__all__ = [
-    'NewVoteSerializer',
-    'PollVoteSerializer',
-]
-
-
 class NewVoteSerializer(serializers.Serializer):
 class NewVoteSerializer(serializers.Serializer):
     choices = serializers.ListField(
     choices = serializers.ListField(
         child=serializers.CharField(),
         child=serializers.CharField(),

+ 0 - 2
misago/threads/serializers/post.py

@@ -7,8 +7,6 @@ from misago.threads.models import Post
 from misago.users.serializers import UserSerializer as BaseUserSerializer
 from misago.users.serializers import UserSerializer as BaseUserSerializer
 
 
 
 
-__all__ = ['PostSerializer']
-
 UserSerializer = BaseUserSerializer.subset_fields(
 UserSerializer = BaseUserSerializer.subset_fields(
     'id',
     'id',
     'username',
     'username',

+ 0 - 5
misago/threads/serializers/postedit.py

@@ -5,11 +5,6 @@ from django.urls import reverse
 from misago.threads.models import PostEdit
 from misago.threads.models import PostEdit
 
 
 
 
-__all__ = [
-    'PostEditSerializer',
-]
-
-
 class PostEditSerializer(serializers.ModelSerializer):
 class PostEditSerializer(serializers.ModelSerializer):
     diff = serializers.SerializerMethodField()
     diff = serializers.SerializerMethodField()
 
 

+ 0 - 5
misago/threads/serializers/postlike.py

@@ -5,11 +5,6 @@ from django.urls import reverse
 from misago.threads.models import PostLike
 from misago.threads.models import PostLike
 
 
 
 
-__all__ = [
-    'PostLikeSerializer',
-]
-
-
 class PostLikeSerializer(serializers.ModelSerializer):
 class PostLikeSerializer(serializers.ModelSerializer):
     avatars = serializers.SerializerMethodField()
     avatars = serializers.SerializerMethodField()
     liker_id = serializers.SerializerMethodField()
     liker_id = serializers.SerializerMethodField()

+ 0 - 7
misago/threads/serializers/thread.py

@@ -10,13 +10,6 @@ from .poll import PollSerializer
 from .threadparticipant import ThreadParticipantSerializer
 from .threadparticipant import ThreadParticipantSerializer
 
 
 
 
-__all__ = [
-    'ThreadSerializer',
-    'PrivateThreadSerializer',
-    'ThreadsListSerializer',
-]
-
-
 class ThreadSerializer(serializers.ModelSerializer, MutableFields):
 class ThreadSerializer(serializers.ModelSerializer, MutableFields):
     category = BasicCategorySerializer(many=False, read_only=True)
     category = BasicCategorySerializer(many=False, read_only=True)
 
 

+ 0 - 3
misago/threads/serializers/threadparticipant.py

@@ -3,9 +3,6 @@ from rest_framework import serializers
 from misago.threads.models import ThreadParticipant
 from misago.threads.models import ThreadParticipant
 
 
 
 
-__all__ = ['ThreadParticipantSerializer']
-
-
 class ThreadParticipantSerializer(serializers.ModelSerializer):
 class ThreadParticipantSerializer(serializers.ModelSerializer):
     id = serializers.SerializerMethodField()
     id = serializers.SerializerMethodField()
     username = serializers.SerializerMethodField()
     username = serializers.SerializerMethodField()

+ 45 - 13
misago/threads/tests/test_attachments_api.py

@@ -46,12 +46,18 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.override_acl({'max_attachment_size': 0})
         self.override_acl({'max_attachment_size': 0})
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "don't have permission to upload new files", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You don't have permission to upload new files.",
+        })
 
 
     def test_no_file_uploaded(self):
     def test_no_file_uploaded(self):
         """no file uploaded scenario is handled"""
         """no file uploaded scenario is handled"""
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "No file has been uploaded.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'upload': ["No file has been uploaded."],
+        })
 
 
     def test_invalid_extension(self):
     def test_invalid_extension(self):
         """uploaded file's extension is rejected as invalid"""
         """uploaded file's extension is rejected as invalid"""
@@ -67,7 +73,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     'upload': upload,
                     'upload': upload,
                 }
                 }
             )
             )
-        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'upload': ["You can't upload files of this type."],
+            })
 
 
     def test_invalid_mime(self):
     def test_invalid_mime(self):
         """uploaded file's mimetype is rejected as invalid"""
         """uploaded file's mimetype is rejected as invalid"""
@@ -83,7 +92,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     'upload': upload,
                     'upload': upload,
                 }
                 }
             )
             )
-        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'upload': ["You can't upload files of this type."],
+            })
 
 
     def test_no_perm_to_type(self):
     def test_no_perm_to_type(self):
         """user needs permission to upload files of this type"""
         """user needs permission to upload files of this type"""
@@ -102,7 +114,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     'upload': upload,
                     'upload': upload,
                 }
                 }
             )
             )
-        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'upload': ["You can't upload files of this type."],
+            })
 
 
     def test_type_is_locked(self):
     def test_type_is_locked(self):
         """new uploads for this filetype are locked"""
         """new uploads for this filetype are locked"""
@@ -119,7 +134,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     'upload': upload,
                     'upload': upload,
                 }
                 }
             )
             )
-        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'upload': ["You can't upload files of this type."],
+            })
 
 
     def test_type_is_disabled(self):
     def test_type_is_disabled(self):
         """new uploads for this filetype are disabled"""
         """new uploads for this filetype are disabled"""
@@ -136,7 +154,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     'upload': upload,
                     'upload': upload,
                 }
                 }
             )
             )
-        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'upload': ["You can't upload files of this type."],
+            })
 
 
     def test_upload_too_big_for_type(self):
     def test_upload_too_big_for_type(self):
         """too big uploads are rejected"""
         """too big uploads are rejected"""
@@ -153,10 +174,13 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     'upload': upload,
                     'upload': upload,
                 }
                 }
             )
             )
-
-        self.assertContains(
-            response, "can't upload files of this type larger than", status_code=400
-        )
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'upload': [
+                    "You can't upload files of this type larger "
+                    "than 100.0\xa0KB (your file has 253.9\xa0KB)."
+                ],
+            })
 
 
     def test_upload_too_big_for_user(self):
     def test_upload_too_big_for_user(self):
         """too big uploads are rejected"""
         """too big uploads are rejected"""
@@ -174,7 +198,12 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     'upload': upload,
                     'upload': upload,
                 }
                 }
             )
             )
-        self.assertContains(response, "can't upload files larger than", status_code=400)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'upload': [
+                    "You can't upload files larger than 100.0\xa0KB (your file has 253.9\xa0KB)."
+                ],
+            })
 
 
     def test_corrupted_image_upload(self):
     def test_corrupted_image_upload(self):
         """corrupted image upload is handled"""
         """corrupted image upload is handled"""
@@ -189,7 +218,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     'upload': upload,
                     'upload': upload,
                 }
                 }
             )
             )
-        self.assertContains(response, "Uploaded image was corrupted or invalid.", status_code=400)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'upload': ["Uploaded image was corrupted or invalid."],
+            })
 
 
     def test_document_upload(self):
     def test_document_upload(self):
         """successful upload creates orphan attachment"""
         """successful upload creates orphan attachment"""

+ 76 - 54
misago/threads/tests/test_privatethread_patch_api.py

@@ -42,10 +42,10 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "be thread owner to add new participants to it", status_code=400
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You have to be thread owner to add new participants to it.",
+        })
 
 
     def test_add_empty_username(self):
     def test_add_empty_username(self):
         """path validates username"""
         """path validates username"""
@@ -60,10 +60,10 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "You have to enter new participant's username.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["You have to enter new participant's username."],
+        })
 
 
     def test_add_nonexistant_user(self):
     def test_add_nonexistant_user(self):
         """can't user two times"""
         """can't user two times"""
@@ -78,8 +78,10 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "No user with such name exists.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["No user with such name exists."],
+        })
 
 
     def test_add_already_participant(self):
     def test_add_already_participant(self):
         """can't add user that is already participant"""
         """can't add user that is already participant"""
@@ -94,8 +96,10 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "This user is already thread participant", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["This user is already thread participant."],
+        })
 
 
     def test_add_blocking_user(self):
     def test_add_blocking_user(self):
         """can't add user that is already participant"""
         """can't add user that is already participant"""
@@ -111,8 +115,10 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "BobBoberson is blocking you.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["BobBoberson is blocking you."],
+        })
 
 
     def test_add_no_perm_user(self):
     def test_add_no_perm_user(self):
         """can't add user that has no permission to use private threads"""
         """can't add user that has no permission to use private threads"""
@@ -129,8 +135,10 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "BobBoberson can't participate", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["BobBoberson can't participate in private threads."],
+        })
 
 
     def test_add_too_many_users(self):
     def test_add_too_many_users(self):
         """can't add user that is already participant"""
         """can't add user that is already participant"""
@@ -151,10 +159,10 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "You can't add any more new users to this thread.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["You can't add any more new users to this thread."],
+        })
 
 
     def test_add_user_closed_thread(self):
     def test_add_user_closed_thread(self):
         """adding user to closed thread fails for non-moderator"""
         """adding user to closed thread fails for non-moderator"""
@@ -172,10 +180,10 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "Only moderators can add participants to closed threads.", status_code=400
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "Only moderators can add participants to closed threads.",
+        })
 
 
     def test_add_user(self):
     def test_add_user(self):
         """adding user to thread add user to thread as participant, sets event and emails him"""
         """adding user to thread add user to thread as participant, sets event and emails him"""
@@ -276,8 +284,10 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["Participant doesn't exist."],
+        })
 
 
     def test_remove_invalid(self):
     def test_remove_invalid(self):
         """api validates user id type"""
         """api validates user id type"""
@@ -292,8 +302,10 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["Participant doesn't exist."],
+        })
 
 
     def test_remove_nonexistant(self):
     def test_remove_nonexistant(self):
         """removed user has to be participant"""
         """removed user has to be participant"""
@@ -308,8 +320,10 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["Participant doesn't exist."],
+        })
 
 
     def test_remove_not_owner(self):
     def test_remove_not_owner(self):
         """api validates if user trying to remove other user is an owner"""
         """api validates if user trying to remove other user is an owner"""
@@ -325,10 +339,10 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "be thread owner to remove participants from it", status_code=400
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You have to be thread owner to remove participants from it.",
+        })
 
 
     def test_owner_remove_user_closed_thread(self):
     def test_owner_remove_user_closed_thread(self):
         """api disallows owner to remove other user from closed thread"""
         """api disallows owner to remove other user from closed thread"""
@@ -347,10 +361,10 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "moderators can remove participants from closed threads", status_code=400
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "Only moderators can remove participants from closed threads.",
+        })
 
 
     def test_user_leave_thread(self):
     def test_user_leave_thread(self):
         """api allows user to remove himself from thread"""
         """api allows user to remove himself from thread"""
@@ -577,8 +591,10 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["Participant doesn't exist."],
+        })
 
 
     def test_invalid_user_id(self):
     def test_invalid_user_id(self):
         """api handles invalid user id"""
         """api handles invalid user id"""
@@ -593,8 +609,10 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["Participant doesn't exist."],
+        })
 
 
     def test_nonexistant_user_id(self):
     def test_nonexistant_user_id(self):
         """api handles nonexistant user id"""
         """api handles nonexistant user id"""
@@ -609,8 +627,10 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["Participant doesn't exist."],
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """non-moderator/owner can't change owner"""
         """non-moderator/owner can't change owner"""
@@ -626,10 +646,10 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "thread owner and moderators can change threads owners", status_code=400
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "Only thread owner and moderators can change threads owners.",
+        })
 
 
     def test_no_change(self):
     def test_no_change(self):
         """api validates that new owner id is same as current owner"""
         """api validates that new owner id is same as current owner"""
@@ -645,8 +665,10 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "This user already is thread owner.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': ["This user already is thread owner."],
+        })
 
 
     def test_change_closed_thread_owner(self):
     def test_change_closed_thread_owner(self):
         """non-moderator can't change owner in closed thread"""
         """non-moderator can't change owner in closed thread"""
@@ -665,10 +687,10 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "Only moderators can change closed threads owners.", status_code=400
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "Only moderators can change closed threads owners.",
+        })
 
 
     def test_owner_change_thread_owner(self):
     def test_owner_change_thread_owner(self):
         """owner can pass thread ownership to other participant"""
         """owner can pass thread ownership to other participant"""

+ 52 - 34
misago/threads/tests/test_thread_merge_api.py

@@ -67,27 +67,27 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.override_acl({'can_merge_threads': 0})
         self.override_acl({'can_merge_threads': 0})
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(
-            response,
-            "You can't merge threads in this category.",
-            status_code=403
-        )
-
+        self.assertContains(response, "You can't merge threads in this category.", status_code=403)
+        
     def test_merge_no_url(self):
     def test_merge_no_url(self):
         """api validates if thread url was given"""
         """api validates if thread url was given"""
         self.override_acl({'can_merge_threads': 1})
         self.override_acl({'can_merge_threads': 1})
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "Enter link to new thread.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {'other_thread': ["Enter link to new thread."]})
 
 
     def test_invalid_url(self):
     def test_invalid_url(self):
         """api validates thread url"""
         """api validates thread url"""
         self.override_acl({'can_merge_threads': 1})
         self.override_acl({'can_merge_threads': 1})
 
 
-        response = self.client.post(self.api_link, {
-            'other_thread': self.user.get_absolute_url(),
-        })
-        self.assertContains(response, "This is not a valid thread link.", status_code=400)
+        response = self.client.post(
+            self.api_link, {
+                'other_thread': self.user.get_absolute_url(),
+            }
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {'other_thread': ["This is not a valid thread link."]})
 
 
     def test_current_other_thread(self):
     def test_current_other_thread(self):
         """api validates if thread url given is to current thread"""
         """api validates if thread url given is to current thread"""
@@ -98,7 +98,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': self.thread.get_absolute_url(),
                 'other_thread': self.thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, "You can't merge thread with itself.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(), {'other_thread': ["You can't merge thread with itself."]}
+        )
 
 
     def test_other_thread_exists(self):
     def test_other_thread_exists(self):
         """api validates if other thread exists"""
         """api validates if other thread exists"""
@@ -113,8 +116,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'other_thread': other_other_thread,
             'other_thread': other_other_thread,
         })
         })
-        self.assertContains(
-            response, "The thread you have entered link to doesn't exist", status_code=400
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {'other_thread': [
+                "The thread you have entered link to doesn't exist or "
+                "you don't have permission to see it."
+            ]},
         )
         )
 
 
     def test_other_thread_is_invisible(self):
     def test_other_thread_is_invisible(self):
@@ -130,8 +138,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(
-            response, "The thread you have entered link to doesn't exist", status_code=400
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {'other_thread': [
+                "The thread you have entered link to doesn't exist or "
+                "you don't have permission to see it."
+            ]},
         )
         )
 
 
     def test_other_thread_isnt_mergeable(self):
     def test_other_thread_isnt_mergeable(self):
@@ -147,9 +160,9 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-
-        self.assertContains(
-            response, "Other thread can't be merged with.", status_code=400
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(), {'other_thread': ["Other thread can't be merged with."]}
         )
         )
 
 
     def test_thread_category_is_closed(self):
     def test_thread_category_is_closed(self):
@@ -172,10 +185,9 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(
-            response,
-            "This category is closed. You can't merge it's threads.",
-            status_code=403,
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(
+            response.json(), {'detail': "This category is closed. You can't merge it's threads."},
         )
         )
 
 
     def test_thread_is_closed(self):
     def test_thread_is_closed(self):
@@ -198,10 +210,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(
-            response,
-            "This thread is closed. You can't merge it with other threads.",
-            status_code=403,
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(
+            response.json(),
+            {'detail': "This thread is closed. You can't merge it with other threads."},
         )
         )
 
 
     def test_other_thread_category_is_closed(self):
     def test_other_thread_category_is_closed(self):
@@ -224,8 +236,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(
-            response, "Other thread's category is closed. You can't merge with it.", status_code=400
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {'other_thread': ["Other thread's category is closed. You can't merge with it."]},
         )
         )
 
 
     def test_other_thread_is_closed(self):
     def test_other_thread_is_closed(self):
@@ -248,8 +262,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(
-            response, "Other thread is closed and can't be merged with", status_code=400
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {'other_thread': ["Other thread is closed and can't be merged with."]},
         )
         )
 
 
     def test_other_thread_isnt_replyable(self):
     def test_other_thread_isnt_replyable(self):
@@ -268,8 +284,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(
-            response, "You can't merge this thread into thread you can't reply.", status_code=400
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {'other_thread': ["You can't merge this thread into thread you can't reply."]},
         )
         )
 
 
     def test_merge_threads(self):
     def test_merge_threads(self):
@@ -526,7 +544,7 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             }
             }
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {'detail': "Invalid choice."})
+        self.assertEqual(response.json(), {'poll': ["Invalid choice."]})
 
 
         # polls and votes were untouched
         # polls and votes were untouched
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(Poll.objects.count(), 2)

+ 119 - 171
misago/threads/tests/test_thread_patch_api.py

@@ -81,10 +81,10 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't edit threads in this category.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't edit threads in this category."
+        })
 
 
     def test_change_thread_title_closed_category_no_permission(self):
     def test_change_thread_title_closed_category_no_permission(self):
         """api test permission to edit thread title in closed category"""
         """api test permission to edit thread title in closed category"""
@@ -105,12 +105,11 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't edit threads in it."
+        })
 
 
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't edit threads in it."
-        )
 
 
     def test_change_thread_title_closed_thread_no_permission(self):
     def test_change_thread_title_closed_thread_no_permission(self):
         """api test permission to edit closed thread title"""
         """api test permission to edit closed thread title"""
@@ -131,12 +130,10 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't edit it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't edit it."
+        })
 
 
     def test_change_thread_title_after_edit_time(self):
     def test_change_thread_title_after_edit_time(self):
         """api cleans, validates and rejects too short title"""
         """api cleans, validates and rejects too short title"""
@@ -155,12 +152,10 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't edit threads that are older than 1 minute."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't edit threads that are older than 1 minute."
+        })
 
 
     def test_change_thread_title_invalid(self):
     def test_change_thread_title_invalid(self):
         """api cleans, validates and rejects too short title"""
         """api cleans, validates and rejects too short title"""
@@ -176,12 +171,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
             ]
             ]
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0],
-            "Thread title should be at least 5 characters long (it has 2)."
-        )
+        self.assertEqual(response.json(), {
+            'detail': ["Thread title should be at least 5 characters long (it has 2)."]
+        })
 
 
 
 
 class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
 class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
@@ -225,12 +217,10 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't change threads weights in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't change threads weights in it."
+        })
 
 
     def test_pin_thread_closed_no_permission(self):
     def test_pin_thread_closed_no_permission(self):
         """api checks if thread is closed"""
         """api checks if thread is closed"""
@@ -251,12 +241,10 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't change its weight."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't change its weight."
+        })
 
 
     def test_unpin_thread(self):
     def test_unpin_thread(self):
         """api makes it possible to unpin thread"""
         """api makes it possible to unpin thread"""
@@ -298,12 +286,10 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't pin threads globally in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't pin threads globally in this category."
+        })
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
         self.assertEqual(thread_json['weight'], 0)
@@ -327,12 +313,10 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't change globally pinned threads weights in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't change globally pinned threads weights in this category."
+        })
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
         self.assertEqual(thread_json['weight'], 2)
@@ -400,12 +384,10 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't change threads weights in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't change threads weights in this category."
+        })
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
         self.assertEqual(thread_json['weight'], 0)
@@ -429,12 +411,10 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't change threads weights in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't change threads weights in this category."
+        })
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
         self.assertEqual(thread_json['weight'], 1)
@@ -639,13 +619,11 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't move threads in this category."
-        )
-
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't move threads in this category."
+        })
+        
         self.override_other_acl({})
         self.override_other_acl({})
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
@@ -671,12 +649,10 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't move it's threads."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't move it's threads."
+        })
 
 
     def test_move_closed_thread_no_permission(self):
     def test_move_closed_thread_no_permission(self):
         """api move closed thread with no permission fails"""
         """api move closed thread with no permission fails"""
@@ -698,12 +674,10 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't move it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't move it."
+        })
 
 
     def test_move_thread_no_category_access(self):
     def test_move_thread_no_category_access(self):
         """api move thread to category with no access fails"""
         """api move thread to category with no access fails"""
@@ -719,10 +693,10 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], 'NOT FOUND')
+        self.assertEqual(response.status_code, 404)
+        self.assertEqual(response.json(), {
+            'detail': "NOT FOUND"
+        })
 
 
         self.override_other_acl({})
         self.override_other_acl({})
 
 
@@ -743,13 +717,10 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0],
-            'You don\'t have permission to browse "Category B" contents.'
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': 'You don\'t have permission to browse "Category B" contents.'
+        })
 
 
         self.override_other_acl({})
         self.override_other_acl({})
 
 
@@ -770,13 +741,10 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0],
-            "You don't have permission to start new threads in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You don't have permission to start new threads in this category."
+        })
 
 
         self.override_other_acl({})
         self.override_other_acl({})
 
 
@@ -798,11 +766,9 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
             ]
             ]
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't move thread to the category it's already in."
-        )
+        self.assertEqual(response.json(), {
+            'detail': ["You can't move thread to the category it's already in."]
+        })
 
 
         self.override_other_acl({})
         self.override_other_acl({})
 
 
@@ -888,12 +854,10 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You don't have permission to close this thread."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You don't have permission to close this thread."
+        })
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_closed'])
         self.assertFalse(thread_json['is_closed'])
@@ -917,12 +881,10 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You don't have permission to open this thread."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You don't have permission to open this thread."
+        })
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
         self.assertTrue(thread_json['is_closed'])
@@ -993,10 +955,10 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "This category is closed. You can't approve threads in it.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't approve threads in it."
+        })
 
 
     def test_approve_thread_closed_no_permission(self):
     def test_approve_thread_closed_no_permission(self):
         """api checks permission for approving posts in closed categories"""
         """api checks permission for approving posts in closed categories"""
@@ -1026,10 +988,10 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "This thread is closed. You can't approve it.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't approve it."
+        })
 
 
     def test_unapprove_thread(self):
     def test_unapprove_thread(self):
         """api returns permission error on approval removal"""
         """api returns permission error on approval removal"""
@@ -1044,10 +1006,10 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "Content approval can't be reversed.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "Content approval can't be reversed."
+        })
 
 
 
 
 class ThreadHideApiTests(ThreadPatchApiTestCase):
 class ThreadHideApiTests(ThreadPatchApiTestCase):
@@ -1087,12 +1049,10 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't hide threads in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't hide threads in this category."
+        })
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_hidden'])
         self.assertFalse(thread_json['is_hidden'])
@@ -1113,12 +1073,10 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't hide other users theads in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't hide other users theads in this category."
+        })
 
 
     def test_hide_owned_thread_no_time(self):
     def test_hide_owned_thread_no_time(self):
         """api forbids non-moderator from hiding other users threads"""
         """api forbids non-moderator from hiding other users threads"""
@@ -1141,12 +1099,10 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't hide threads that are older than 1 minute."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't hide threads that are older than 1 minute."
+        })
 
 
     def test_hide_closed_category_no_permission(self):
     def test_hide_closed_category_no_permission(self):
         """api test permission to hide thread in closed category"""
         """api test permission to hide thread in closed category"""
@@ -1167,12 +1123,10 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't hide threads in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't hide threads in it."
+        })
 
 
     def test_hide_closed_thread_no_permission(self):
     def test_hide_closed_thread_no_permission(self):
         """api test permission to hide closed thread"""
         """api test permission to hide closed thread"""
@@ -1193,12 +1147,10 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't hide it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't hide it."
+        })
 
 
 
 
 class ThreadUnhideApiTests(ThreadPatchApiTestCase):
 class ThreadUnhideApiTests(ThreadPatchApiTestCase):
@@ -1265,12 +1217,10 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't reveal threads in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't reveal threads in it."
+        })
 
 
     def test_unhide_closed_thread_no_permission(self):
     def test_unhide_closed_thread_no_permission(self):
         """api test permission to unhide closed thread"""
         """api test permission to unhide closed thread"""
@@ -1291,12 +1241,10 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't reveal it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't reveal it."
+        })
 
 
 
 
 class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
 class ThreadSubscribeApiTests(ThreadPatchApiTestCase):

+ 9 - 12
misago/threads/tests/test_thread_postbulkpatch_api.py

@@ -170,9 +170,7 @@ class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
         })
         })
 
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {'id': self.ids[0], 'detail': ['undefined op']},
-        ])
+        self.assertEqual(response.json(), {'detail': '"op" parameter must be defined.'})
 
 
     def test_anonymous_user(self):
     def test_anonymous_user(self):
         """anonymous users can't use bulk actions"""
         """anonymous users can't use bulk actions"""
@@ -268,15 +266,14 @@ class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
                 ]
                 ]
             }
             }
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        for i, post in enumerate(self.posts):
-            self.assertEqual(response_json[i]['id'], post.id)
-            self.assertEqual(
-                response_json[i]['detail'],
-                ["You can't protect posts in this category."],
-            )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), [
+            {
+                'id': post.id,
+                'status': 403,
+                'detail': "You can't protect posts in this category.",
+            } for post in self.posts
+        ])
 
 
         for post in Post.objects.filter(id__in=self.ids):
         for post in Post.objects.filter(id__in=self.ids):
             self.assertFalse(post.is_protected)
             self.assertFalse(post.is_protected)

+ 3 - 3
misago/threads/tests/test_thread_postedits_api.py

@@ -66,14 +66,14 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
 
 
 class ThreadPostGetEditTests(ThreadPostEditsApiTestCase):
 class ThreadPostGetEditTests(ThreadPostEditsApiTestCase):
     def test_no_edits(self):
     def test_no_edits(self):
-        """api returns 403 if post has no edits record"""
+        """api returns 404 if post has no edits record"""
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "Edits record is unavailable", status_code=403)
+        self.assertEqual(response.status_code, 404)
 
 
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "Edits record is unavailable", status_code=403)
+        self.assertEqual(response.status_code, 404)
 
 
     def test_empty_edit_id(self):
     def test_empty_edit_id(self):
         """api handles empty edit in querystring"""
         """api handles empty edit in querystring"""

+ 125 - 155
misago/threads/tests/test_thread_postpatch_api.py

@@ -27,7 +27,7 @@ class ThreadPostPatchApiTestCase(AuthenticatedUserTestCase):
             kwargs={
             kwargs={
                 'thread_pk': self.thread.pk,
                 'thread_pk': self.thread.pk,
                 'pk': self.post.pk,
                 'pk': self.post.pk,
-            }
+            },
         )
         )
 
 
     def patch(self, api_link, ops):
     def patch(self, api_link, ops):
@@ -141,10 +141,10 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't protect posts in this category.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't protect posts in this category.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_protected)
         self.assertFalse(self.post.is_protected)
@@ -165,10 +165,10 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't protect posts in this category.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't protect posts in this category.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_protected)
         self.assertTrue(self.post.is_protected)
@@ -186,10 +186,10 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't protect posts you can't edit.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't protect posts you can't edit.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_protected)
         self.assertFalse(self.post.is_protected)
@@ -210,10 +210,10 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't protect posts you can't edit.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't protect posts you can't edit.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_protected)
         self.assertTrue(self.post.is_protected)
@@ -257,10 +257,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "Content approval can't be reversed.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "Content approval can't be reversed.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_unapproved)
         self.assertFalse(self.post.is_unapproved)
@@ -281,10 +281,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't approve posts in this category.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't approve posts in this category.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_unapproved)
         self.assertTrue(self.post.is_unapproved)
@@ -311,13 +311,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0],
-            "This thread is closed. You can't approve posts in it.",
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't approve posts in it.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_unapproved)
         self.assertTrue(self.post.is_unapproved)
@@ -344,13 +341,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0],
-            "This category is closed. You can't approve posts in it.",
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't approve posts in it.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_unapproved)
         self.assertTrue(self.post.is_unapproved)
@@ -374,10 +368,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't approve thread's first post.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't approve thread's first post.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_unapproved)
         self.assertTrue(self.post.is_unapproved)
@@ -399,12 +393,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't approve posts the content you can't see."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't approve posts the content you can't see.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_unapproved)
         self.assertTrue(self.post.is_unapproved)
@@ -466,10 +458,10 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't hide posts in this category.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't hide posts in this category.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
         self.assertFalse(self.post.is_hidden)
@@ -490,10 +482,10 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "This post is protected. You can't hide it.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This post is protected. You can't hide it.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
         self.assertFalse(self.post.is_hidden)
@@ -514,12 +506,10 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't hide other users posts in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't hide other users posts in this category.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
         self.assertFalse(self.post.is_hidden)
@@ -540,12 +530,10 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't hide posts that are older than 1 minute."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't hide posts that are older than 1 minute.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
         self.assertFalse(self.post.is_hidden)
@@ -566,12 +554,10 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't hide posts in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't hide posts in it.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
         self.assertFalse(self.post.is_hidden)
@@ -592,12 +578,10 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't hide posts in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't hide posts in it.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
         self.assertFalse(self.post.is_hidden)
@@ -618,10 +602,10 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't hide thread's first post.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't hide thread's first post.",
+        })
 
 
 
 
 class PostUnhideApiTests(ThreadPostPatchApiTestCase):
 class PostUnhideApiTests(ThreadPostPatchApiTestCase):
@@ -698,10 +682,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't reveal posts in this category.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't reveal posts in this category.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
@@ -725,12 +709,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This post is protected. You can't reveal it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This post is protected. You can't reveal it.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
@@ -752,12 +734,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't reveal other users posts in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't reveal other users posts in this category.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
@@ -779,12 +759,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't reveal posts that are older than 1 minute."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't reveal posts that are older than 1 minute.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
@@ -808,12 +786,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't reveal posts in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't reveal posts in it.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
@@ -837,12 +813,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't reveal posts in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't reveal posts in it.",
+        })
 
 
         self.refresh_post()
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
@@ -863,10 +837,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't reveal thread's first post.")
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't reveal thread's first post.",
+        })
 
 
 
 
 class PostLikeApiTests(ThreadPostPatchApiTestCase):
 class PostLikeApiTests(ThreadPostPatchApiTestCase):
@@ -883,7 +857,10 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertContains(response, "You can't like posts in this category.", status_code=400)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't like posts in this category.",
+        })
 
 
     def test_like_no_like_permission(self):
     def test_like_no_like_permission(self):
         """api validates user's permission to see posts likes"""
         """api validates user's permission to see posts likes"""
@@ -898,7 +875,10 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertContains(response, "You can't like posts in this category.", status_code=400)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't like posts in this category.",
+        })
 
 
     def test_like_post(self):
     def test_like_post(self):
         """api adds user like to post"""
         """api adds user like to post"""
@@ -1177,12 +1157,10 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "You can't hide events in this category."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't hide events in this category.",
+        })
 
 
         self.refresh_event()
         self.refresh_event()
         self.assertFalse(self.event.is_hidden)
         self.assertFalse(self.event.is_hidden)
@@ -1206,12 +1184,10 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't hide events in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't hide events in it.",
+        })
 
 
         self.refresh_event()
         self.refresh_event()
         self.assertFalse(self.event.is_hidden)
         self.assertFalse(self.event.is_hidden)
@@ -1235,12 +1211,10 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't hide events in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't hide events in it.",
+        })
 
 
         self.refresh_event()
         self.refresh_event()
         self.assertFalse(self.event.is_hidden)
         self.assertFalse(self.event.is_hidden)
@@ -1288,12 +1262,10 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't reveal events in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This thread is closed. You can't reveal events in it.",
+        })
 
 
         self.refresh_event()
         self.refresh_event()
         self.assertTrue(self.event.is_hidden)
         self.assertTrue(self.event.is_hidden)
@@ -1320,12 +1292,10 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        self.assertEqual(
-            response_json['detail'][0], "This category is closed. You can't reveal events in it."
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This category is closed. You can't reveal events in it.",
+        })
 
 
         self.refresh_event()
         self.refresh_event()
         self.assertTrue(self.event.is_hidden)
         self.assertTrue(self.event.is_hidden)

+ 79 - 43
misago/threads/tests/test_threads_bulkdelete_api.py

@@ -49,7 +49,10 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link)
         response = self.delete(self.api_link)
-        self.assertContains(response, "You have to specify at least one thread to delete.", status_code=403)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'threads': ["You have to specify at least one thread to delete."],
+        })
 
 
     def test_validate_ids(self):
     def test_validate_ids(self):
         """api validates that ids are list of ints"""
         """api validates that ids are list of ints"""
@@ -59,13 +62,32 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link, True)
         response = self.delete(self.api_link, True)
-        self.assertContains(response, "Expected a list of items", status_code=403)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'threads': ['Expected a list of items but got type "bool".'],
+        })
+
+        self.override_acl({
+            'can_hide_own_threads': 2,
+            'can_hide_threads': 2,
+        })
 
 
         response = self.delete(self.api_link, 'abbss')
         response = self.delete(self.api_link, 'abbss')
-        self.assertContains(response, "Expected a list of items", status_code=403)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'threads': ['Expected a list of items but got type "str".'],
+        })
+        
+        self.override_acl({
+            'can_hide_own_threads': 2,
+            'can_hide_threads': 2,
+        })
 
 
         response = self.delete(self.api_link, [1, 2, 3, 'a', 'b', 'x'])
         response = self.delete(self.api_link, [1, 2, 3, 'a', 'b', 'x'])
-        self.assertContains(response, "One or more thread ids received were invalid.", status_code=403)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'threads': ["One or more thread ids received were invalid."],
+        })
 
 
     def test_validate_ids_length(self):
     def test_validate_ids_length(self):
         """api validates that ids are list of ints"""
         """api validates that ids are list of ints"""
@@ -75,11 +97,12 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link, list(range(THREADS_LIMIT + 1)))
         response = self.delete(self.api_link, list(range(THREADS_LIMIT + 1)))
-        self.assertContains(
-            response,
-            "No more than {} threads can be deleted at single time.".format(THREADS_LIMIT),
-            status_code=403,
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'threads': [
+                "No more than {} threads can be deleted at single time.".format(THREADS_LIMIT)
+            ],
+        })
 
 
     def test_validate_thread_visibility(self):
     def test_validate_thread_visibility(self):
         """api valdiates if user can see deleted thread"""
         """api valdiates if user can see deleted thread"""
@@ -96,7 +119,10 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         threads_ids = [p.id for p in self.threads]
         threads_ids = [p.id for p in self.threads]
 
 
         response = self.delete(self.api_link, threads_ids)
         response = self.delete(self.api_link, threads_ids)
-        self.assertContains(response, "threads to delete could not be found", status_code=403)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'threads': ["One or more threads to delete could not be found."],
+        })
 
 
         # no thread was deleted
         # no thread was deleted
         for thread in self.threads:
         for thread in self.threads:
@@ -112,17 +138,20 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         other_thread = self.threads[1]
         other_thread = self.threads[1]
 
 
         response = self.delete(self.api_link, [p.id for p in self.threads])
         response = self.delete(self.api_link, [p.id for p in self.threads])
-
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {
-                'thread': {
-                    'id': other_thread.pk,
-                    'title': other_thread.title
-                },
-                'error': "You can't delete other users theads in this category."
-            }
-        ])
+        self.assertEqual(response.json(), {
+            'threads': {
+                'details': [
+                    {
+                        'thread': {
+                            'id': str(other_thread.pk),
+                            'title': other_thread.title
+                        },
+                        'error': "You can't delete other users theads in this category.",
+                    },
+                ],
+            },
+        })
 
 
         # no threads are removed on failed attempt
         # no threads are removed on failed attempt
         for thread in self.threads:
         for thread in self.threads:
@@ -140,17 +169,20 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link, [p.id for p in self.threads])
         response = self.delete(self.api_link, [p.id for p in self.threads])
-
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {
-                'thread': {
-                    'id': thread.pk,
-                    'title': thread.title
-                },
-                'error': "This category is closed. You can't delete threads in it."
-            } for thread in sorted(self.threads, key=lambda i: i.pk)
-        ])
+        self.assertEqual(response.json(), {
+            'threads': {
+                'details': [
+                    {
+                        'thread': {
+                            'id': str(thread.pk),
+                            'title': thread.title,
+                        },
+                        'error': "This category is closed. You can't delete threads in it.",
+                    } for thread in self.threads
+                ],
+            },
+        })
 
 
     def test_delete_thread_closed_no_permission(self):
     def test_delete_thread_closed_no_permission(self):
         """api tests thread's closed state"""
         """api tests thread's closed state"""
@@ -165,17 +197,20 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link, [p.id for p in self.threads])
         response = self.delete(self.api_link, [p.id for p in self.threads])
-
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {
-                'thread': {
-                    'id': closed_thread.pk,
-                    'title': closed_thread.title
-                },
-                'error': "This thread is closed. You can't delete it."
-            }
-        ])
+        self.assertEqual(response.json(), {
+            'threads': {
+                'details': [
+                    {
+                        'thread': {
+                            'id': str(closed_thread.pk),
+                            'title': closed_thread.title,
+                        },
+                        'error': "This thread is closed. You can't delete it.",
+                    },
+                ],
+            },
+        })
 
 
     def test_delete_private_thread(self):
     def test_delete_private_thread(self):
         """attempt to delete private thread fails"""
         """attempt to delete private thread fails"""
@@ -199,8 +234,9 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         threads_ids = [p.id for p in self.threads]
         threads_ids = [p.id for p in self.threads]
 
 
         response = self.delete(self.api_link, threads_ids)
         response = self.delete(self.api_link, threads_ids)
-
-        self.assertEqual(response.status_code, 403)
-        self.assertContains(response, "threads to delete could not be found", status_code=403)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'threads': ["One or more threads to delete could not be found."],
+        })
 
 
         Thread.objects.get(pk=private_thread.pk)
         Thread.objects.get(pk=private_thread.pk)

+ 17 - 20
misago/threads/tests/test_thread_bulkpatch_api.py → misago/threads/tests/test_threads_bulkpatch_api.py

@@ -132,22 +132,20 @@ class BulkPatchSerializerTests(ThreadsBulkPatchApiTestCase):
             'ops': [{}],
             'ops': [{}],
         })
         })
 
 
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 404)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
-            'detail': "One or more threads to update could not be found.",
+            'detail': "NOT FOUND",
         })
         })
 
 
     def test_ops_invalid(self):
     def test_ops_invalid(self):
         """api validates descriptions"""
         """api validates descriptions"""
         response = self.patch(self.api_link, {
         response = self.patch(self.api_link, {
-            'ids': self.ids[:1],
+            'ids': self.ids,
             'ops': [{}],
             'ops': [{}],
         })
         })
 
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {'id': self.ids[0], 'detail': ['undefined op']},
-        ])
+        self.assertEqual(response.json(), {'detail': '"op" parameter must be defined.'})
 
 
     def test_anonymous_user(self):
     def test_anonymous_user(self):
         """anonymous users can't use bulk actions"""
         """anonymous users can't use bulk actions"""
@@ -182,7 +180,7 @@ class ThreadAddAclApiTests(ThreadsBulkPatchApiTestCase):
             self.assertTrue(response_json[i]['acl'])
             self.assertTrue(response_json[i]['acl'])
 
 
 
 
-class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
+class ThreadsBulkChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
     def test_change_thread_title(self):
     def test_change_thread_title(self):
         """api changes thread title and resyncs the category"""
         """api changes thread title and resyncs the category"""
         self.override_acl({'can_edit_threads': 2})
         self.override_acl({'can_edit_threads': 2})
@@ -230,20 +228,19 @@ class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
                 ]
                 ]
             }
             }
         )
         )
-        self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
-        for i, thread in enumerate(self.threads):
-            self.assertEqual(response_json[i]['id'], thread.id)
-            self.assertEqual(
-                response_json[i]['detail'],
-                ["You can't edit threads in this category."],
-            )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), [
+            {
+                'id': thread_id,
+                'status': 403,
+                'detail': "You can't edit threads in this category.",
+            } for thread_id in sorted(self.ids, reverse=True)
+        ])
 
 
 
 
-class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
+class ThreadsBulkMoveApiTests(ThreadsBulkPatchApiTestCase):
     def setUp(self):
     def setUp(self):
-        super(BulkThreadMoveApiTests, self).setUp()
+        super(ThreadsBulkMoveApiTests, self).setUp()
 
 
         Category(
         Category(
             name='Category B',
             name='Category B',
@@ -321,7 +318,7 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
         self.assertEqual(new_category.threads, 3)
         self.assertEqual(new_category.threads, 3)
 
 
 
 
-class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
+class ThreadsBulksHideApiTests(ThreadsBulkPatchApiTestCase):
     def test_hide_thread(self):
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
         """api makes it possible to hide thread"""
         self.override_acl({'can_hide_threads': 1})
         self.override_acl({'can_hide_threads': 1})
@@ -353,7 +350,7 @@ class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
         self.assertNotIn(category.last_thread_id, self.ids)
         self.assertNotIn(category.last_thread_id, self.ids)
 
 
 
 
-class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
+class ThreadsBulksApproveApiTests(ThreadsBulkPatchApiTestCase):
     def test_approve_thread(self):
     def test_approve_thread(self):
         """api approvse threads and syncs category"""
         """api approvse threads and syncs category"""
         for thread in self.threads:
         for thread in self.threads:

+ 16 - 29
misago/threads/tests/test_threads_merge_api.py

@@ -58,13 +58,11 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
     def test_merge_no_threads(self):
     def test_merge_no_threads(self):
         """api validates if we are trying to merge no threads"""
         """api validates if we are trying to merge no threads"""
         response = self.client.post(self.api_link, content_type="application/json")
         response = self.client.post(self.api_link, content_type="application/json")
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 400)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
-                'detail': "You have to select at least two threads to merge.",
-            }
+            response_json['threads'], ["You have to select at least two threads to merge."]
         )
         )
 
 
     def test_merge_empty_threads(self):
     def test_merge_empty_threads(self):
@@ -76,13 +74,11 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 400)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
-                'detail': "You have to select at least two threads to merge.",
-            }
+            response_json['threads'], ["You have to select at least two threads to merge."]
         )
         )
 
 
     def test_merge_invalid_threads(self):
     def test_merge_invalid_threads(self):
@@ -94,7 +90,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Expected a list of items", status_code=403)
+        self.assertContains(response, "Expected a list of items", status_code=400)
 
 
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
@@ -103,13 +99,11 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 400)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
-                'detail': "One or more thread ids received were invalid.",
-            }
+            response_json['threads'], ["One or more thread ids received were invalid."]
         )
         )
 
 
     def test_merge_single_thread(self):
     def test_merge_single_thread(self):
@@ -121,13 +115,11 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 400)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
-                'detail': "You have to select at least two threads to merge.",
-            }
+            response_json['threads'], ["You have to select at least two threads to merge."]
         )
         )
 
 
     def test_merge_with_nonexisting_thread(self):
     def test_merge_with_nonexisting_thread(self):
@@ -139,13 +131,11 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 400)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
-                'detail': "One or more threads to merge could not be found.",
-            }
+            response_json['threads'], ["One or more threads to merge could not be found."]
         )
         )
 
 
     def test_merge_with_invisible_thread(self):
     def test_merge_with_invisible_thread(self):
@@ -159,13 +149,11 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 400)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
-                'detail': "One or more threads to merge could not be found.",
-            }
+            response_json['threads'], ["One or more threads to merge could not be found."]
         )
         )
 
 
     def test_merge_no_permission(self):
     def test_merge_no_permission(self):
@@ -275,13 +263,12 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 400)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
-                'detail': "No more than %s threads can be merged at single time." % THREADS_LIMIT,
-            }
+            response_json['threads'],
+            ["No more than %s threads can be merged at single time." % THREADS_LIMIT],
         )
         )
 
 
     def test_merge_no_final_thread(self):
     def test_merge_no_final_thread(self):

+ 2 - 2
misago/users/serializers/__init__.py

@@ -2,13 +2,13 @@ from .ban import BanMessageSerializer, BanDetailsSerializer
 from .moderation import ModerateAvatarSerializer, ModerateSignatureSerializer
 from .moderation import ModerateAvatarSerializer, ModerateSignatureSerializer
 from .options import (
 from .options import (
     ForumOptionsSerializer, EditSignatureSerializer, ChangeUsernameSerializer,
     ForumOptionsSerializer, EditSignatureSerializer, ChangeUsernameSerializer,
-    ChangePasswordSerializer, ChangeEmailSerializer
+    ChangePasswordSerializer, ChangeEmailSerializer,
 )
 )
 from .rank import RankSerializer
 from .rank import RankSerializer
 from .user import StatusSerializer, UserCardSerializer, UserSerializer
 from .user import StatusSerializer, UserCardSerializer, UserSerializer
 from .auth import (
 from .auth import (
     AuthenticatedUserSerializer, AnonymousUserSerializer, LoginSerializer,
     AuthenticatedUserSerializer, AnonymousUserSerializer, LoginSerializer,
-    ResendActivationSerializer, SendPasswordFormSerializer, ChangeForgottenPasswordSerializer
+    ResendActivationSerializer, SendPasswordFormSerializer, ChangeForgottenPasswordSerializer,
 )
 )
 from .register import RegisterUserSerializer
 from .register import RegisterUserSerializer
 from .usernamechange import UsernameChangeSerializer
 from .usernamechange import UsernameChangeSerializer