Browse Source

Merge pull request #1131 from rafalp/make-api-response-tests-explicit

Make api response tests more explicit
Rafał Pitoń 6 years ago
parent
commit
c77a7b053e
39 changed files with 1531 additions and 715 deletions
  1. 40 14
      misago/markup/tests/test_api.py
  2. 4 2
      misago/search/tests/test_api.py
  3. 45 13
      misago/threads/tests/test_attachments_api.py
  4. 1 1
      misago/threads/tests/test_events.py
  5. 4 3
      misago/threads/tests/test_floodprotection.py
  6. 95 54
      misago/threads/tests/test_privatethread_patch_api.py
  7. 8 2
      misago/threads/tests/test_privatethread_start_api.py
  8. 16 4
      misago/threads/tests/test_privatethreads_api.py
  9. 36 23
      misago/threads/tests/test_thread_editreply_api.py
  10. 144 52
      misago/threads/tests/test_thread_merge_api.py
  11. 20 5
      misago/threads/tests/test_thread_pollcreate_api.py
  12. 24 8
      misago/threads/tests/test_thread_polldelete_api.py
  13. 24 8
      misago/threads/tests/test_thread_polledit_api.py
  14. 64 18
      misago/threads/tests/test_thread_pollvotes_api.py
  15. 77 30
      misago/threads/tests/test_thread_postbulkdelete_api.py
  16. 48 26
      misago/threads/tests/test_thread_postdelete_api.py
  17. 8 2
      misago/threads/tests/test_thread_postedits_api.py
  18. 8 2
      misago/threads/tests/test_thread_postlikes_api.py
  19. 92 60
      misago/threads/tests/test_thread_postmerge_api.py
  20. 109 59
      misago/threads/tests/test_thread_postmove_api.py
  21. 10 2
      misago/threads/tests/test_thread_postpatch_api.py
  22. 4 1
      misago/threads/tests/test_thread_postread_api.py
  23. 75 41
      misago/threads/tests/test_thread_postsplit_api.py
  24. 20 15
      misago/threads/tests/test_thread_reply_api.py
  25. 37 14
      misago/threads/tests/test_thread_start_api.py
  26. 31 13
      misago/threads/tests/test_threads_bulkdelete_api.py
  27. 72 36
      misago/threads/tests/test_threads_editor_api.py
  28. 48 78
      misago/threads/tests/test_threads_merge_api.py
  29. 16 2
      misago/threads/tests/test_validate_post.py
  30. 114 28
      misago/users/tests/test_auth_api.py
  31. 1 1
      misago/users/tests/test_captcha_api.py
  32. 1 1
      misago/users/tests/test_mention_api.py
  33. 72 20
      misago/users/tests/test_user_avatar_api.py
  34. 8 2
      misago/users/tests/test_user_changeemail_api.py
  35. 63 28
      misago/users/tests/test_user_create_api.py
  36. 13 5
      misago/users/tests/test_user_signature_api.py
  37. 31 24
      misago/users/tests/test_user_username_api.py
  38. 9 5
      misago/users/tests/test_usernamechanges_api.py
  39. 39 13
      misago/users/tests/test_users_api.py

+ 40 - 14
misago/markup/tests/test_api.py

@@ -14,46 +14,72 @@ class ParseMarkupApiTests(AuthenticatedUserTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "This action is not available to guests.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_no_data(self):
     def test_no_data(self):
         """api handles no data"""
         """api handles no data"""
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "You have to enter a message.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to enter a message.",
+        })
 
 
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api handles post that is invalid type"""
         """api handles post that is invalid type"""
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         response = self.client.post(self.api_link, '[]', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got list.",
+        })
 
 
         response = self.client.post(self.api_link, '123', content_type="application/json")
         response = self.client.post(self.api_link, '123', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got int.",
+        })
 
 
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got str.",
+        })
 
 
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
-        self.assertContains(response, "JSON parse error", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
+        })
 
 
     def test_empty_post(self):
     def test_empty_post(self):
         """api handles empty post"""
         """api handles empty post"""
         response = self.client.post(self.api_link, {'post': ''})
         response = self.client.post(self.api_link, {'post': ''})
-        self.assertContains(response, "You have to enter a message.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to enter a message.",
+        })
 
 
         # regression test for #929
         # regression test for #929
         response = self.client.post(self.api_link, {'post': '\n'})
         response = self.client.post(self.api_link, {'post': '\n'})
-        self.assertContains(response, "You have to enter a message.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to enter a message.",
+        })
 
 
     def test_invalid_post(self):
     def test_invalid_post(self):
         """api handles invalid post type"""
         """api handles invalid post type"""
         response = self.client.post(self.api_link, {'post': 123})
         response = self.client.post(self.api_link, {'post': 123})
-        self.assertContains(
-            response,
-            "Posted message should be at least 5 characters long (it has 3).",
-            status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Posted message should be at least 5 characters long (it has 3).",
+        })
 
 
     def test_valid_post(self):
     def test_valid_post(self):
         """api returns parsed markup for valid post"""
         """api returns parsed markup for valid post"""
         response = self.client.post(self.api_link, {'post': 'Lorem ipsum dolor met!'})
         response = self.client.post(self.api_link, {'post': 'Lorem ipsum dolor met!'})
-        self.assertContains(response, "<p>Lorem ipsum dolor met!</p>")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            "parsed": "<p>Lorem ipsum dolor met!</p>",
+        })

+ 4 - 2
misago/search/tests/test_api.py

@@ -16,8 +16,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         override_acl(self.user, {'can_search': 0})
         override_acl(self.user, {'can_search': 0})
 
 
         response = self.client.get(self.test_link)
         response = self.client.get(self.test_link)
-
-        self.assertContains(response, "have permission to search site", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You don't have permission to search site."
+        })
 
 
     def test_no_phrase(self):
     def test_no_phrase(self):
         """api handles no search query"""
         """api handles no search query"""

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

@@ -45,12 +45,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(), {
+            'detail': "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"""
@@ -66,7 +72,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(), {
+                'detail': "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"""
@@ -82,7 +91,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(), {
+                'detail': "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"""
@@ -101,7 +113,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(), {
+                'detail': "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"""
@@ -118,7 +133,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(), {
+                'detail': "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"""
@@ -135,7 +153,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(), {
+                'detail': "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"""
@@ -152,10 +173,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(), {
+                'detail': (
+                    "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"""
@@ -173,7 +197,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(), {
+                'detail': (
+                    "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"""
@@ -188,7 +217,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(), {
+                'detail': "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"""

+ 1 - 1
misago/threads/tests/test_events.py

@@ -17,7 +17,7 @@ class MockRequest(object):
         self.user_ip = '123.14.15.222'
         self.user_ip = '123.14.15.222'
 
 
 
 
-class EventsAPITests(TestCase):
+class EventsApiTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.user = UserModel.objects.create_user("Bob", "bob@bob.com", "Pass.123")
         self.user = UserModel.objects.create_user("Bob", "bob@bob.com", "Pass.123")
 
 

+ 4 - 3
misago/threads/tests/test_floodprotection.py

@@ -45,6 +45,7 @@ class PostMentionsTests(AuthenticatedUserTestCase):
                 'post': "This is test response!",
                 'post': "This is test response!",
             }
             }
         )
         )
-        self.assertContains(
-            response, "You can't post message so quickly after previous one.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't post message so quickly after previous one."
+        })

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

@@ -42,10 +42,11 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "be thread owner to add new participants to it", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +61,11 @@ 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(), {
+            'id': self.thread.pk,
+            '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 +80,11 @@ 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(), {
+            'id': self.thread.pk,
+            '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 +99,11 @@ 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(), {
+            'id': self.thread.pk,
+            '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 +119,11 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "BobBoberson is blocking you.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +140,11 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "BobBoberson can't participate", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +165,11 @@ 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(), {
+            'id': self.thread.pk,
+            '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 +187,11 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "Only moderators can add participants to closed threads.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +292,11 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "A valid integer is required.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            'detail': ["A valid integer is required."],
+        })
 
 
     def test_remove_invalid(self):
     def test_remove_invalid(self):
         """api validates user id type"""
         """api validates user id type"""
@@ -292,8 +311,11 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "A valid integer is required.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            'detail': ["A valid integer is required."],
+        })
 
 
     def test_remove_nonexistant(self):
     def test_remove_nonexistant(self):
         """removed user has to be participant"""
         """removed user has to be participant"""
@@ -308,8 +330,11 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +350,11 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "be thread owner to remove participants from it", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +373,11 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "moderators can remove participants from closed threads", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +604,11 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "A valid integer is required.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            'detail': ["A valid integer is required."],
+        })
 
 
     def test_invalid_user_id(self):
     def test_invalid_user_id(self):
         """api handles invalid user id"""
         """api handles invalid user id"""
@@ -593,8 +623,11 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "A valid integer is required.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            'detail': ["A valid integer is required."],
+        })
 
 
     def test_nonexistant_user_id(self):
     def test_nonexistant_user_id(self):
         """api handles nonexistant user id"""
         """api handles nonexistant user id"""
@@ -609,8 +642,11 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +662,11 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "thread owner and moderators can change threads owners", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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 +682,11 @@ 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(), {
+            'id': self.thread.pk,
+            '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 +705,11 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-
-        self.assertContains(
-            response, "Only moderators can change closed threads owners.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.pk,
+            '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"""

+ 8 - 2
misago/threads/tests/test_privatethread_start_api.py

@@ -35,14 +35,20 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         override_acl(self.user, {'can_use_private_threads': 0})
         override_acl(self.user, {'can_use_private_threads': 0})
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "You can't use private threads.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't use private threads.",
+        })
 
 
     def test_cant_start_private_thread(self):
     def test_cant_start_private_thread(self):
         """permission to start private thread is validated"""
         """permission to start private thread is validated"""
         override_acl(self.user, {'can_start_private_threads': 0})
         override_acl(self.user, {'can_start_private_threads': 0})
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "You can't start private threads.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't start private threads.",
+        })
 
 
     def test_empty_data(self):
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
         """no data sent handling has no showstoppers"""

+ 16 - 4
misago/threads/tests/test_privatethreads_api.py

@@ -18,14 +18,20 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "sign in to use private threads", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You have to sign in to use private threads."
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """api requires user to have permission to be able to access it"""
         """api requires user to have permission to be able to access it"""
         override_acl(self.user, {'can_use_private_threads': 0})
         override_acl(self.user, {'can_use_private_threads': 0})
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "can't use private threads", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't use private threads."
+        })
 
 
     def test_empty_list(self):
     def test_empty_list(self):
         """api has no showstoppers on returning empty list"""
         """api has no showstoppers on returning empty list"""
@@ -79,14 +85,20 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "sign in to use private threads", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You have to sign in to use private threads."
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """user needs to have permission to see private thread"""
         """user needs to have permission to see private thread"""
         override_acl(self.user, {'can_use_private_threads': 0})
         override_acl(self.user, {'can_use_private_threads': 0})
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "t use private threads", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't use private threads."
+        })
 
 
     def test_no_participant(self):
     def test_no_participant(self):
         """user cant see thread he isn't part of"""
         """user cant see thread he isn't part of"""

+ 36 - 23
misago/threads/tests/test_thread_editreply_api.py

@@ -72,7 +72,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.override_acl({'can_edit_posts': 0})
         self.override_acl({'can_edit_posts': 0})
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(response, "You can't edit posts in this category.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't edit posts in this category.",
+        })
 
 
     def test_cant_edit_other_user_reply(self):
     def test_cant_edit_other_user_reply(self):
         """permission to edit reply by other users is validated"""
         """permission to edit reply by other users is validated"""
@@ -82,9 +85,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(
-            response, "You can't edit other users posts in this category.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't edit other users posts in this category.",
+        })
 
 
     def test_edit_too_old(self):
     def test_edit_too_old(self):
         """permission to edit reply within timelimit is validated"""
         """permission to edit reply within timelimit is validated"""
@@ -97,9 +101,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(
-            response, "You can't edit posts that are older than 1 minute.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't edit posts that are older than 1 minute.",
+        })
 
 
     def test_closed_category(self):
     def test_closed_category(self):
         """permssion to edit reply in closed category is validated"""
         """permssion to edit reply in closed category is validated"""
@@ -109,9 +114,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(
-            response, "This category is closed. You can't edit posts in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't edit posts in it.",
+        })
 
 
         # allow to post in closed category
         # allow to post in closed category
         self.override_acl({'can_close_threads': 1})
         self.override_acl({'can_close_threads': 1})
@@ -127,9 +133,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(
-            response, "This thread is closed. You can't edit posts in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't edit posts in it.",
+        })
 
 
         # allow to post in closed thread
         # allow to post in closed thread
         self.override_acl({'can_close_threads': 1})
         self.override_acl({'can_close_threads': 1})
@@ -145,9 +152,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(
-            response, "This post is protected. You can't edit it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This post is protected. You can't edit it.",
+        })
 
 
         # allow to post in closed thread
         # allow to post in closed thread
         self.override_acl({'can_protect_posts': 1})
         self.override_acl({'can_protect_posts': 1})
@@ -160,8 +168,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.override_acl()
         self.override_acl()
 
 
         response = self.put(self.api_link, data={})
         response = self.put(self.api_link, data={})
-
-        self.assertContains(response, "You have to enter a message.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "post": ["You have to enter a message."],
+        })
 
 
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api errors for invalid request data"""
         """api errors for invalid request data"""
@@ -172,8 +182,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
             'false',
             'false',
             content_type="application/json",
             content_type="application/json",
         )
         )
-
-        self.assertContains(response, "Invalid data.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "non_field_errors": ["Invalid data. Expected a dictionary, but got bool."]
+        })
 
 
     def test_edit_event(self):
     def test_edit_event(self):
         """events can't be edited"""
         """events can't be edited"""
@@ -183,8 +195,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.put(self.api_link, data={})
         response = self.put(self.api_link, data={})
-
-        self.assertContains(response, "Events can't be edited.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "Events can't be edited.",
+        })
 
 
     def test_post_is_validated(self):
     def test_post_is_validated(self):
         """post is validated"""
         """post is validated"""
@@ -195,7 +209,6 @@ class EditReplyTests(AuthenticatedUserTestCase):
                 'post': "a",
                 'post': "a",
             }
             }
         )
         )
-
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
         self.assertEqual(
             response.json(), {
             response.json(), {

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

@@ -67,18 +67,20 @@ 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.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't merge threads in this category."
+        })
 
 
     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(), {
+            "detail": "Enter link to new thread."
+        })
 
 
     def test_invalid_url(self):
     def test_invalid_url(self):
         """api validates thread url"""
         """api validates thread url"""
@@ -87,7 +89,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'other_thread': self.user.get_absolute_url(),
             'other_thread': self.user.get_absolute_url(),
         })
         })
-        self.assertContains(response, "This is not a valid thread link.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "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 +103,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(), {
+            "detail": "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"""
@@ -112,9 +120,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(), {
+            "detail": (
+                "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):
         """api validates if other thread is visible"""
         """api validates if other thread is visible"""
@@ -128,9 +140,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(), {
+            "detail": (
+                "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):
         """api validates if other thread can be merged"""
         """api validates if other thread can be merged"""
@@ -144,10 +160,10 @@ 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(), {
+            "detail": "Other thread can't be merged with."
+        })
 
 
     def test_thread_category_is_closed(self):
     def test_thread_category_is_closed(self):
         """api validates if thread's category is open"""
         """api validates if thread's category is open"""
@@ -169,11 +185,10 @@ 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):
         """api validates if thread is open"""
         """api validates if thread is open"""
@@ -195,11 +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):
         """api validates if other thread's category is open"""
         """api validates if other thread's category is open"""
@@ -221,9 +235,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(), {
+            "detail": "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):
         """api validates if other thread is open"""
         """api validates if other thread is open"""
@@ -245,9 +260,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(), {
+            "detail": "Other thread is closed and can't be merged with."
+        })
 
 
     def test_other_thread_isnt_replyable(self):
     def test_other_thread_isnt_replyable(self):
         """api validates if other thread can be replied, which is condition for merge"""
         """api validates if other thread can be replied, which is condition for merge"""
@@ -265,9 +281,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(), {
+            "detail": "You can't merge this thread into thread you can't reply."
+        })
 
 
     def test_merge_threads(self):
     def test_merge_threads(self):
         """api merges two threads successfully"""
         """api merges two threads successfully"""
@@ -281,7 +298,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has two posts and an event now
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -305,7 +327,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # posts reads are kept
         # posts reads are kept
         postreads = self.user.postread_set.filter(post__is_event=False).order_by('id')
         postreads = self.user.postread_set.filter(post__is_event=False).order_by('id')
@@ -340,7 +367,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # subscriptions are kept
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -370,7 +402,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # subscriptions are kept
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -409,7 +446,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # subscriptions are kept
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -431,7 +473,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has three posts and an event now
         # other thread has three posts and an event now
         self.assertEqual(other_thread.post_set.count(), 4)
         self.assertEqual(other_thread.post_set.count(), 4)
@@ -460,7 +507,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has three posts and an event now
         # other thread has three posts and an event now
         self.assertEqual(other_thread.post_set.count(), 4)
         self.assertEqual(other_thread.post_set.count(), 4)
@@ -558,7 +610,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'best_answer': 0,
                 'best_answer': 0,
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has four posts and an event now
         # other thread has four posts and an event now
         self.assertEqual(other_thread.post_set.count(), 5)
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -590,7 +647,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'best_answer': self.thread.pk,
                 'best_answer': self.thread.pk,
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has four posts and an event now
         # other thread has four posts and an event now
         self.assertEqual(other_thread.post_set.count(), 5)
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -622,7 +684,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'best_answer': other_thread.pk,
                 'best_answer': other_thread.pk,
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has four posts and an event now
         # other thread has four posts and an event now
         self.assertEqual(other_thread.post_set.count(), 5)
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -648,7 +715,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has two posts and an event now
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -674,7 +746,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'other_thread': other_thread.get_absolute_url(),
                 'other_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has two posts and an event now
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -760,7 +837,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'poll': 0,
                 'poll': 0,
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has two posts and an event now
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -788,7 +870,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'poll': poll.pk,
                 'poll': poll.pk,
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has two posts and an event now
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -823,7 +910,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 'poll': other_poll.pk,
                 'poll': other_poll.pk,
             }
             }
         )
         )
-        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': other_thread.id,
+            'title': other_thread.title,
+            'url': other_thread.get_absolute_url(),
+        })
 
 
         # other thread has two posts and an event now
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
         self.assertEqual(other_thread.post_set.count(), 3)

+ 20 - 5
misago/threads/tests/test_thread_pollcreate_api.py

@@ -41,7 +41,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.override_acl({'can_start_polls': 0})
         self.override_acl({'can_start_polls': 0})
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "can't start polls", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't start polls."
+        })
 
 
     def test_no_permission_closed_thread(self):
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to start poll in closed thread"""
         """api validates that user has permission to start poll in closed thread"""
@@ -51,7 +54,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "thread is closed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't start polls in it."
+        })
 
 
         self.override_acl(category={'can_close_threads': 1})
         self.override_acl(category={'can_close_threads': 1})
 
 
@@ -66,7 +72,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "category is closed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't start polls in it."
+        })
 
 
         self.override_acl(category={'can_close_threads': 1})
         self.override_acl(category={'can_close_threads': 1})
 
 
@@ -81,7 +90,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "can't start polls in other users threads", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't start polls in other users threads."
+        })
 
 
         self.override_acl({'can_start_polls': 2})
         self.override_acl({'can_start_polls': 2})
 
 
@@ -106,7 +118,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         )
         )
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "There's already a poll in this thread.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "There's already a poll in this thread."
+        })
 
 
     def test_empty_data(self):
     def test_empty_data(self):
         """api handles empty request data"""
         """api handles empty request data"""

+ 24 - 8
misago/threads/tests/test_thread_polldelete_api.py

@@ -78,7 +78,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.override_acl({'can_delete_polls': 0})
         self.override_acl({'can_delete_polls': 0})
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "can't delete polls", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete polls."
+        })
 
 
     def test_no_permission_timeout(self):
     def test_no_permission_timeout(self):
         """api validates that user's window to delete poll in thread has closed"""
         """api validates that user's window to delete poll in thread has closed"""
@@ -88,9 +91,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.poll.save()
         self.poll.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(
-            response, "can't delete polls that are older than 5 minutes", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete polls that are older than 5 minutes."
+        })
 
 
     def test_no_permission_poll_closed(self):
     def test_no_permission_poll_closed(self):
         """api validates that user's window to delete poll in thread has closed"""
         """api validates that user's window to delete poll in thread has closed"""
@@ -101,7 +105,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.poll.save()
         self.poll.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "This poll is over", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This poll is over. You can't delete it."
+        })
 
 
     def test_no_permission_other_user_poll(self):
     def test_no_permission_other_user_poll(self):
         """api validates that user has permission to delete other user poll in thread"""
         """api validates that user has permission to delete other user poll in thread"""
@@ -111,7 +118,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.poll.save()
         self.poll.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "can't delete other users polls", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete other users polls in this category."
+        })
 
 
     def test_no_permission_closed_thread(self):
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to delete poll in closed thread"""
         """api validates that user has permission to delete poll in closed thread"""
@@ -121,7 +131,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "thread is closed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't delete polls in it."
+        })
 
 
         self.override_acl(category={'can_close_threads': 1})
         self.override_acl(category={'can_close_threads': 1})
 
 
@@ -136,7 +149,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "category is closed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't delete polls in it."
+        })
 
 
         self.override_acl(category={'can_close_threads': 1})
         self.override_acl(category={'can_close_threads': 1})
 
 

+ 24 - 8
misago/threads/tests/test_thread_polledit_api.py

@@ -78,7 +78,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.override_acl({'can_edit_polls': 0})
         self.override_acl({'can_edit_polls': 0})
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(response, "can't edit polls", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't edit polls.",
+        })
 
 
     def test_no_permission_timeout(self):
     def test_no_permission_timeout(self):
         """api validates that user's window to edit poll in thread has closed"""
         """api validates that user's window to edit poll in thread has closed"""
@@ -88,9 +91,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.poll.save()
         self.poll.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(
-            response, "can't edit polls that are older than 5 minutes", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't edit polls that are older than 5 minutes.",
+        })
 
 
     def test_no_permission_poll_closed(self):
     def test_no_permission_poll_closed(self):
         """api validates that user's window to edit poll in thread has closed"""
         """api validates that user's window to edit poll in thread has closed"""
@@ -101,7 +105,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.poll.save()
         self.poll.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(response, "This poll is over", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This poll is over. You can't edit it.",
+        })
 
 
     def test_no_permission_other_user_poll(self):
     def test_no_permission_other_user_poll(self):
         """api validates that user has permission to edit other user poll in thread"""
         """api validates that user has permission to edit other user poll in thread"""
@@ -111,7 +118,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.poll.save()
         self.poll.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(response, "can't edit other users polls", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't edit other users polls in this category.",
+        })
 
 
     def test_no_permission_closed_thread(self):
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to edit poll in closed thread"""
         """api validates that user has permission to edit poll in closed thread"""
@@ -121,7 +131,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(response, "thread is closed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't edit polls in it.",
+        })
 
 
         self.override_acl(category={'can_close_threads': 1})
         self.override_acl(category={'can_close_threads': 1})
 
 
@@ -136,7 +149,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.put(self.api_link)
         response = self.put(self.api_link)
-        self.assertContains(response, "category is closed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't edit polls in it.",
+        })
 
 
         self.override_acl(category={'can_close_threads': 1})
         self.override_acl(category={'can_close_threads': 1})
 
 

+ 64 - 18
misago/threads/tests/test_thread_pollvotes_api.py

@@ -190,37 +190,58 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         response = self.client.post(
         response = self.client.post(
             self.api_link, '[]', content_type='application/json'
             self.api_link, '[]', content_type='application/json'
         )
         )
-        self.assertContains(response, "You have to make a choice.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to make a choice.",
+        })
 
 
     def test_empty_vote_form(self):
     def test_empty_vote_form(self):
         """api validates if vote that user has made was empty"""
         """api validates if vote that user has made was empty"""
         self.delete_user_votes()
         self.delete_user_votes()
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "You have to make a choice.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to make a choice.",
+        })
 
 
     def test_malformed_vote(self):
     def test_malformed_vote(self):
         """api validates if vote that user has made was correctly structured"""
         """api validates if vote that user has made was correctly structured"""
         self.delete_user_votes()
         self.delete_user_votes()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "dict".',
+        })
 
 
         response = self.post(self.api_link, data={})
         response = self.post(self.api_link, data={})
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "dict".',
+        })
 
 
         response = self.post(self.api_link, data='hello')
         response = self.post(self.api_link, data='hello')
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "str".',
+        })
 
 
         response = self.post(self.api_link, data=123)
         response = self.post(self.api_link, data=123)
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "int".',
+        })
 
 
     def test_invalid_choices(self):
     def test_invalid_choices(self):
         """api validates if vote that user has made overlaps with allowed votes"""
         """api validates if vote that user has made overlaps with allowed votes"""
         self.delete_user_votes()
         self.delete_user_votes()
 
 
         response = self.post(self.api_link, data=['lorem', 'ipsum'])
         response = self.post(self.api_link, data=['lorem', 'ipsum'])
-        self.assertContains(response, "One or more of poll choices were invalid.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more of poll choices were invalid.",
+        })
 
 
     def test_too_many_choices(self):
     def test_too_many_choices(self):
         """api validates if vote that user has made overlaps with allowed votes"""
         """api validates if vote that user has made overlaps with allowed votes"""
@@ -229,19 +250,26 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.poll.save()
         self.poll.save()
 
 
         response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
         response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
-        self.assertContains(
-            response, "This poll disallows voting for more than 1 choice.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This poll disallows voting for more than 1 choice.",
+        })
 
 
     def test_revote(self):
     def test_revote(self):
         """api validates if user is trying to change vote in poll that disallows revoting"""
         """api validates if user is trying to change vote in poll that disallows revoting"""
         response = self.post(self.api_link, data=['lorem', 'ipsum'])
         response = self.post(self.api_link, data=['lorem', 'ipsum'])
-        self.assertContains(response, "You have already voted in this poll.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You have already voted in this poll.",
+        })
 
 
         self.delete_user_votes()
         self.delete_user_votes()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "dict".',
+        })
 
 
     def test_vote_in_closed_thread(self):
     def test_vote_in_closed_thread(self):
         """api validates is user has permission to vote poll in closed thread"""
         """api validates is user has permission to vote poll in closed thread"""
@@ -253,12 +281,18 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.delete_user_votes()
         self.delete_user_votes()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "thread is closed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't vote in it.",
+        })
 
 
         self.override_acl(category={'can_close_threads': 1})
         self.override_acl(category={'can_close_threads': 1})
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "dict".',
+        })
 
 
     def test_vote_in_closed_category(self):
     def test_vote_in_closed_category(self):
         """api validates is user has permission to vote poll in closed category"""
         """api validates is user has permission to vote poll in closed category"""
@@ -270,12 +304,18 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.delete_user_votes()
         self.delete_user_votes()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "category is closed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't vote in it.",
+        })
 
 
         self.override_acl(category={'can_close_threads': 1})
         self.override_acl(category={'can_close_threads': 1})
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "dict".',
+        })
 
 
     def test_vote_in_finished_poll(self):
     def test_vote_in_finished_poll(self):
         """api valdiates if poll has finished before letting user to vote in it"""
         """api valdiates if poll has finished before letting user to vote in it"""
@@ -286,13 +326,19 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.delete_user_votes()
         self.delete_user_votes()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "This poll is over. You can't vote in it.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This poll is over. You can't vote in it.",
+        })
 
 
         self.poll.length = 50
         self.poll.length = 50
         self.poll.save()
         self.poll.save()
 
 
         response = self.post(self.api_link)
         response = self.post(self.api_link)
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "dict".',
+        })
 
 
     def test_fresh_vote(self):
     def test_fresh_vote(self):
         """api handles first vote in poll"""
         """api handles first vote in poll"""

+ 77 - 30
misago/threads/tests/test_thread_postbulkdelete_api.py

@@ -35,22 +35,34 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.delete(self.api_link)
         response = self.delete(self.api_link)
-        self.assertContains(response, "This action is not available to guests.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_delete_no_data(self):
     def test_delete_no_data(self):
         """api handles empty data"""
         """api handles empty data"""
         response = self.client.delete(self.api_link, content_type="application/json")
         response = self.client.delete(self.api_link, content_type="application/json")
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "dict".',
+        })
 
 
     def test_delete_no_ids(self):
     def test_delete_no_ids(self):
         """api requires ids to delete"""
         """api requires ids to delete"""
         response = self.delete(self.api_link)
         response = self.delete(self.api_link)
-        self.assertContains(response, "You have to specify at least one post to delete.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to specify at least one post to delete.",
+        })
 
 
     def test_delete_empty_ids(self):
     def test_delete_empty_ids(self):
         """api requires ids to delete"""
         """api requires ids to delete"""
         response = self.delete(self.api_link, [])
         response = self.delete(self.api_link, [])
-        self.assertContains(response, "You have to specify at least one post to delete.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to specify at least one post 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"""
@@ -60,13 +72,22 @@ class PostBulkDeleteApiTests(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=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "bool".',
+        })
 
 
         response = self.delete(self.api_link, 'abbss')
         response = self.delete(self.api_link, 'abbss')
-        self.assertContains(response, "Expected a list of items", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "str".',
+        })
 
 
         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 post ids received were invalid.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more post 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"""
@@ -76,7 +97,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link, list(range(100)))
         response = self.delete(self.api_link, list(range(100)))
-        self.assertContains(response, "No more than 24 posts can be deleted at single time.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "No more than 24 posts can be deleted at single time.",
+        })
 
 
     def test_validate_posts_exist(self):
     def test_validate_posts_exist(self):
         """api validates that ids are visible posts"""
         """api validates that ids are visible posts"""
@@ -86,7 +110,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link, [p.id * 10 for p in self.posts])
         response = self.delete(self.api_link, [p.id * 10 for p in self.posts])
-        self.assertContains(response, "One or more posts to delete could not be found.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to delete could not be found.",
+        })
 
 
     def test_validate_posts_visibility(self):
     def test_validate_posts_visibility(self):
         """api validates that ids are visible posts"""
         """api validates that ids are visible posts"""
@@ -99,7 +126,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts[1].save()
         self.posts[1].save()
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(response, "One or more posts to delete could not be found.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to delete could not be found.",
+        })
 
 
     def test_validate_posts_same_thread(self):
     def test_validate_posts_same_thread(self):
         """api validates that ids are same thread posts"""
         """api validates that ids are same thread posts"""
@@ -112,7 +142,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts.append(testutils.reply_thread(other_thread, poster=self.user))
         self.posts.append(testutils.reply_thread(other_thread, poster=self.user))
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(response, "One or more posts to delete could not be found.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to delete could not be found.",
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to delete"""
         """api validates permission to delete"""
@@ -122,7 +155,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(response, "You can't delete posts in this category.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete posts in this category.",
+        })
 
 
     def test_delete_other_user_post_no_permission(self):
     def test_delete_other_user_post_no_permission(self):
         """api valdiates if user can delete other users posts"""
         """api valdiates if user can delete other users posts"""
@@ -133,9 +169,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(
-            response, "You can't delete other users posts in this category", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete other users posts in this category.",
+        })
 
 
     def test_delete_protected_post_no_permission(self):
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
         """api validates if user can delete protected post"""
@@ -149,9 +186,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts[0].save()
         self.posts[0].save()
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(
-            response, "This post is protected. You can't delete it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This post is protected. You can't delete it.",
+        })
 
 
     def test_delete_protected_post_after_edit_time(self):
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
         """api validates if user can delete delete post after edit time"""
@@ -165,9 +203,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts[0].save()
         self.posts[0].save()
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(
-            response, "You can't delete posts that are older than 1 minute.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete posts that are older than 1 minute.",
+        })
 
 
     def test_delete_post_closed_thread_no_permission(self):
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
         """api valdiates if user can delete posts in closed threads"""
@@ -180,9 +219,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(
-            response, "This thread is closed. You can't delete posts in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't delete posts in it.",
+        })
 
 
     def test_delete_post_closed_category_no_permission(self):
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
         """api valdiates if user can delete posts in closed categories"""
@@ -195,9 +235,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(
-            response, "This category is closed. You can't delete posts in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't delete posts in it.",
+        })
 
 
     def test_delete_first_post(self):
     def test_delete_first_post(self):
         """api disallows first post's deletion"""
         """api disallows first post's deletion"""
@@ -210,7 +251,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         ids.append(self.thread.first_post_id)
         ids.append(self.thread.first_post_id)
 
 
         response = self.delete(self.api_link, ids)
         response = self.delete(self.api_link, ids)
-        self.assertContains(response, "You can't delete thread's first post.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete thread's first post.",
+        })
 
 
     def test_delete_best_answer(self):
     def test_delete_best_answer(self):
         """api disallows best answer deletion"""
         """api disallows best answer deletion"""
@@ -222,7 +266,7 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
-            'detail': "You can't delete this post because its marked as best answer.",
+            "detail": "You can't delete this post because its marked as best answer.",
         })
         })
 
 
     def test_delete_event(self):
     def test_delete_event(self):
@@ -237,7 +281,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts[1].save()
         self.posts[1].save()
 
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         response = self.delete(self.api_link, [p.id for p in self.posts])
-        self.assertContains(response, "You can't delete events in this category.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete events in this category.",
+        })
 
 
     def test_delete_owned_posts(self):
     def test_delete_owned_posts(self):
         """api deletes owned thread posts"""
         """api deletes owned thread posts"""

+ 48 - 26
misago/threads/tests/test_thread_postdelete_api.py

@@ -28,14 +28,20 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "This action is not available to guests.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to delete post"""
         """api validates permission to delete post"""
         self.override_acl({'can_hide_own_posts': 1, 'can_hide_posts': 1})
         self.override_acl({'can_hide_own_posts': 1, 'can_hide_posts': 1})
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "You can't delete posts in this category.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete posts in this category.",
+        })
 
 
     def test_delete_other_user_post_no_permission(self):
     def test_delete_other_user_post_no_permission(self):
         """api valdiates if user can delete other users posts"""
         """api valdiates if user can delete other users posts"""
@@ -49,9 +55,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(
-            response, "You can't delete other users posts in this category", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete other users posts in this category.",
+        })
 
 
     def test_delete_protected_post_no_permission(self):
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
         """api validates if user can delete protected post"""
@@ -65,9 +72,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(
-            response, "This post is protected. You can't delete it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This post is protected. You can't delete it.",
+        })
 
 
     def test_delete_protected_post_after_edit_time(self):
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
         """api validates if user can delete delete post after edit time"""
@@ -81,9 +89,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(
-            response, "You can't delete posts that are older than 1 minute.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete posts that are older than 1 minute.",
+        })
 
 
     def test_delete_post_closed_thread_no_permission(self):
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
         """api valdiates if user can delete posts in closed threads"""
@@ -96,9 +105,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(
-            response, "This thread is closed. You can't delete posts in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't delete posts in it.",
+        })
 
 
     def test_delete_post_closed_category_no_permission(self):
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
         """api valdiates if user can delete posts in closed categories"""
@@ -111,9 +121,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(
-            response, "This category is closed. You can't delete posts in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't delete posts in it.",
+        })
 
 
     def test_delete_first_post(self):
     def test_delete_first_post(self):
         """api disallows first post deletion"""
         """api disallows first post deletion"""
@@ -128,7 +139,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         )
         )
 
 
         response = self.client.delete(api_link)
         response = self.client.delete(api_link)
-        self.assertContains(response, "You can't delete thread's first post.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete thread's first post.",
+        })
 
 
     def test_delete_best_answer(self):
     def test_delete_best_answer(self):
         """api disallows best answer deletion"""
         """api disallows best answer deletion"""
@@ -193,7 +207,10 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "This action is not available to guests.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to delete event"""
         """api validates permission to delete event"""
@@ -204,7 +221,10 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         })
         })
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "You can't delete events in this category.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't delete events in this category.",
+        })
 
 
     def test_delete_event_closed_thread_no_permission(self):
     def test_delete_event_closed_thread_no_permission(self):
         """api valdiates if user can delete events in closed threads"""
         """api valdiates if user can delete events in closed threads"""
@@ -217,9 +237,10 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(
-            response, "This thread is closed. You can't delete events in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't delete events in it.",
+        })
 
 
     def test_delete_event_closed_category_no_permission(self):
     def test_delete_event_closed_category_no_permission(self):
         """api valdiates if user can delete events in closed categories"""
         """api valdiates if user can delete events in closed categories"""
@@ -232,9 +253,10 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
-        self.assertContains(
-            response, "This category is closed. You can't delete events in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't delete events in it.",
+        })
 
 
     def test_delete_event(self):
     def test_delete_event(self):
         """api differs posts from events"""
         """api differs posts from events"""

+ 8 - 2
misago/threads/tests/test_thread_postedits_api.py

@@ -62,12 +62,18 @@ class ThreadPostGetEditTests(ThreadPostEditsApiTestCase):
     def test_no_edits(self):
     def test_no_edits(self):
         """api returns 403 if post has no edits record"""
         """api returns 403 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, 403)
+        self.assertEqual(response.json(), {
+            "detail": "Edits record is unavailable for this post."
+        })
 
 
         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, 403)
+        self.assertEqual(response.json(), {
+            "detail": "Edits record is unavailable for this post."
+        })
 
 
     def test_empty_edit_id(self):
     def test_empty_edit_id(self):
         """api handles empty edit in querystring"""
         """api handles empty edit in querystring"""

+ 8 - 2
misago/threads/tests/test_thread_postlikes_api.py

@@ -25,14 +25,20 @@ class ThreadPostLikesApiTestCase(ThreadsApiTestCase):
         self.override_acl({'can_see_posts_likes': 0})
         self.override_acl({'can_see_posts_likes': 0})
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You can't see who liked this post.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEquals(response.json(), {
+            "detail": "You can't see who liked this post."
+        })
 
 
     def test_no_permission_to_list(self):
     def test_no_permission_to_list(self):
         """api errors if user has no permission to see likes, but can see likes count"""
         """api errors if user has no permission to see likes, but can see likes count"""
         self.override_acl({'can_see_posts_likes': 1})
         self.override_acl({'can_see_posts_likes': 1})
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You can't see who liked this post.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEquals(response.json(), {
+            "detail": "You can't see who liked this post."
+        })
 
 
     def test_no_likes(self):
     def test_no_likes(self):
         """api returns empty list if post has no likes"""
         """api returns empty list if post has no likes"""

+ 92 - 60
misago/threads/tests/test_thread_postmerge_api.py

@@ -57,6 +57,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to merge"""
         """api validates permission to merge"""
@@ -67,42 +70,58 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             json.dumps({}),
             json.dumps({}),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "You can't merge posts in this thread.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't merge posts in this thread.",
+        })
 
 
     def test_empty_data_json(self):
     def test_empty_data_json(self):
         """api handles empty json data"""
         """api handles empty json data"""
         response = self.client.post(
         response = self.client.post(
             self.api_link, json.dumps({}), content_type="application/json"
             self.api_link, json.dumps({}), content_type="application/json"
         )
         )
-        self.assertContains(
-            response, "You have to select at least two posts to merge.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to select at least two posts to merge.",
+        })
 
 
     def test_empty_data_form(self):
     def test_empty_data_form(self):
         """api handles empty form data"""
         """api handles empty form data"""
         response = self.client.post(self.api_link, {})
         response = self.client.post(self.api_link, {})
-
-        self.assertContains(
-            response, "You have to select at least two posts to merge.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to select at least two posts to merge.",
+        })
 
 
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api handles post that is invalid type"""
         """api handles post that is invalid type"""
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         response = self.client.post(self.api_link, '[]', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got list.",
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '123', content_type="application/json")
         response = self.client.post(self.api_link, '123', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got int.",
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got str.",
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
-        self.assertContains(response, "JSON parse error", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
+        })
 
 
     def test_no_posts_ids(self):
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         """api rejects no posts ids"""
@@ -113,9 +132,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "You have to select at least two posts to merge.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to select at least two posts to merge.",
+        })
 
 
     def test_invalid_posts_data(self):
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         """api handles invalid data"""
@@ -126,9 +146,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "Expected a list of items but got type", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "str".',
+        })
 
 
     def test_invalid_posts_ids(self):
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         """api handles invalid post id"""
@@ -139,9 +160,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more post ids received were invalid.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more post ids received were invalid.",
+        })
 
 
     def test_one_post_id(self):
     def test_one_post_id(self):
         """api rejects one post id"""
         """api rejects one post id"""
@@ -152,9 +174,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "You have to select at least two posts to merge.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to select at least two posts to merge.",
+        })
 
 
     def test_merge_limit(self):
     def test_merge_limit(self):
         """api rejects more posts than merge limit"""
         """api rejects more posts than merge limit"""
@@ -165,9 +188,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "No more than {} posts can be merged".format(POSTS_LIMIT), status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "No more than %s posts can be merged at single time." % POSTS_LIMIT,
+        })
 
 
     def test_merge_event(self):
     def test_merge_event(self):
         """api recjects events"""
         """api recjects events"""
@@ -180,7 +204,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Events can't be merged.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Events can't be merged.",
+        })
 
 
     def test_merge_notfound_pk(self):
     def test_merge_notfound_pk(self):
         """api recjects nonexistant pk's"""
         """api recjects nonexistant pk's"""
@@ -191,9 +218,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more posts to merge could not be found.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to merge could not be found.",
+        })
 
 
     def test_merge_cross_threads(self):
     def test_merge_cross_threads(self):
         """api recjects attempt to merge with post made in other thread"""
         """api recjects attempt to merge with post made in other thread"""
@@ -207,9 +235,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more posts to merge could not be found.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to merge could not be found.",
+        })
 
 
     def test_merge_authenticated_with_guest_post(self):
     def test_merge_authenticated_with_guest_post(self):
         """api recjects attempt to merge with post made by deleted user"""
         """api recjects attempt to merge with post made by deleted user"""
@@ -222,9 +251,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "Posts made by different users can't be merged.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Posts made by different users can't be merged.",
+        })
 
 
     def test_merge_guest_with_authenticated_post(self):
     def test_merge_guest_with_authenticated_post(self):
         """api recjects attempt to merge with post made by deleted user"""
         """api recjects attempt to merge with post made by deleted user"""
@@ -237,9 +267,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "Posts made by different users can't be merged.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Posts made by different users can't be merged.",
+        })
 
 
     def test_merge_guest_posts_different_usernames(self):
     def test_merge_guest_posts_different_usernames(self):
         """api recjects attempt to merge posts made by different guests"""
         """api recjects attempt to merge posts made by different guests"""
@@ -253,9 +284,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "Posts made by different users can't be merged.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Posts made by different users can't be merged.",
+        })
 
 
     def test_merge_different_visibility(self):
     def test_merge_different_visibility(self):
         """api recjects attempt to merge posts with different visibility"""
         """api recjects attempt to merge posts with different visibility"""
@@ -271,9 +303,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "Posts with different visibility can't be merged.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Posts with different visibility can't be merged.",
+        })
 
 
     def test_merge_different_approval(self):
     def test_merge_different_approval(self):
         """api recjects attempt to merge posts with different approval"""
         """api recjects attempt to merge posts with different approval"""
@@ -289,9 +322,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "Posts with different visibility can't be merged.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Posts with different visibility can't be merged.",
+        })
 
 
     def test_closed_thread(self):
     def test_closed_thread(self):
         """api validates permission to merge in closed thread"""
         """api validates permission to merge in closed thread"""
@@ -308,11 +342,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             json.dumps({'posts': posts}),
             json.dumps({'posts': posts}),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response,
-            "This thread is closed. You can't merge posts in it.",
-            status_code=400,
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't merge posts in it.",
+        })
 
 
         # allow closing threads
         # allow closing threads
         self.override_acl({'can_close_threads': 1})
         self.override_acl({'can_close_threads': 1})
@@ -339,11 +372,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             json.dumps({'posts': posts}),
             json.dumps({'posts': posts}),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response,
-            "This category is closed. You can't merge posts in it.",
-            status_code=400,
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't merge posts in it.",
+        })
 
 
         # allow closing threads
         # allow closing threads
         self.override_acl({'can_close_threads': 1})
         self.override_acl({'can_close_threads': 1})
@@ -378,7 +410,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
-            'detail': "Post marked as best answer can't be merged with thread's first post."
+            "detail": "Post marked as best answer can't be merged with thread's first post.",
         })
         })
 
 
     def test_merge_posts(self):
     def test_merge_posts(self):

+ 109 - 59
misago/threads/tests/test_thread_postmove_api.py

@@ -92,43 +92,67 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
 
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api handles post that is invalid type"""
         """api handles post that is invalid type"""
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         response = self.client.post(self.api_link, '[]', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got list.",
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '123', content_type="application/json")
         response = self.client.post(self.api_link, '123', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got int.",
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Invalid data. Expected a dictionary, but got str.",
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
-        self.assertContains(response, "JSON parse error", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to move"""
         """api validates permission to move"""
         self.override_acl({'can_move_posts': 0})
         self.override_acl({'can_move_posts': 0})
 
 
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
-        self.assertContains(response, "You can't move posts in this thread.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't move posts in this thread.",
+        })
 
 
     def test_move_no_new_thread_url(self):
     def test_move_no_new_thread_url(self):
         """api validates if new thread url was given"""
         """api validates if new thread url was given"""
         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(), {
+            "detail": "Enter link to new thread.",
+        })
 
 
     def test_invalid_new_thread_url(self):
     def test_invalid_new_thread_url(self):
         """api validates new thread url"""
         """api validates new thread url"""
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'new_thread': self.user.get_absolute_url(),
             'new_thread': self.user.get_absolute_url(),
         })
         })
-        self.assertContains(response, "This is not a valid thread link.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This is not a valid thread link.",
+        })
 
 
     def test_current_new_thread_url(self):
     def test_current_new_thread_url(self):
         """api validates if new thread url points to current thread"""
         """api validates if new thread url points to current thread"""
@@ -137,9 +161,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
                 'new_thread': self.thread.get_absolute_url(),
                 'new_thread': self.thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(
-            response, "Thread to move posts to is same as current one.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Thread to move posts to is same as current one.",
+        })
 
 
     def test_other_thread_exists(self):
     def test_other_thread_exists(self):
         """api validates if other thread exists"""
         """api validates if other thread exists"""
@@ -152,9 +177,13 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'new_thread': other_new_thread,
             'new_thread': other_new_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(), {
+            "detail": (
+                "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):
         """api validates if other thread is visible"""
         """api validates if other thread is visible"""
@@ -167,9 +196,13 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
                 'new_thread': other_thread.get_absolute_url(),
                 'new_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(), {
+            "detail": (
+                "The thread you have entered link to doesn't exist "
+                "or you don't have permission to see it."
+            ),
+        })
 
 
     def test_other_thread_isnt_replyable(self):
     def test_other_thread_isnt_replyable(self):
         """api validates if other thread can be replied"""
         """api validates if other thread can be replied"""
@@ -182,18 +215,20 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
                 'new_thread': other_thread.get_absolute_url(),
                 'new_thread': other_thread.get_absolute_url(),
             }
             }
         )
         )
-        self.assertContains(
-            response, "You can't move posts to threads you can't reply.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You can't move posts to threads you can't reply.",
+        })
 
 
     def test_empty_data(self):
     def test_empty_data(self):
         """api handles empty data"""
         """api handles empty data"""
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
 
 
         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(), {
+            "detail": "Enter link to new thread.",
+        })
 
 
     def test_empty_posts_data_json(self):
     def test_empty_posts_data_json(self):
         """api handles empty json data"""
         """api handles empty json data"""
@@ -206,10 +241,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-
-        self.assertContains(
-            response, "You have to specify at least one post to move.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to specify at least one post to move.",
+        })
 
 
     def test_empty_posts_data_form(self):
     def test_empty_posts_data_form(self):
         """api handles empty form data"""
         """api handles empty form data"""
@@ -221,10 +256,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
                 'new_thread': other_thread.get_absolute_url(),
                 'new_thread': other_thread.get_absolute_url(),
             },
             },
         )
         )
-
-        self.assertContains(
-            response, "You have to specify at least one post to move.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to specify at least one post to move.",
+        })
 
 
     def test_no_posts_ids(self):
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         """api rejects no posts ids"""
@@ -238,9 +273,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "You have to specify at least one post to move.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to specify at least one post to move.",
+        })
 
 
     def test_invalid_posts_data(self):
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         """api handles invalid data"""
@@ -254,9 +290,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "Expected a list of items", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "str".',
+        })
 
 
     def test_invalid_posts_ids(self):
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         """api handles invalid post id"""
@@ -270,9 +307,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more post ids received were invalid.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more post ids received were invalid.",
+        })
 
 
     def test_move_limit(self):
     def test_move_limit(self):
         """api rejects more posts than move limit"""
         """api rejects more posts than move limit"""
@@ -286,9 +324,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "No more than {} posts can be moved".format(POSTS_LIMIT), status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "No more than %s posts can be moved at single time." % POSTS_LIMIT,
+        })
 
 
     def test_move_invisible(self):
     def test_move_invisible(self):
         """api validates posts visibility"""
         """api validates posts visibility"""
@@ -302,9 +341,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more posts to move could not be found.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to move could not be found.",
+        })
 
 
     def test_move_other_thread_posts(self):
     def test_move_other_thread_posts(self):
         """api recjects attempt to move other thread's post"""
         """api recjects attempt to move other thread's post"""
@@ -318,9 +358,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more posts to move could not be found.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to move could not be found.",
+        })
 
 
     def test_move_event(self):
     def test_move_event(self):
         """api rejects events move"""
         """api rejects events move"""
@@ -334,7 +375,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Events can't be moved.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Events can't be moved.",
+        })
 
 
     def test_move_first_post(self):
     def test_move_first_post(self):
         """api rejects first post move"""
         """api rejects first post move"""
@@ -348,7 +392,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "You can't move thread's first post.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You can't move thread's first post.",
+        })
 
 
     def test_move_hidden_posts(self):
     def test_move_hidden_posts(self):
         """api recjects attempt to move urneadable hidden post"""
         """api recjects attempt to move urneadable hidden post"""
@@ -362,9 +409,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "You can't move posts the content you can't see.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You can't move posts the content you can't see.",
+        })
 
 
     def test_move_posts_closed_thread_no_permission(self):
     def test_move_posts_closed_thread_no_permission(self):
         """api recjects attempt to move posts from closed thread"""
         """api recjects attempt to move posts from closed thread"""
@@ -383,9 +431,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "This thread is closed. You can't move posts in it.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't move posts in it.",
+        })
 
 
     def test_move_posts_closed_category_no_permission(self):
     def test_move_posts_closed_category_no_permission(self):
         """api recjects attempt to move posts from closed thread"""
         """api recjects attempt to move posts from closed thread"""
@@ -405,9 +454,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "This category is closed. You can't move posts in it.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't move posts in it.",
+        })
 
 
     def test_move_posts(self):
     def test_move_posts(self):
         """api moves posts to other thread"""
         """api moves posts to other thread"""

+ 10 - 2
misago/threads/tests/test_thread_postpatch_api.py

@@ -966,7 +966,11 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertContains(response, "You can't like posts in this category.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "id": self.post.id,
+            "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"""
@@ -981,7 +985,11 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
                 },
                 },
             ]
             ]
         )
         )
-        self.assertContains(response, "You can't like posts in this category.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "id": self.post.id,
+            "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"""

+ 4 - 1
misago/threads/tests/test_thread_postread_api.py

@@ -29,7 +29,10 @@ class PostReadApiTests(ThreadsApiTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, "This action is not available to guests.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests."
+        })
 
 
     def test_read_post(self):
     def test_read_post(self):
         """api marks post as read"""
         """api marks post as read"""

+ 75 - 41
misago/threads/tests/test_thread_postsplit_api.py

@@ -96,38 +96,57 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
 
 
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to split"""
         """api validates permission to split"""
         self.override_acl({'can_move_posts': 0})
         self.override_acl({'can_move_posts': 0})
 
 
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
-        self.assertContains(response, "You can't split posts from this thread.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't split posts from this thread.",
+        })
 
 
     def test_empty_data(self):
     def test_empty_data(self):
         """api handles empty data"""
         """api handles empty data"""
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(
-            response, "You have to specify at least one post to split.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to specify at least one post to split.",
+        })
 
 
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api handles post that is invalid type"""
         """api handles post that is invalid type"""
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         response = self.client.post(self.api_link, '[]', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "non_field_errors": ["Invalid data. Expected a dictionary, but got list."],
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '123', content_type="application/json")
         response = self.client.post(self.api_link, '123', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "non_field_errors": ["Invalid data. Expected a dictionary, but got int."],
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
-        self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "non_field_errors": ["Invalid data. Expected a dictionary, but got str."],
+        })
 
 
         self.override_acl()
         self.override_acl()
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
-        self.assertContains(response, "JSON parse error", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
+        })
 
 
     def test_no_posts_ids(self):
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         """api rejects no posts ids"""
@@ -136,10 +155,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             json.dumps({}),
             json.dumps({}),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "You have to specify at least one post to split.", status_code=400
-        )
-
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to specify at least one post to split.",
+        })
     def test_empty_posts_ids(self):
     def test_empty_posts_ids(self):
         """api rejects empty posts ids list"""
         """api rejects empty posts ids list"""
         response = self.client.post(
         response = self.client.post(
@@ -149,9 +168,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "You have to specify at least one post to split.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You have to specify at least one post to split.",
+        })
 
 
     def test_invalid_posts_data(self):
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         """api handles invalid data"""
@@ -162,9 +182,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "Expected a list of items but got type", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "str".',
+        })
 
 
     def test_invalid_posts_ids(self):
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         """api handles invalid post id"""
@@ -175,9 +196,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more post ids received were invalid.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more post ids received were invalid.",
+        })
 
 
     def test_split_limit(self):
     def test_split_limit(self):
         """api rejects more posts than split limit"""
         """api rejects more posts than split limit"""
@@ -188,9 +210,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "No more than {} posts can be split".format(POSTS_LIMIT), status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "No more than %s posts can be split at single time." % POSTS_LIMIT,
+        })
 
 
     def test_split_invisible(self):
     def test_split_invisible(self):
         """api validates posts visibility"""
         """api validates posts visibility"""
@@ -201,9 +224,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more posts to split could not be found.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to split could not be found.",
+        })
 
 
     def test_split_event(self):
     def test_split_event(self):
         """api rejects events split"""
         """api rejects events split"""
@@ -214,7 +238,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Events can't be split.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Events can't be split.",
+        })
 
 
     def test_split_first_post(self):
     def test_split_first_post(self):
         """api rejects first post split"""
         """api rejects first post split"""
@@ -225,7 +252,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "You can't split thread's first post.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You can't split thread's first post.",
+        })
 
 
     def test_split_hidden_posts(self):
     def test_split_hidden_posts(self):
         """api recjects attempt to split urneadable hidden post"""
         """api recjects attempt to split urneadable hidden post"""
@@ -236,9 +266,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "You can't split posts the content you can't see.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "You can't split posts the content you can't see.",
+        })
 
 
     def test_split_posts_closed_thread_no_permission(self):
     def test_split_posts_closed_thread_no_permission(self):
         """api recjects attempt to split posts from closed thread"""
         """api recjects attempt to split posts from closed thread"""
@@ -254,9 +285,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "This thread is closed. You can't split posts in it.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't split posts in it.",
+        })
 
 
     def test_split_posts_closed_category_no_permission(self):
     def test_split_posts_closed_category_no_permission(self):
         """api recjects attempt to split posts from closed thread"""
         """api recjects attempt to split posts from closed thread"""
@@ -272,9 +304,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "This category is closed. You can't split posts in it.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't split posts in it.",
+        })
 
 
     def test_split_other_thread_posts(self):
     def test_split_other_thread_posts(self):
         """api recjects attempt to split other thread's post"""
         """api recjects attempt to split other thread's post"""
@@ -287,9 +320,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(
-            response, "One or more posts to split could not be found.", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "One or more posts to split could not be found.",
+        })
 
 
     def test_split_empty_new_thread_data(self):
     def test_split_empty_new_thread_data(self):
         """api handles empty form data"""
         """api handles empty form data"""

+ 20 - 15
misago/threads/tests/test_thread_reply_api.py

@@ -60,9 +60,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.override_acl({'can_reply_threads': 0})
         self.override_acl({'can_reply_threads': 0})
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(
-            response, "You can't reply to threads in this category.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't reply to threads in this category.",
+        })
 
 
     def test_closed_category(self):
     def test_closed_category(self):
         """permssion to reply in closed category is validated"""
         """permssion to reply in closed category is validated"""
@@ -72,11 +73,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(
-            response,
-            "This category is closed. You can't reply to threads in it.",
-            status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't reply to threads in it.",
+        })
 
 
         # allow to post in closed category
         # allow to post in closed category
         self.override_acl({'can_close_threads': 1})
         self.override_acl({'can_close_threads': 1})
@@ -92,9 +92,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(
-            response, "You can't reply to closed threads in this category.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't reply to closed threads in this category.",
+        })
 
 
         # allow to post in closed thread
         # allow to post in closed thread
         self.override_acl({'can_close_threads': 1})
         self.override_acl({'can_close_threads': 1})
@@ -107,8 +108,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.override_acl()
         self.override_acl()
 
 
         response = self.client.post(self.api_link, data={})
         response = self.client.post(self.api_link, data={})
-
-        self.assertContains(response, "You have to enter a message.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "post": ["You have to enter a message."],
+        })
 
 
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api errors for invalid request data"""
         """api errors for invalid request data"""
@@ -119,8 +122,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             'false',
             'false',
             content_type="application/json",
             content_type="application/json",
         )
         )
-
-        self.assertContains(response, "Invalid data.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
+        })
 
 
     def test_post_is_validated(self):
     def test_post_is_validated(self):
         """post is validated"""
         """post is validated"""

+ 37 - 14
misago/threads/tests/test_thread_start_api.py

@@ -50,8 +50,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
             'category': self.category.pk,
         })
         })
-
-        self.assertContains(response, "Selected category is invalid.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'category': ["Selected category is invalid."],
+            'post': ['You have to enter a message.'],
+            'title': ['You have to enter thread title.'],
+        })
 
 
     def test_cant_browse(self):
     def test_cant_browse(self):
         """has no permission to browse selected category"""
         """has no permission to browse selected category"""
@@ -60,8 +64,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
             'category': self.category.pk,
         })
         })
-
-        self.assertContains(response, "Selected category is invalid.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'category': ['Selected category is invalid.'],
+            'post': ['You have to enter a message.'],
+            'title': ['You have to enter thread title.'],
+        })
 
 
     def test_cant_start_thread(self):
     def test_cant_start_thread(self):
         """permission to start thread in category is validated"""
         """permission to start thread in category is validated"""
@@ -70,10 +78,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
             'category': self.category.pk,
         })
         })
-
-        self.assertContains(
-            response, "You don't have permission to start new threads", status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'category': ["You don't have permission to start new threads in this category."],
+            'post': ['You have to enter a message.'],
+            'title': ['You have to enter thread title.'],
+        })
 
 
     def test_cant_start_thread_in_locked_category(self):
     def test_cant_start_thread_in_locked_category(self):
         """can't post in closed category"""
         """can't post in closed category"""
@@ -85,8 +95,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
             'category': self.category.pk,
         })
         })
-
-        self.assertContains(response, "This category is closed.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'category': ["This category is closed. You can't start new threads in it."],
+            'post': ['You have to enter a message.'],
+            'title': ['You have to enter thread title.'],
+        })
 
 
     def test_cant_start_thread_in_invalid_category(self):
     def test_cant_start_thread_in_invalid_category(self):
         """can't post in invalid category"""
         """can't post in invalid category"""
@@ -96,8 +110,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.override_acl({'can_close_threads': 0})
         self.override_acl({'can_close_threads': 0})
 
 
         response = self.client.post(self.api_link, {'category': self.category.pk * 100000})
         response = self.client.post(self.api_link, {'category': self.category.pk * 100000})
-
-        self.assertContains(response, "Selected category doesn't exist", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'category': [
+                "Selected category doesn't exist or "
+                "you don't have permission to browse it."
+            ],
+            'post': ['You have to enter a message.'],
+            'title': ['You have to enter thread title.'],
+        })
 
 
     def test_empty_data(self):
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
         """no data sent handling has no showstoppers"""
@@ -122,8 +143,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'false',
             'false',
             content_type="application/json",
             content_type="application/json",
         )
         )
-
-        self.assertContains(response, "Invalid data.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
+        })
 
 
     def test_title_is_validated(self):
     def test_title_is_validated(self):
         """title is validated"""
         """title is validated"""

+ 31 - 13
misago/threads/tests/test_threads_bulkdelete_api.py

@@ -39,7 +39,10 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.delete(self.api_link)
         response = self.delete(self.api_link)
-        self.assertContains(response, "This action is not available to guests.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_delete_no_ids(self):
     def test_delete_no_ids(self):
         """api requires ids to delete"""
         """api requires ids to delete"""
@@ -49,7 +52,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, 403)
+        self.assertEqual(response.json(), {
+            "detail": "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 +65,22 @@ 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, 403)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "bool".',
+        })
 
 
         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, 403)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "str".',
+        })
 
 
         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, 403)
+        self.assertEqual(response.json(), {
+            "detail": "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 +90,10 @@ 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, 403)
+        self.assertEqual(response.json(), {
+            "detail": "No more than %s threads can be deleted at single time." % 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 +110,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, 403)
+        self.assertEqual(response.json(), {
+            "detail": "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:
@@ -199,8 +216,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.assertEqual(response.status_code, 403)
-        self.assertContains(response, "threads to delete could not be found", status_code=403)
+        self.assertEqual(response.json(), {
+            "detail": "One or more threads to delete could not be found.",
+        })
 
 
         Thread.objects.get(pk=private_thread.pk)
         Thread.objects.get(pk=private_thread.pk)

+ 72 - 36
misago/threads/tests/test_threads_editor_api.py

@@ -80,21 +80,30 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You need to be signed in", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You need to be signed in to start threads.",
+        })
 
 
     def test_category_visibility_validation(self):
     def test_category_visibility_validation(self):
         """endpoint omits non-browseable categories"""
         """endpoint omits non-browseable categories"""
         self.override_acl({'can_browse': 0})
         self.override_acl({'can_browse': 0})
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "No categories that allow new threads", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "No categories that allow new threads are available to you at the moment.",
+        })
 
 
     def test_category_disallowing_new_threads(self):
     def test_category_disallowing_new_threads(self):
         """endpoint omits category disallowing starting threads"""
         """endpoint omits category disallowing starting threads"""
         self.override_acl({'can_start_threads': 0})
         self.override_acl({'can_start_threads': 0})
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "No categories that allow new threads", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "No categories that allow new threads are available to you at the moment.",
+        })
 
 
     def test_category_closed_disallowing_new_threads(self):
     def test_category_closed_disallowing_new_threads(self):
         """endpoint omits closed category"""
         """endpoint omits closed category"""
@@ -104,7 +113,10 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "No categories that allow new threads", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "No categories that allow new threads are available to you at the moment.",
+        })
 
 
     def test_category_closed_allowing_new_threads(self):
     def test_category_closed_allowing_new_threads(self):
         """endpoint adds closed category that allows new threads"""
         """endpoint adds closed category that allows new threads"""
@@ -271,7 +283,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You have to sign in to reply threads.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You have to sign in to reply threads.",
+        })
 
 
     def test_thread_visibility(self):
     def test_thread_visibility(self):
         """thread's visibility is validated"""
         """thread's visibility is validated"""
@@ -292,9 +307,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.override_acl({'can_reply_threads': 0})
         self.override_acl({'can_reply_threads': 0})
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(
-            response, "You can't reply to threads in this category.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't reply to threads in this category.",
+        })
 
 
     def test_closed_category(self):
     def test_closed_category(self):
         """permssion to reply in closed category is validated"""
         """permssion to reply in closed category is validated"""
@@ -304,11 +320,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(
-            response,
-            "This category is closed. You can't reply to threads in it.",
-            status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't reply to threads in it.",
+        })
 
 
         # allow to post in closed category
         # allow to post in closed category
         self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
         self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
@@ -324,9 +339,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(
-            response, "You can't reply to closed threads in this category.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't reply to closed threads in this category.",
+        })
 
 
         # allow to post in closed thread
         # allow to post in closed thread
         self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
         self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
@@ -360,7 +376,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         hidden_reply = testutils.reply_thread(self.thread, is_hidden=True)
         hidden_reply = testutils.reply_thread(self.thread, is_hidden=True)
 
 
         response = self.client.get('{}?reply={}'.format(self.api_link, hidden_reply.pk))
         response = self.client.get('{}?reply={}'.format(self.api_link, hidden_reply.pk))
-        self.assertContains(response, "You can't reply to hidden posts", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't reply to hidden posts.",
+        })
 
 
     def test_reply_to_other_thread_post(self):
     def test_reply_to_other_thread_post(self):
         """api validates is replied post belongs to same thread"""
         """api validates is replied post belongs to same thread"""
@@ -377,8 +396,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         reply_to = testutils.reply_thread(self.thread, is_event=True)
         reply_to = testutils.reply_thread(self.thread, is_event=True)
 
 
         response = self.client.get('{}?reply={}'.format(self.api_link, reply_to.pk))
         response = self.client.get('{}?reply={}'.format(self.api_link, reply_to.pk))
-
-        self.assertContains(response, "You can't reply to events.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't reply to events.",
+        })
 
 
     def test_reply_to(self):
     def test_reply_to(self):
         """api includes replied to post details in response"""
         """api includes replied to post details in response"""
@@ -418,7 +439,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You have to sign in to edit posts.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You have to sign in to edit posts.",
+        })
 
 
     def test_thread_visibility(self):
     def test_thread_visibility(self):
         """thread's visibility is validated"""
         """thread's visibility is validated"""
@@ -439,7 +463,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.override_acl({'can_edit_posts': 0})
         self.override_acl({'can_edit_posts': 0})
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You can't edit posts in this category.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't edit posts in this category.",
+        })
 
 
     def test_closed_category(self):
     def test_closed_category(self):
         """permssion to edit in closed category is validated"""
         """permssion to edit in closed category is validated"""
@@ -449,9 +476,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.category.save()
         self.category.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(
-            response, "This category is closed. You can't edit posts in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This category is closed. You can't edit posts in it.",
+        })
 
 
         # allow to edit in closed category
         # allow to edit in closed category
         self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
         self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
@@ -467,9 +495,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(
-            response, "This thread is closed. You can't edit posts in it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This thread is closed. You can't edit posts in it.",
+        })
 
 
         # allow to edit in closed thread
         # allow to edit in closed thread
         self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
         self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
@@ -485,9 +514,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(
-            response, "This post is protected. You can't edit it.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This post is protected. You can't edit it.",
+        })
 
 
         # allow to post in closed thread
         # allow to post in closed thread
         self.override_acl({'can_edit_posts': 1, 'can_protect_posts': 1})
         self.override_acl({'can_edit_posts': 1, 'can_protect_posts': 1})
@@ -503,7 +533,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(response, "This post is hidden, you can't edit it.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This post is hidden, you can't edit it.",
+        })
 
 
         # allow hidden edition
         # allow hidden edition
         self.override_acl({'can_edit_posts': 1, 'can_hide_posts': 1})
         self.override_acl({'can_edit_posts': 1, 'can_hide_posts': 1})
@@ -538,8 +571,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-
-        self.assertContains(response, "Events can't be edited.", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "Events can't be edited.",
+        })
 
 
     def test_other_user_post(self):
     def test_other_user_post(self):
         """api validates if other user's post can be edited"""
         """api validates if other user's post can be edited"""
@@ -549,9 +584,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.save()
         self.post.save()
 
 
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-        self.assertContains(
-            response, "You can't edit other users posts in this category.", status_code=403
-        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't edit other users posts in this category.",
+        })
 
 
         # allow other users post edition
         # allow other users post edition
         self.override_acl({'can_edit_posts': 2})
         self.override_acl({'can_edit_posts': 2})

+ 48 - 78
misago/threads/tests/test_threads_merge_api.py

@@ -59,10 +59,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         """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, 403)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "You have to select at least two threads to merge.",
                 'detail': "You have to select at least two threads to merge.",
             }
             }
         )
         )
@@ -77,10 +75,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "You have to select at least two threads to merge.",
                 'detail': "You have to select at least two threads to merge.",
             }
             }
         )
         )
@@ -94,7 +90,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Expected a list of items", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": 'Expected a list of items but got type "str".',
+        })
 
 
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
@@ -104,10 +103,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "One or more thread ids received were invalid.",
                 'detail': "One or more thread ids received were invalid.",
             }
             }
         )
         )
@@ -122,10 +119,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "You have to select at least two threads to merge.",
                 'detail': "You have to select at least two threads to merge.",
             }
             }
         )
         )
@@ -140,10 +135,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "One or more threads to merge could not be found.",
                 'detail': "One or more threads to merge could not be found.",
             }
             }
         )
         )
@@ -160,10 +153,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "One or more threads to merge could not be found.",
                 'detail': "One or more threads to merge could not be found.",
             }
             }
         )
         )
@@ -182,10 +173,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, [
+            response.json(), [
                 {
                 {
                     'id': thread.pk,
                     'id': thread.pk,
                     'title': thread.title,
                     'title': thread.title,
@@ -221,11 +210,19 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        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(), [
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "errors": ["This category is closed. You can't merge it's threads."],
+            },
+            {
+                "id": self.thread.id,
+                "title": self.thread.title,
+                "errors": ["This category is closed. You can't merge it's threads."],
+            },
+        ])
 
 
     def test_thread_is_closed(self):
     def test_thread_is_closed(self):
         """api validates if thread is open"""
         """api validates if thread is open"""
@@ -249,11 +246,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        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(), [
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "errors": ["This thread is closed. You can't merge it with other threads."],
+            },
+        ])
 
 
     def test_merge_too_many_threads(self):
     def test_merge_too_many_threads(self):
         """api rejects too many threads to merge"""
         """api rejects too many threads to merge"""
@@ -276,10 +276,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "No more than %s threads can be merged at single time." % THREADS_LIMIT,
                 'detail': "No more than %s threads can be merged at single time." % THREADS_LIMIT,
             }
             }
         )
         )
@@ -303,10 +301,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ['This field is required.'],
                 'title': ['This field is required.'],
                 'category': ['This field is required.'],
                 'category': ['This field is required.'],
             }
             }
@@ -333,10 +329,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
             }
         )
         )
@@ -362,10 +356,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'category': ["Requested category could not be found."],
                 'category': ["Requested category could not be found."],
             }
             }
         )
         )
@@ -392,10 +384,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'category': ["You can't create new threads in selected category."],
                 'category': ["You can't create new threads in selected category."],
             }
             }
         )
         )
@@ -422,10 +412,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'weight': ["Ensure this value is less than or equal to 2."],
                 'weight': ["Ensure this value is less than or equal to 2."],
             }
             }
         )
         )
@@ -452,10 +440,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'weight': ["You don't have permission to pin threads globally in this category."],
                 'weight': ["You don't have permission to pin threads globally in this category."],
             }
             }
         )
         )
@@ -482,10 +468,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'weight': ["You don't have permission to pin threads in this category."],
                 'weight': ["You don't have permission to pin threads in this category."],
             }
             }
         )
         )
@@ -513,10 +497,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
             }
         )
         )
@@ -544,10 +526,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
             }
         )
         )
@@ -574,10 +554,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'is_closed': ["You don't have permission to close threads in this category."],
                 'is_closed': ["You don't have permission to close threads in this category."],
             }
             }
         )
         )
@@ -605,10 +583,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
             }
         )
         )
@@ -636,10 +612,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'is_hidden': ["You don't have permission to hide threads in this category."],
                 'is_hidden': ["You don't have permission to hide threads in this category."],
             }
             }
         )
         )
@@ -668,10 +642,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
             }
         )
         )
@@ -1002,8 +974,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        response_json = response.json()
-        new_thread = Thread.objects.get(pk=response_json['id'])
+        new_thread = Thread.objects.get(pk=response.json()['id'])
 
 
         # poll and its votes were kept
         # poll and its votes were kept
         self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=new_thread).count(), 1)
         self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=new_thread).count(), 1)
@@ -1030,8 +1001,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        response_json = response.json()
-        new_thread = Thread.objects.get(pk=response_json['id'])
+        new_thread = Thread.objects.get(pk=response.json()['id'])
 
 
         # poll and its votes were kept
         # poll and its votes were kept
         self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=new_thread).count(), 1)
         self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=new_thread).count(), 1)

+ 16 - 2
misago/threads/tests/test_validate_post.py

@@ -21,7 +21,10 @@ class ValidatePostTests(AuthenticatedUserTestCase):
                 'post': 'Lorem ipsum dolor met!',
                 'post': 'Lorem ipsum dolor met!',
             }
             }
         )
         )
-        self.assertContains(response, "Don't discuss gambling!", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'non_field_errors': ["Don't discuss gambling!"],
+        })
 
 
         # clean title passes validation
         # clean title passes validation
         response = self.client.post(
         response = self.client.post(
@@ -44,7 +47,10 @@ class ValidatePostTests(AuthenticatedUserTestCase):
                 'post': 'Check our l33t CaSiNo!',
                 'post': 'Check our l33t CaSiNo!',
             }
             }
         )
         )
-        self.assertContains(response, "Don't discuss gambling!", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'non_field_errors': ["Don't discuss gambling!"],
+        })
 
 
         # clean post passes validation
         # clean post passes validation
         response = self.client.post(
         response = self.client.post(
@@ -65,6 +71,10 @@ class ValidatePostTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'title': ['You have to enter thread title.'],
+            'post': ['You have to enter a message.'],
+        })
 
 
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
@@ -74,3 +84,7 @@ class ValidatePostTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'title': ['This field may not be blank.'],
+            'post': ['This field may not be blank.'],
+        })

+ 114 - 28
misago/users/tests/test_auth_api.py

@@ -18,8 +18,11 @@ class GatewayTests(TestCase):
                 'password': 'nope',
                 'password': 'nope',
             }
             }
         )
         )
-
-        self.assertContains(response, "Login or password is incorrect.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': "invalid_login",
+            "detail": "Login or password is incorrect.",
+        })
 
 
         response = self.client.get('/api/auth/')
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
@@ -82,7 +85,11 @@ class GatewayTests(TestCase):
     def test_submit_empty(self):
     def test_submit_empty(self):
         """login api errors for no body"""
         """login api errors for no body"""
         response = self.client.post('/api/auth/')
         response = self.client.post('/api/auth/')
-        self.assertContains(response, 'empty_data', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': "empty_data",
+            "detail": "Fill out both fields.",
+        })
 
 
     def test_submit_invalid(self):
     def test_submit_invalid(self):
         """login api errors for invalid data"""
         """login api errors for invalid data"""
@@ -91,7 +98,10 @@ class GatewayTests(TestCase):
             'false',
             'false',
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Invalid data.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
+        })
 
 
     def test_login_not_usable_password(self):
     def test_login_not_usable_password(self):
         """login api fails to sign user with not-usable password in"""
         """login api fails to sign user with not-usable password in"""
@@ -255,7 +265,11 @@ class GatewayTests(TestCase):
                 'password': 'Pass.123',
                 'password': 'Pass.123',
             },
             },
         )
         )
-        self.assertContains(response, "Login or password is incorrect.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'invalid_login',
+            'detail': 'Login or password is incorrect.',
+        })
 
 
         response = self.client.get('/api/auth/')
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
@@ -271,7 +285,7 @@ class UserCredentialsTests(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class SendActivationAPITests(TestCase):
+class SendActivationApiTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         self.user.requires_activation = 1
         self.user.requires_activation = 1
@@ -320,14 +334,22 @@ class SendActivationAPITests(TestCase):
                 'email': self.user.email,
                 'email': self.user.email,
             },
             },
         )
         )
-        self.assertContains(response, 'not_found', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'not_found',
+            'detail': 'No user with this e-mail exists.',
+        })
 
 
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
     def test_submit_empty(self):
     def test_submit_empty(self):
         """request activation link api errors for no body"""
         """request activation link api errors for no body"""
         response = self.client.post(self.link)
         response = self.client.post(self.link)
-        self.assertContains(response, 'empty_email', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'empty_email',
+            'detail': 'Enter e-mail address.',
+        })
 
 
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
@@ -338,7 +360,10 @@ class SendActivationAPITests(TestCase):
             'false',
             'false',
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Invalid data.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
+        })
 
 
     def test_submit_invalid_email(self):
     def test_submit_invalid_email(self):
         """request activation link api errors for invalid email"""
         """request activation link api errors for invalid email"""
@@ -348,7 +373,11 @@ class SendActivationAPITests(TestCase):
                 'email': 'fake@mail.com',
                 'email': 'fake@mail.com',
             },
             },
         )
         )
-        self.assertContains(response, 'not_found', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'not_found',
+            'detail': 'No user with this e-mail exists.',
+        })
 
 
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
@@ -363,7 +392,11 @@ class SendActivationAPITests(TestCase):
                 'email': self.user.email,
                 'email': self.user.email,
             },
             },
         )
         )
-        self.assertContains(response, 'Bob, your account is already active.', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'already_active',
+            'detail': 'Bob, your account is already active.',
+        })
 
 
     def test_submit_inactive_user(self):
     def test_submit_inactive_user(self):
         """request activation link api errors for admin-activated users"""
         """request activation link api errors for admin-activated users"""
@@ -376,7 +409,11 @@ class SendActivationAPITests(TestCase):
                 'email': self.user.email,
                 'email': self.user.email,
             },
             },
         )
         )
-        self.assertContains(response, 'inactive_admin', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'inactive_admin',
+            'detail': 'Bob, only administrator may activate your account.',
+        })
 
 
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
@@ -394,7 +431,7 @@ class SendActivationAPITests(TestCase):
         self.assertTrue(mail.outbox)
         self.assertTrue(mail.outbox)
 
 
 
 
-class SendPasswordFormAPITests(TestCase):
+class SendPasswordFormApiTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
 
 
@@ -441,14 +478,22 @@ class SendPasswordFormAPITests(TestCase):
                 'email': self.user.email,
                 'email': self.user.email,
             },
             },
         )
         )
-        self.assertContains(response, 'not_found', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'not_found',
+            'detail': 'No user with this e-mail exists.',
+        })
 
 
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
     def test_submit_empty(self):
     def test_submit_empty(self):
         """request change password form link api errors for no body"""
         """request change password form link api errors for no body"""
         response = self.client.post(self.link)
         response = self.client.post(self.link)
-        self.assertContains(response, 'empty_email', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'empty_email',
+            'detail': 'Enter e-mail address.',
+        })
 
 
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
@@ -460,7 +505,11 @@ class SendPasswordFormAPITests(TestCase):
                 'email': 'fake@mail.com',
                 'email': 'fake@mail.com',
             },
             },
         )
         )
-        self.assertContains(response, 'not_found', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'not_found',
+            'detail': 'No user with this e-mail exists.',
+        })
 
 
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
@@ -471,7 +520,10 @@ class SendPasswordFormAPITests(TestCase):
             'false',
             'false',
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Invalid data.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
+        })
 
 
     def test_submit_inactive_user(self):
     def test_submit_inactive_user(self):
         """request change password form link api errors for inactive users"""
         """request change password form link api errors for inactive users"""
@@ -484,7 +536,14 @@ class SendPasswordFormAPITests(TestCase):
                 'email': self.user.email,
                 'email': self.user.email,
             },
             },
         )
         )
-        self.assertContains(response, 'inactive_user', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'inactive_user',
+            'detail': (
+                'You have to activate your account before you '
+                'will be able to request new password.'
+            )
+        })
 
 
         self.user.requires_activation = 2
         self.user.requires_activation = 2
         self.user.save()
         self.user.save()
@@ -495,12 +554,19 @@ class SendPasswordFormAPITests(TestCase):
                 'email': self.user.email,
                 'email': self.user.email,
             },
             },
         )
         )
-        self.assertContains(response, 'inactive_admin', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'code': 'inactive_admin',
+            'detail': (
+                'Administrator has to activate your account before you '
+                'will be able to request new password.'
+            )
+        })
 
 
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
 
 
-class ChangePasswordAPITests(TestCase):
+class ChangePasswordApiTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
 
 
@@ -539,13 +605,18 @@ class ChangePasswordAPITests(TestCase):
             'false',
             'false',
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Invalid data.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
+        })
 
 
     def test_invalid_token_link(self):
     def test_invalid_token_link(self):
         """api errors on invalid user id link"""
         """api errors on invalid user id link"""
         response = self.client.post(self.link % (self.user.pk, 'asda7ad89sa7d9s789as'))
         response = self.client.post(self.link % (self.user.pk, 'asda7ad89sa7d9s789as'))
-
-        self.assertContains(response, "Form link is invalid.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': 'Form link is invalid. Please try again.'
+        })
 
 
     def test_banned_user_link(self):
     def test_banned_user_link(self):
         """request errors because user is banned"""
         """request errors because user is banned"""
@@ -558,7 +629,10 @@ class ChangePasswordAPITests(TestCase):
         response = self.client.post(
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user))
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         )
-        self.assertContains(response, "Your link has expired.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': 'Your link has expired. Please request new one.'
+        })
 
 
     def test_inactive_user(self):
     def test_inactive_user(self):
         """change password api errors for inactive users"""
         """change password api errors for inactive users"""
@@ -568,7 +642,10 @@ class ChangePasswordAPITests(TestCase):
         response = self.client.post(
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user))
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         )
-        self.assertContains(response, "Your link has expired.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': 'Your link has expired. Please request new one.'
+        })
 
 
         self.user.requires_activation = 2
         self.user.requires_activation = 2
         self.user.save()
         self.user.save()
@@ -576,7 +653,10 @@ class ChangePasswordAPITests(TestCase):
         response = self.client.post(
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user))
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         )
-        self.assertContains(response, "Your link has expired.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': 'Your link has expired. Please request new one.'
+        })
 
 
     def test_disabled_user(self):
     def test_disabled_user(self):
         """change password api errors for disabled users"""
         """change password api errors for disabled users"""
@@ -586,11 +666,17 @@ class ChangePasswordAPITests(TestCase):
         response = self.client.post(
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user))
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         )
-        self.assertContains(response, "Form link is invalid.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': 'Form link is invalid. Please try again.'
+        })
 
 
     def test_submit_empty(self):
     def test_submit_empty(self):
         """change password api errors for empty body"""
         """change password api errors for empty body"""
         response = self.client.post(
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user))
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         )
-        self.assertContains(response, "This password is too shor", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'detail': "This password is too short. It must contain at least 7 characters."
+        })

+ 1 - 1
misago/users/tests/test_captcha_api.py

@@ -4,7 +4,7 @@ from django.urls import reverse
 from misago.conf import settings
 from misago.conf import settings
 
 
 
 
-class AuthenticateAPITests(TestCase):
+class AuthenticateApiTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.api_link = reverse('misago:api:captcha-question')
         self.api_link = reverse('misago:api:captcha-question')
 
 

+ 1 - 1
misago/users/tests/test_mention_api.py

@@ -8,7 +8,7 @@ from misago.conf import settings
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 
 
-class AuthenticateAPITests(TestCase):
+class AuthenticateApiTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.api_link = reverse('misago:api:mention-suggestions')
         self.api_link = reverse('misago:api:mention-suggestions')
 
 

+ 72 - 20
misago/users/tests/test_user_avatar_api.py

@@ -78,26 +78,39 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.user.save()
         self.user.save()
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, "Your avatar is pwnt", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "Your avatar is locked. You can't change it.",
+            "reason": "<p>Your avatar is pwnt.</p>",
+        })
 
 
     def test_other_user_avatar(self):
     def test_other_user_avatar(self):
         """requests to api error if user tries to access other user"""
         """requests to api error if user tries to access other user"""
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, "You have to sign in", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You have to sign in to perform this action.",
+        })
 
 
         self.login_user(
         self.login_user(
             UserModel.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
             UserModel.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
         )
         )
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, "can't change other users avatars", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't change other users avatars.",
+        })
 
 
     def test_empty_requests(self):
     def test_empty_requests(self):
         """empty request errors with code 400"""
         """empty request errors with code 400"""
         response = self.client.post(self.link)
         response = self.client.post(self.link)
-        self.assertContains(response, "Unknown avatar type.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Unknown avatar type.",
+        })
 
 
     def test_failed_gravatar_request(self):
     def test_failed_gravatar_request(self):
         """no gravatar RPC fails"""
         """no gravatar RPC fails"""
@@ -105,7 +118,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(self.link, data={'avatar': 'gravatar'})
         response = self.client.post(self.link, data={'avatar': 'gravatar'})
-        self.assertContains(response, "No Gravatar is associated", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "No Gravatar is associated with your e-mail address.",
+        })
 
 
     def test_successful_gravatar_request(self):
     def test_successful_gravatar_request(self):
         """gravatar RPC passes"""
         """gravatar RPC passes"""
@@ -113,21 +129,30 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(self.link, data={'avatar': 'gravatar'})
         response = self.client.post(self.link, data={'avatar': 'gravatar'})
-        self.assertContains(response, "Gravatar was downloaded and set")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.json()["detail"], "Gravatar was downloaded and set as new avatar."
+        )
 
 
         self.assertOldAvatarsAreDeleted(self.user)
         self.assertOldAvatarsAreDeleted(self.user)
 
 
     def test_generation_request(self):
     def test_generation_request(self):
         """generated avatar is set"""
         """generated avatar is set"""
         response = self.client.post(self.link, data={'avatar': 'generated'})
         response = self.client.post(self.link, data={'avatar': 'generated'})
-        self.assertContains(response, "New avatar based on your account")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.json()["detail"], "New avatar based on your account was set."
+        )
 
 
         self.assertOldAvatarsAreDeleted(self.user)
         self.assertOldAvatarsAreDeleted(self.user)
 
 
     def test_avatar_upload_and_crop(self):
     def test_avatar_upload_and_crop(self):
         """avatar can be uploaded and cropped"""
         """avatar can be uploaded and cropped"""
         response = self.client.post(self.link, data={'avatar': 'upload'})
         response = self.client.post(self.link, data={'avatar': 'upload'})
-        self.assertContains(response, "No file was sent.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "No file was sent.",
+        })
 
 
         with open(TEST_AVATAR_PATH, 'rb') as avatar:
         with open(TEST_AVATAR_PATH, 'rb') as avatar:
             response = self.client.post(self.link, data={'avatar': 'upload', 'image': avatar})
             response = self.client.post(self.link, data={'avatar': 'upload', 'image': avatar})
@@ -160,7 +185,9 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, "Uploaded avatar was set.")
+        self.assertEqual(
+            response.json()["detail"], "Uploaded avatar was set."
+        )
 
 
         self.assertFalse(self.get_current_user().avatar_tmp)
         self.assertFalse(self.get_current_user().avatar_tmp)
         self.assertOldAvatarsAreDeleted(self.user)
         self.assertOldAvatarsAreDeleted(self.user)
@@ -183,7 +210,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "This avatar type is not allowed.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This avatar type is not allowed.",
+        })
 
 
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
@@ -199,7 +229,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
             }),
             }),
             content_type="application/json",
             content_type="application/json",
         )
         )
-        self.assertContains(response, "Avatar was re-cropped.")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.json()["detail"], "Avatar was re-cropped."
+        )
         self.assertOldAvatarsAreDeleted(self.user)
         self.assertOldAvatarsAreDeleted(self.user)
 
 
         # delete user avatars, test if it deletes src and tmp
         # delete user avatars, test if it deletes src and tmp
@@ -217,8 +250,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response = self.client.post(self.link, data={'avatar': 'galleries', 'image': 123})
         response = self.client.post(self.link, data={'avatar': 'galleries', 'image': 123})
-
-        self.assertContains(response, "This avatar type is not allowed.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This avatar type is not allowed.",
+        })
 
 
     def test_gallery_image_validation(self):
     def test_gallery_image_validation(self):
         """gallery validates image to set"""
         """gallery validates image to set"""
@@ -234,7 +269,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
                 'avatar': 'galleries',
                 'avatar': 'galleries',
             },
             },
         )
         )
-        self.assertContains(response, "Incorrect image.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Incorrect image.",
+        })
 
 
         # invalid id is handled
         # invalid id is handled
         response = self.client.post(
         response = self.client.post(
@@ -244,7 +282,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
                 'image': 'asdsadsadsa',
                 'image': 'asdsadsadsa',
             },
             },
         )
         )
-        self.assertContains(response, "Incorrect image.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Incorrect image.",
+        })
 
 
         # nonexistant image is handled
         # nonexistant image is handled
         response = self.client.get(self.link)
         response = self.client.get(self.link)
@@ -261,13 +302,19 @@ class UserAvatarTests(AuthenticatedUserTestCase):
                 'image': test_avatar + 5000,
                 'image': test_avatar + 5000,
             },
             },
         )
         )
-        self.assertContains(response, "Incorrect image.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Incorrect image.",
+        })
 
 
         # default gallery image is handled
         # default gallery image is handled
         AvatarGallery.objects.filter(pk=test_avatar).update(gallery=gallery.DEFAULT_GALLERY)
         AvatarGallery.objects.filter(pk=test_avatar).update(gallery=gallery.DEFAULT_GALLERY)
 
 
         response = self.client.post(self.link, data={'avatar': 'galleries', 'image': test_avatar})
         response = self.client.post(self.link, data={'avatar': 'galleries', 'image': test_avatar})
-        self.assertContains(response, "Incorrect image.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Incorrect image.",
+        })
 
 
     def test_gallery_set_valid_avatar(self):
     def test_gallery_set_valid_avatar(self):
         """its possible to set avatar from gallery"""
         """its possible to set avatar from gallery"""
@@ -287,8 +334,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
                 'image': test_avatar,
                 'image': test_avatar,
             },
             },
         )
         )
-
-        self.assertContains(response, "Avatar from gallery was set.")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.json()["detail"], "Avatar from gallery was set."
+        )
         self.assertOldAvatarsAreDeleted(self.user)
         self.assertOldAvatarsAreDeleted(self.user)
 
 
 
 
@@ -309,7 +358,10 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         })
         })
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, "can't moderate avatars", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't moderate avatars.",
+        })
 
 
     def test_moderate_avatar(self):
     def test_moderate_avatar(self):
         """moderate avatar"""
         """moderate avatar"""

+ 8 - 2
misago/users/tests/test_user_changeemail_api.py

@@ -41,7 +41,10 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
                 'password': 'Lor3mIpsum',
                 'password': 'Lor3mIpsum',
             },
             },
         )
         )
-        self.assertContains(response, 'password is invalid', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'password': ["Entered password is invalid."]
+        })
 
 
     def test_invalid_input(self):
     def test_invalid_input(self):
         """api errors correctly for invalid input"""
         """api errors correctly for invalid input"""
@@ -82,7 +85,10 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
                 'password': self.USER_PASSWORD,
                 'password': self.USER_PASSWORD,
             },
             },
         )
         )
-        self.assertContains(response, 'not available', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'new_email': ["This e-mail address is not available."]
+        })
 
 
     def test_change_email(self):
     def test_change_email(self):
         """api allows users to change their e-mail addresses"""
         """api allows users to change their e-mail addresses"""

+ 63 - 28
misago/users/tests/test_user_create_api.py

@@ -29,6 +29,11 @@ class UserCreateTests(UserTestCase):
         """empty request errors with code 400"""
         """empty request errors with code 400"""
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'username': ['This field is required.'],
+            'email': ['This field is required.'],
+            'password': ['This field is required.'],
+        })
 
 
     def test_invalid_data(self):
     def test_invalid_data(self):
         """invalid request data errors with code 400"""
         """invalid request data errors with code 400"""
@@ -38,19 +43,30 @@ class UserCreateTests(UserTestCase):
             content_type="application/json",
             content_type="application/json",
         )
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'username': ['This field is required.'],
+            'email': ['This field is required.'],
+            'password': ['This field is required.'],
+        })
 
 
     def test_authenticated_request(self):
     def test_authenticated_request(self):
         """authentiated user request errors with code 403"""
         """authentiated user request errors with code 403"""
         self.login_user(self.get_authenticated_user())
         self.login_user(self.get_authenticated_user())
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to signed in users."
+        })
 
 
     def test_registration_off_request(self):
     def test_registration_off_request(self):
         """registrations off request errors with code 403"""
         """registrations off request errors with code 403"""
         settings.override_setting('account_activation', 'closed')
         settings.override_setting('account_activation', 'closed')
 
 
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
-        self.assertContains(response, 'closed', status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "New users registrations are currently closed."
+        })
 
 
     def test_registration_validates_ip_ban(self):
     def test_registration_validates_ip_ban(self):
         """api validates ip ban"""
         """api validates ip ban"""
@@ -234,8 +250,10 @@ class UserCreateTests(UserTestCase):
                 'password': '',
                 'password': '',
             },
             },
         )
         )
-        
-        self.assertContains(response, "This field is required", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "password": ["This field is required."],
+        })
 
 
     def test_registration_validates_password(self):
     def test_registration_validates_password(self):
         """api uses django's validate_password to validate registrations"""
         """api uses django's validate_password to validate registrations"""
@@ -247,10 +265,14 @@ class UserCreateTests(UserTestCase):
                 'password': '123',
                 'password': '123',
             },
             },
         )
         )
-
-        self.assertContains(response, "password is too short", status_code=400)
-        self.assertContains(response, "password is entirely numeric", status_code=400)
-        self.assertContains(response, "email is not allowed", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "email": ["This email is not allowed."],
+            "password": [
+                "This password is too short. It must contain at least 7 characters.",
+                "This password is entirely numeric.",
+            ],
+        })
 
 
     def test_registration_validates_password_similiarity(self):
     def test_registration_validates_password_similiarity(self):
         """api uses validate_password to validate registrations"""
         """api uses validate_password to validate registrations"""
@@ -262,8 +284,11 @@ class UserCreateTests(UserTestCase):
                 'password': 'BobBoberson',
                 'password': 'BobBoberson',
             },
             },
         )
         )
-
-        self.assertContains(response, "password is too similar to the username", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "email": ["This email is not allowed."],
+            "password": ["The password is too similar to the username."],
+        })
 
 
     @override_settings(captcha_type='qa', qa_question='Test', qa_answers='Lorem\nIpsum')
     @override_settings(captcha_type='qa', qa_question='Test', qa_answers='Lorem\nIpsum')
     def test_registration_validates_captcha(self):
     def test_registration_validates_captcha(self):
@@ -391,8 +416,11 @@ class UserCreateTests(UserTestCase):
                 'password': 'pas123',
                 'password': 'pas123',
             },
             },
         )
         )
-
-        self.assertContains(response, "email is not allowed", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "email": ["This email is not allowed."],
+            "password": ["This password is too short. It must contain at least 7 characters."],
+        })
 
 
     def test_registration_creates_active_user(self):
     def test_registration_creates_active_user(self):
         """api creates active and signed in user on POST"""
         """api creates active and signed in user on POST"""
@@ -406,10 +434,12 @@ class UserCreateTests(UserTestCase):
                 'password': 'pass123',
                 'password': 'pass123',
             },
             },
         )
         )
-
-        self.assertContains(response, 'active')
-        self.assertContains(response, 'Bob')
-        self.assertContains(response, 'bob@bob.com')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'activation': 'active',
+            'username': 'Bob',
+            'email': 'bob@bob.com',
+        })
 
 
         UserModel.objects.get_by_username('Bob')
         UserModel.objects.get_by_username('Bob')
 
 
@@ -438,10 +468,12 @@ class UserCreateTests(UserTestCase):
                 'password': 'pass123',
                 'password': 'pass123',
             },
             },
         )
         )
-
-        self.assertContains(response, 'user')
-        self.assertContains(response, 'Bob')
-        self.assertContains(response, 'bob@bob.com')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'activation': 'user',
+            'username': 'Bob',
+            'email': 'bob@bob.com',
+        })
 
 
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         self.assertFalse(auth_json['is_authenticated'])
         self.assertFalse(auth_json['is_authenticated'])
@@ -463,10 +495,12 @@ class UserCreateTests(UserTestCase):
                 'password': 'pass123',
                 'password': 'pass123',
             },
             },
         )
         )
-
-        self.assertContains(response, 'admin')
-        self.assertContains(response, 'Bob')
-        self.assertContains(response, 'bob@bob.com')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'activation': 'admin',
+            'username': 'Bob',
+            'email': 'bob@bob.com',
+        })
 
 
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         self.assertFalse(auth_json['is_authenticated'])
         self.assertFalse(auth_json['is_authenticated'])
@@ -488,16 +522,17 @@ class UserCreateTests(UserTestCase):
                 'password': ' pass123 ',
                 'password': ' pass123 ',
             },
             },
         )
         )
-
-        self.assertContains(response, 'active')
-        self.assertContains(response, 'Bob')
-        self.assertContains(response, 'bob@bob.com')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'activation': 'active',
+            'username': 'Bob',
+            'email': 'bob@bob.com',
+        })
 
 
         UserModel.objects.get_by_username('Bob')
         UserModel.objects.get_by_username('Bob')
 
 
         test_user = UserModel.objects.get_by_email('bob@bob.com')
         test_user = UserModel.objects.get_by_email('bob@bob.com')
         self.assertEqual(Online.objects.filter(user=test_user).count(), 1)
         self.assertEqual(Online.objects.filter(user=test_user).count(), 1)
-
         self.assertTrue(test_user.check_password(' pass123 '))
         self.assertTrue(test_user.check_password(' pass123 '))
 
 
         self.assertIn('Welcome', mail.outbox[0].subject)
         self.assertIn('Welcome', mail.outbox[0].subject)

+ 13 - 5
misago/users/tests/test_user_signature_api.py

@@ -16,7 +16,10 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         })
         })
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, "You don't have permission to change", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You don't have permission to change signature.",
+        })
 
 
     def test_signature_locked(self):
     def test_signature_locked(self):
         """locked edit signature returns 403"""
         """locked edit signature returns 403"""
@@ -29,7 +32,11 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         self.user.save()
         self.user.save()
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, 'Your siggy is banned', status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "Your signature is locked. You can't change it.",
+            "reason": "<p>Your siggy is banned.</p>",
+        })
 
 
     def test_get_signature(self):
     def test_get_signature(self):
         """GET to api returns json with no signature"""
         """GET to api returns json with no signature"""
@@ -79,7 +86,10 @@ class UserSignatureTests(AuthenticatedUserTestCase):
                 'signature': 'abcd' * 1000,
                 'signature': 'abcd' * 1000,
             },
             },
         )
         )
-        self.assertContains(response, 'too long', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Signature is too long.",
+        })
 
 
     def test_post_good_signature(self):
     def test_post_good_signature(self):
         """POST with good signature changes user signature"""
         """POST with good signature changes user signature"""
@@ -97,12 +107,10 @@ class UserSignatureTests(AuthenticatedUserTestCase):
             },
             },
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
-
         self.assertEqual(
         self.assertEqual(
             response.json()['signature']['html'], '<p>Hello, <strong>bros</strong>!</p>'
             response.json()['signature']['html'], '<p>Hello, <strong>bros</strong>!</p>'
         )
         )
         self.assertEqual(response.json()['signature']['plain'], 'Hello, **bros**!')
         self.assertEqual(response.json()['signature']['plain'], 'Hello, **bros**!')
 
 
         self.reload_user()
         self.reload_user()
-
         self.assertEqual(self.user.signature_parsed, '<p>Hello, <strong>bros</strong>!</p>')
         self.assertEqual(self.user.signature_parsed, '<p>Hello, <strong>bros</strong>!</p>')

+ 31 - 24
misago/users/tests/test_user_username_api.py

@@ -44,13 +44,11 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        response_json = response.json()
-        for i in range(response_json['changes_left']):
+        for i in range(response.json()['changes_left']):
             self.user.set_username('NewName%s' % i, self.user)
             self.user.set_username('NewName%s' % i, self.user)
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        response_json = response.json()
-        self.assertEqual(response_json['changes_left'], 0)
+        self.assertEqual(response.json()['changes_left'], 0)
 
 
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
@@ -58,15 +56,17 @@ class UserUsernameTests(AuthenticatedUserTestCase):
                 'username': 'Pointless',
                 'username': 'Pointless',
             },
             },
         )
         )
-
-        self.assertContains(response, 'change your username now', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json()["detail"], "You can't change your username now.")
         self.assertTrue(self.user.username != 'Pointless')
         self.assertTrue(self.user.username != 'Pointless')
 
 
     def test_change_username_no_input(self):
     def test_change_username_no_input(self):
         """api returns error 400 if new username is empty"""
         """api returns error 400 if new username is empty"""
         response = self.client.post(self.link, data={})
         response = self.client.post(self.link, data={})
-
-        self.assertContains(response, 'Enter new username.', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": 'Enter new username.',
+        })
 
 
     def test_change_username_invalid_name(self):
     def test_change_username_invalid_name(self):
         """api returns error 400 if new username is wrong"""
         """api returns error 400 if new username is wrong"""
@@ -76,8 +76,10 @@ class UserUsernameTests(AuthenticatedUserTestCase):
                 'username': '####',
                 'username': '####',
             },
             },
         )
         )
-
-        self.assertContains(response, 'can only contain latin', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Username can only contain latin alphabet letters and digits.",
+        })
 
 
     def test_change_username(self):
     def test_change_username(self):
         """api changes username and records change"""
         """api changes username and records change"""
@@ -122,14 +124,20 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         })
         })
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, "can't rename users", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't rename users.",
+        })
 
 
         override_acl(self.user, {
         override_acl(self.user, {
             'can_rename_users': 0,
             'can_rename_users': 0,
         })
         })
 
 
         response = self.client.post(self.link)
         response = self.client.post(self.link)
-        self.assertContains(response, "can't rename users", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't rename users.",
+        })
 
 
     def test_moderate_username(self):
     def test_moderate_username(self):
         """moderate username"""
         """moderate username"""
@@ -155,8 +163,10 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             }),
             }),
             content_type='application/json',
             content_type='application/json',
         )
         )
-
-        self.assertContains(response, "Enter new username", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Enter new username.",
+        })
 
 
         override_acl(self.user, {
         override_acl(self.user, {
             'can_rename_users': 1,
             'can_rename_users': 1,
@@ -169,12 +179,10 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             }),
             }),
             content_type='application/json',
             content_type='application/json',
         )
         )
-
-        self.assertContains(
-            response,
-            "Username can only contain latin alphabet letters and digits.",
-            status_code=400
-        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "Username can only contain latin alphabet letters and digits.",
+        })
 
 
         override_acl(self.user, {
         override_acl(self.user, {
             'can_rename_users': 1,
             'can_rename_users': 1,
@@ -187,11 +195,10 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             }),
             }),
             content_type='application/json',
             content_type='application/json',
         )
         )
-
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
-        self.assertContains(
-            response, "Username must be at least 3 characters long.", status_code=400
-        )
+        self.assertEqual(response.json(), {
+            "detail": "Username must be at least 3 characters long.",
+        })
 
 
         override_acl(self.user, {
         override_acl(self.user, {
             'can_rename_users': 1,
             'can_rename_users': 1,

+ 9 - 5
misago/users/tests/test_usernamechanges_api.py

@@ -42,21 +42,25 @@ class UsernameChangesApiTests(AuthenticatedUserTestCase):
         override_acl(self.user, {'can_see_users_name_history': False})
         override_acl(self.user, {'can_see_users_name_history': False})
 
 
         response = self.client.get('%s?user=%s&search=new' % (self.link, self.user.pk))
         response = self.client.get('%s?user=%s&search=new' % (self.link, self.user.pk))
-
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
         self.assertContains(response, self.user.username)
 
 
         response = self.client.get('%s?user=%s&search=usernew' % (self.link, self.user.pk))
         response = self.client.get('%s?user=%s&search=usernew' % (self.link, self.user.pk))
-
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, '[]')
+        self.assertEqual(response.json()["count"], 0)
 
 
     def test_list_denies_permission(self):
     def test_list_denies_permission(self):
         """list denies permission for other user (or all) if no access"""
         """list denies permission for other user (or all) if no access"""
         override_acl(self.user, {'can_see_users_name_history': False})
         override_acl(self.user, {'can_see_users_name_history': False})
 
 
         response = self.client.get('%s?user=%s' % (self.link, self.user.pk + 1))
         response = self.client.get('%s?user=%s' % (self.link, self.user.pk + 1))
-        self.assertContains(response, "don't have permission to", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You don't have permission to see other users name history."
+        })
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, "don't have permission to", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You don't have permission to see other users name history."
+        })

+ 39 - 13
misago/users/tests/test_users_api.py

@@ -408,12 +408,18 @@ class UserFollowTests(AuthenticatedUserTestCase):
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.post(self.link)
         response = self.client.post(self.link)
-        self.assertContains(response, "action is not available to guests", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
 
     def test_follow_myself(self):
     def test_follow_myself(self):
         """you can't follow yourself"""
         """you can't follow yourself"""
         response = self.client.post('/api/users/%s/follow/' % self.user.pk)
         response = self.client.post('/api/users/%s/follow/' % self.user.pk)
-        self.assertContains(response, "can't add yourself to followed", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't add yourself to followed.",
+        })
 
 
     def test_cant_follow(self):
     def test_cant_follow(self):
         """no permission to follow users"""
         """no permission to follow users"""
@@ -422,7 +428,10 @@ class UserFollowTests(AuthenticatedUserTestCase):
         })
         })
 
 
         response = self.client.post(self.link)
         response = self.client.post(self.link)
-        self.assertContains(response, "can't follow other users", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't follow other users.",
+        })
 
 
     def test_follow(self):
     def test_follow(self):
         """follow and unfollow other user"""
         """follow and unfollow other user"""
@@ -472,7 +481,10 @@ class UserBanTests(AuthenticatedUserTestCase):
         override_acl(self.user, {'can_see_ban_details': 0})
         override_acl(self.user, {'can_see_ban_details': 0})
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
-        self.assertContains(response, "can't see users bans details", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "You can't see users bans details.",
+        })
 
 
     def test_no_ban(self):
     def test_no_ban(self):
         """api returns empty json"""
         """api returns empty json"""
@@ -480,7 +492,7 @@ class UserBanTests(AuthenticatedUserTestCase):
 
 
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(smart_str(response.content), '{}')
+        self.assertEqual(response.json(), {})
 
 
     def test_ban_details(self):
     def test_ban_details(self):
         """api returns ban json"""
         """api returns ban json"""
@@ -603,7 +615,9 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
 
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-        self.assertContains(response, "can't delete users", status_code=403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't delete users.",
+        })
 
 
     def test_delete_too_many_posts(self):
     def test_delete_too_many_posts(self):
         """raises 403 error when user has too many posts"""
         """raises 403 error when user has too many posts"""
@@ -619,8 +633,9 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
 
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-        self.assertContains(response, "can't delete users", status_code=403)
-        self.assertContains(response, "made more than 5 posts", status_code=403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't delete users that made more than 5 posts.",
+        })
 
 
     def test_delete_too_old_member(self):
     def test_delete_too_old_member(self):
         """raises 403 error when user is too old"""
         """raises 403 error when user is too old"""
@@ -636,8 +651,10 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
 
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-        self.assertContains(response, "can't delete users", status_code=403)
-        self.assertContains(response, "members for more than 5 days", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't delete users that are members for more than 5 days.",
+        })
 
 
     def test_delete_self(self):
     def test_delete_self(self):
         """raises 403 error when attempting to delete oneself"""
         """raises 403 error when attempting to delete oneself"""
@@ -649,7 +666,10 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         )
         )
 
 
         response = self.client.post('/api/users/%s/delete/' % self.user.pk)
         response = self.client.post('/api/users/%s/delete/' % self.user.pk)
-        self.assertContains(response, "can't delete your account", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't delete your account.",
+        })
 
 
     def test_delete_admin(self):
     def test_delete_admin(self):
         """raises 403 error when attempting to delete admin"""
         """raises 403 error when attempting to delete admin"""
@@ -664,7 +684,10 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.other_user.save()
         self.other_user.save()
 
 
         response = self.client.post(self.link)
         response = self.client.post(self.link)
-        self.assertContains(response, "can't delete administrators", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't delete administrators.",
+        })
 
 
     def test_delete_superadmin(self):
     def test_delete_superadmin(self):
         """raises 403 error when attempting to delete superadmin"""
         """raises 403 error when attempting to delete superadmin"""
@@ -679,7 +702,10 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.other_user.save()
         self.other_user.save()
 
 
         response = self.client.post(self.link)
         response = self.client.post(self.link)
-        self.assertContains(response, "can't delete administrators", status_code=403)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "You can't delete administrators.",
+        })
 
 
     def test_delete_with_content(self):
     def test_delete_with_content(self):
         """returns 200 and deletes user with content"""
         """returns 200 and deletes user with content"""