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()
 
         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):
         """api handles no data"""
         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):
         """api handles post that is invalid type"""
         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")
-        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")
-        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")
-        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):
         """api handles empty 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
         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):
         """api handles invalid post type"""
         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):
         """api returns parsed markup for valid post"""
         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})
 
         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):
         """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})
 
         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):
         """no file uploaded scenario is handled"""
         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):
         """uploaded file's extension is rejected as invalid"""
@@ -66,7 +72,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     '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):
         """uploaded file's mimetype is rejected as invalid"""
@@ -82,7 +91,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     '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):
         """user needs permission to upload files of this type"""
@@ -101,7 +113,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     '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):
         """new uploads for this filetype are locked"""
@@ -118,7 +133,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     '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):
         """new uploads for this filetype are disabled"""
@@ -135,7 +153,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     '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):
         """too big uploads are rejected"""
@@ -152,10 +173,13 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     '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):
         """too big uploads are rejected"""
@@ -173,7 +197,12 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     '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):
         """corrupted image upload is handled"""
@@ -188,7 +217,10 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                     '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):
         """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'
 
 
-class EventsAPITests(TestCase):
+class EventsApiTests(TestCase):
     def setUp(self):
         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!",
             }
         )
-        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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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):
         """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})
 
         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):
         """permission to start private thread is validated"""
         override_acl(self.user, {'can_start_private_threads': 0})
 
         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):
         """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()
 
         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):
         """api requires user to have permission to be able to access it"""
         override_acl(self.user, {'can_use_private_threads': 0})
 
         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):
         """api has no showstoppers on returning empty list"""
@@ -79,14 +85,20 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
         self.logout_user()
 
         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):
         """user needs to have permission to see private thread"""
         override_acl(self.user, {'can_use_private_threads': 0})
 
         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):
         """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})
 
         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):
         """permission to edit reply by other users is validated"""
@@ -82,9 +85,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
 
         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):
         """permission to edit reply within timelimit is validated"""
@@ -97,9 +101,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
 
         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):
         """permssion to edit reply in closed category is validated"""
@@ -109,9 +114,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.save()
 
         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
         self.override_acl({'can_close_threads': 1})
@@ -127,9 +133,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.thread.save()
 
         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
         self.override_acl({'can_close_threads': 1})
@@ -145,9 +152,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
 
         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
         self.override_acl({'can_protect_posts': 1})
@@ -160,8 +168,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.override_acl()
 
         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):
         """api errors for invalid request data"""
@@ -172,8 +182,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
             'false',
             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):
         """events can't be edited"""
@@ -183,8 +195,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
 
         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):
         """post is validated"""
@@ -195,7 +209,6 @@ class EditReplyTests(AuthenticatedUserTestCase):
                 'post': "a",
             }
         )
-
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
             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})
 
         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):
         """api validates if thread url was given"""
         self.override_acl({'can_merge_threads': 1})
 
         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):
         """api validates thread url"""
@@ -87,7 +89,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(self.api_link, {
             'other_thread': self.user.get_absolute_url(),
         })
-        self.assertContains(response, "This is not a valid thread link.", status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            "detail": "This is not a valid thread link."
+        })
 
     def test_current_other_thread(self):
         """api validates if thread url given is to current thread"""
@@ -98,7 +103,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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):
         """api validates if other thread exists"""
@@ -112,9 +120,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(self.api_link, {
             '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):
         """api validates if other thread is visible"""
@@ -128,9 +140,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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):
         """api validates if other thread can be merged"""
@@ -144,10 +160,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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):
         """api validates if thread's category is open"""
@@ -169,11 +185,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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):
         """api validates if thread is open"""
@@ -195,11 +210,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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):
         """api validates if other thread's category is open"""
@@ -221,9 +235,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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):
         """api validates if other thread is open"""
@@ -245,9 +260,10 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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):
         """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(),
             }
         )
-        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):
         """api merges two threads successfully"""
@@ -281,7 +298,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -305,7 +327,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         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(),
             }
         )
-        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
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -370,7 +402,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -409,7 +446,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -431,7 +473,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 4)
@@ -460,7 +507,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 4)
@@ -558,7 +610,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -590,7 +647,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -622,7 +684,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -648,7 +715,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -674,7 +746,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -760,7 +837,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -788,7 +870,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -823,7 +910,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
                 '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
         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})
 
         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):
         """api validates that user has permission to start poll in closed thread"""
@@ -51,7 +54,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.thread.save()
 
         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})
 
@@ -66,7 +72,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.category.save()
 
         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})
 
@@ -81,7 +90,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.thread.save()
 
         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})
 
@@ -106,7 +118,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         )
 
         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):
         """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})
 
         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):
         """api validates that user's window to delete poll in thread has closed"""
@@ -88,9 +91,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.poll.save()
 
         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):
         """api validates that user's window to delete poll in thread has closed"""
@@ -101,7 +105,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.poll.save()
 
         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):
         """api validates that user has permission to delete other user poll in thread"""
@@ -111,7 +118,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.poll.save()
 
         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):
         """api validates that user has permission to delete poll in closed thread"""
@@ -121,7 +131,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.thread.save()
 
         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})
 
@@ -136,7 +149,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         self.category.save()
 
         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})
 

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

@@ -78,7 +78,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.override_acl({'can_edit_polls': 0})
 
         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):
         """api validates that user's window to edit poll in thread has closed"""
@@ -88,9 +91,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.poll.save()
 
         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):
         """api validates that user's window to edit poll in thread has closed"""
@@ -101,7 +105,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.poll.save()
 
         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):
         """api validates that user has permission to edit other user poll in thread"""
@@ -111,7 +118,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.poll.save()
 
         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):
         """api validates that user has permission to edit poll in closed thread"""
@@ -121,7 +131,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.thread.save()
 
         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})
 
@@ -136,7 +149,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         self.category.save()
 
         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})
 

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

@@ -190,37 +190,58 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         response = self.client.post(
             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):
         """api validates if vote that user has made was empty"""
         self.delete_user_votes()
 
         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):
         """api validates if vote that user has made was correctly structured"""
         self.delete_user_votes()
 
         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={})
-        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')
-        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)
-        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):
         """api validates if vote that user has made overlaps with allowed votes"""
         self.delete_user_votes()
 
         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):
         """api validates if vote that user has made overlaps with allowed votes"""
@@ -229,19 +250,26 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.poll.save()
 
         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):
         """api validates if user is trying to change vote in poll that disallows revoting"""
         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()
 
         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):
         """api validates is user has permission to vote poll in closed thread"""
@@ -253,12 +281,18 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.delete_user_votes()
 
         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})
 
         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):
         """api validates is user has permission to vote poll in closed category"""
@@ -270,12 +304,18 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.delete_user_votes()
 
         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})
 
         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):
         """api valdiates if poll has finished before letting user to vote in it"""
@@ -286,13 +326,19 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.delete_user_votes()
 
         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.save()
 
         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):
         """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()
 
         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):
         """api handles empty data"""
         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):
         """api requires ids to delete"""
         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):
         """api requires ids to delete"""
         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):
         """api validates that ids are list of ints"""
@@ -60,13 +72,22 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         })
 
         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')
-        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'])
-        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):
         """api validates that ids are list of ints"""
@@ -76,7 +97,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         })
 
         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):
         """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])
-        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):
         """api validates that ids are visible posts"""
@@ -99,7 +126,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts[1].save()
 
         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):
         """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))
 
         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):
         """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])
-        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):
         """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])
-        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):
         """api validates if user can delete protected post"""
@@ -149,9 +186,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts[0].save()
 
         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):
         """api validates if user can delete delete post after edit time"""
@@ -165,9 +203,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts[0].save()
 
         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):
         """api valdiates if user can delete posts in closed threads"""
@@ -180,9 +219,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.thread.save()
 
         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):
         """api valdiates if user can delete posts in closed categories"""
@@ -195,9 +235,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.category.save()
 
         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):
         """api disallows first post's deletion"""
@@ -210,7 +251,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         ids.append(self.thread.first_post_id)
 
         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):
         """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])
         self.assertEqual(response.status_code, 403)
         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):
@@ -237,7 +281,10 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         self.posts[1].save()
 
         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):
         """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()
 
         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):
         """api validates permission to delete post"""
         self.override_acl({'can_hide_own_posts': 1, 'can_hide_posts': 1})
 
         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):
         """api valdiates if user can delete other users posts"""
@@ -49,9 +55,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.post.save()
 
         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):
         """api validates if user can delete protected post"""
@@ -65,9 +72,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.post.save()
 
         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):
         """api validates if user can delete delete post after edit time"""
@@ -81,9 +89,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.post.save()
 
         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):
         """api valdiates if user can delete posts in closed threads"""
@@ -96,9 +105,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.thread.save()
 
         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):
         """api valdiates if user can delete posts in closed categories"""
@@ -111,9 +121,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.category.save()
 
         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):
         """api disallows first post deletion"""
@@ -128,7 +139,10 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         )
 
         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):
         """api disallows best answer deletion"""
@@ -193,7 +207,10 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         self.logout_user()
 
         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):
         """api validates permission to delete event"""
@@ -204,7 +221,10 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         })
 
         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):
         """api valdiates if user can delete events in closed threads"""
@@ -217,9 +237,10 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         self.thread.save()
 
         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):
         """api valdiates if user can delete events in closed categories"""
@@ -232,9 +253,10 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         self.category.save()
 
         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):
         """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):
         """api returns 403 if post has no edits record"""
         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()
 
         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):
         """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})
 
         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):
         """api errors if user has no permission to see likes, but can see likes count"""
         self.override_acl({'can_see_posts_likes': 1})
 
         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):
         """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",
         )
         self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
     def test_no_permission(self):
         """api validates permission to merge"""
@@ -67,42 +70,58 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             json.dumps({}),
             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):
         """api handles empty json data"""
         response = self.client.post(
             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):
         """api handles empty form data"""
         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):
         """api handles post that is invalid type"""
         self.override_acl()
         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()
         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()
         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()
         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):
         """api rejects no posts ids"""
@@ -113,9 +132,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api handles invalid data"""
@@ -126,9 +146,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api handles invalid post id"""
@@ -139,9 +160,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api rejects one post id"""
@@ -152,9 +174,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api rejects more posts than merge limit"""
@@ -165,9 +188,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects events"""
@@ -180,7 +204,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects nonexistant pk's"""
@@ -191,9 +218,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to merge with post made in other thread"""
@@ -207,9 +235,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to merge with post made by deleted user"""
@@ -222,9 +251,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to merge with post made by deleted user"""
@@ -237,9 +267,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to merge posts made by different guests"""
@@ -253,9 +284,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to merge posts with different visibility"""
@@ -271,9 +303,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to merge posts with different approval"""
@@ -289,9 +322,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api validates permission to merge in closed thread"""
@@ -308,11 +342,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             json.dumps({'posts': posts}),
             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
         self.override_acl({'can_close_threads': 1})
@@ -339,11 +372,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             json.dumps({'posts': posts}),
             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
         self.override_acl({'can_close_threads': 1})
@@ -378,7 +410,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 400)
         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):

+ 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")
         self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
     def test_invalid_data(self):
         """api handles post that is invalid type"""
         self.override_acl()
         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()
         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()
         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()
         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):
         """api validates permission to move"""
         self.override_acl({'can_move_posts': 0})
 
         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):
         """api validates if new thread url was given"""
         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):
         """api validates new thread url"""
         response = self.client.post(self.api_link, {
             '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):
         """api validates if new thread url points to current thread"""
@@ -137,9 +161,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
                 '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):
         """api validates if other thread exists"""
@@ -152,9 +177,13 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
             '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):
         """api validates if other thread is visible"""
@@ -167,9 +196,13 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
                 '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):
         """api validates if other thread can be replied"""
@@ -182,18 +215,20 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
                 '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):
         """api handles empty data"""
         other_thread = testutils.post_thread(self.category)
 
         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):
         """api handles empty json data"""
@@ -206,10 +241,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api handles empty form data"""
@@ -221,10 +256,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
                 '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):
         """api rejects no posts ids"""
@@ -238,9 +273,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api handles invalid data"""
@@ -254,9 +290,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api handles invalid post id"""
@@ -270,9 +307,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api rejects more posts than move limit"""
@@ -286,9 +324,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api validates posts visibility"""
@@ -302,9 +341,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to move other thread's post"""
@@ -318,9 +358,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api rejects events move"""
@@ -334,7 +375,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api rejects first post move"""
@@ -348,7 +392,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to move urneadable hidden post"""
@@ -362,9 +409,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to move posts from closed thread"""
@@ -383,9 +431,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to move posts from closed thread"""
@@ -405,9 +454,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """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):
         """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):
         """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()
 
         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):
         """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")
         self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            "detail": "This action is not available to guests.",
+        })
 
     def test_no_permission(self):
         """api validates permission to split"""
         self.override_acl({'can_move_posts': 0})
 
         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):
         """api handles empty data"""
         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):
         """api handles post that is invalid type"""
         self.override_acl()
         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()
         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()
         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()
         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):
         """api rejects no posts ids"""
@@ -136,10 +155,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             json.dumps({}),
             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):
         """api rejects empty posts ids list"""
         response = self.client.post(
@@ -149,9 +168,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api handles invalid data"""
@@ -162,9 +182,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api handles invalid post id"""
@@ -175,9 +196,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api rejects more posts than split limit"""
@@ -188,9 +210,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api validates posts visibility"""
@@ -201,9 +224,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api rejects events split"""
@@ -214,7 +238,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api rejects first post split"""
@@ -225,7 +252,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to split urneadable hidden post"""
@@ -236,9 +266,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to split posts from closed thread"""
@@ -254,9 +285,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to split posts from closed thread"""
@@ -272,9 +304,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """api recjects attempt to split other thread's post"""
@@ -287,9 +320,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }),
             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):
         """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})
 
         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):
         """permssion to reply in closed category is validated"""
@@ -72,11 +73,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.category.save()
 
         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
         self.override_acl({'can_close_threads': 1})
@@ -92,9 +92,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.thread.save()
 
         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
         self.override_acl({'can_close_threads': 1})
@@ -107,8 +108,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.override_acl()
 
         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):
         """api errors for invalid request data"""
@@ -119,8 +122,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             'false',
             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):
         """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, {
             '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):
         """has no permission to browse selected category"""
@@ -60,8 +64,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
             '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):
         """permission to start thread in category is validated"""
@@ -70,10 +78,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
             '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):
         """can't post in closed category"""
@@ -85,8 +95,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, {
             '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):
         """can't post in invalid category"""
@@ -96,8 +110,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.override_acl({'can_close_threads': 0})
 
         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):
         """no data sent handling has no showstoppers"""
@@ -122,8 +143,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'false',
             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):
         """title is validated"""

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

@@ -39,7 +39,10 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         self.logout_user()
 
         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):
         """api requires ids to delete"""
@@ -49,7 +52,10 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         })
 
         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):
         """api validates that ids are list of ints"""
@@ -59,13 +65,22 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         })
 
         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')
-        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'])
-        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):
         """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)))
-        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):
         """api valdiates if user can see deleted thread"""
@@ -96,7 +110,10 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         threads_ids = [p.id for p in self.threads]
 
         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
         for thread in self.threads:
@@ -199,8 +216,9 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         threads_ids = [p.id for p in self.threads]
 
         response = self.delete(self.api_link, threads_ids)
-
         self.assertEqual(response.status_code, 403)
-        self.assertContains(response, "threads to delete could not be found", status_code=403)
+        self.assertEqual(response.json(), {
+            "detail": "One or more threads to delete could not be found.",
+        })
 
         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()
 
         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):
         """endpoint omits non-browseable categories"""
         self.override_acl({'can_browse': 0})
 
         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):
         """endpoint omits category disallowing starting threads"""
         self.override_acl({'can_start_threads': 0})
 
         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):
         """endpoint omits closed category"""
@@ -104,7 +113,10 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
         self.category.save()
 
         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):
         """endpoint adds closed category that allows new threads"""
@@ -271,7 +283,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.logout_user()
 
         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):
         """thread's visibility is validated"""
@@ -292,9 +307,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.override_acl({'can_reply_threads': 0})
 
         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):
         """permssion to reply in closed category is validated"""
@@ -304,11 +320,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.category.save()
 
         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
         self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
@@ -324,9 +339,10 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.thread.save()
 
         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
         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)
 
         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):
         """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)
 
         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):
         """api includes replied to post details in response"""
@@ -418,7 +439,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.logout_user()
 
         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):
         """thread's visibility is validated"""
@@ -439,7 +463,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.override_acl({'can_edit_posts': 0})
 
         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):
         """permssion to edit in closed category is validated"""
@@ -449,9 +476,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.category.save()
 
         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
         self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
@@ -467,9 +495,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.thread.save()
 
         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
         self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
@@ -485,9 +514,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.save()
 
         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
         self.override_acl({'can_edit_posts': 1, 'can_protect_posts': 1})
@@ -503,7 +533,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.save()
 
         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
         self.override_acl({'can_edit_posts': 1, 'can_hide_posts': 1})
@@ -538,8 +571,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.save()
 
         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):
         """api validates if other user's post can be edited"""
@@ -549,9 +584,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.save()
 
         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
         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"""
         response = self.client.post(self.api_link, content_type="application/json")
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "You have to select at least two threads to merge.",
             }
         )
@@ -77,10 +75,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "You have to select at least two threads to merge.",
             }
         )
@@ -94,7 +90,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             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(
             self.api_link,
@@ -104,10 +103,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "One or more thread ids received were invalid.",
             }
         )
@@ -122,10 +119,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "You have to select at least two threads to merge.",
             }
         )
@@ -140,10 +135,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "One or more threads to merge could not be found.",
             }
         )
@@ -160,10 +153,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'detail': "One or more threads to merge could not be found.",
             }
         )
@@ -182,10 +173,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, [
+            response.json(), [
                 {
                     'id': thread.pk,
                     'title': thread.title,
@@ -221,11 +210,19 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             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):
         """api validates if thread is open"""
@@ -249,11 +246,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }),
             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):
         """api rejects too many threads to merge"""
@@ -276,10 +276,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 '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",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ['This field is required.'],
                 'category': ['This field is required.'],
             }
@@ -333,10 +329,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
         )
@@ -362,10 +356,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'category': ["Requested category could not be found."],
             }
         )
@@ -392,10 +384,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'category': ["You can't create new threads in selected category."],
             }
         )
@@ -422,10 +412,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'weight': ["Ensure this value is less than or equal to 2."],
             }
         )
@@ -452,10 +440,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'weight': ["You don't have permission to pin threads globally in this category."],
             }
         )
@@ -482,10 +468,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'weight': ["You don't have permission to pin threads in this category."],
             }
         )
@@ -513,10 +497,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
         )
@@ -544,10 +526,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
         )
@@ -574,10 +554,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'is_closed': ["You don't have permission to close threads in this category."],
             }
         )
@@ -605,10 +583,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'title': ["Thread title should be at least 5 characters long (it has 3)."],
             }
         )
@@ -636,10 +612,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 'is_hidden': ["You don't have permission to hide threads in this category."],
             }
         )
@@ -668,10 +642,8 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-
-        response_json = response.json()
         self.assertEqual(
-            response_json, {
+            response.json(), {
                 '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)
 
-        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
         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)
 
-        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
         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!',
             }
         )
-        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
         response = self.client.post(
@@ -44,7 +47,10 @@ class ValidatePostTests(AuthenticatedUserTestCase):
                 '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
         response = self.client.post(
@@ -65,6 +71,10 @@ class ValidatePostTests(AuthenticatedUserTestCase):
             }
         )
         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(
             self.api_link, data={
@@ -74,3 +84,7 @@ class ValidatePostTests(AuthenticatedUserTestCase):
             }
         )
         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',
             }
         )
-
-        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/')
         self.assertEqual(response.status_code, 200)
@@ -82,7 +85,11 @@ class GatewayTests(TestCase):
     def test_submit_empty(self):
         """login api errors for no body"""
         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):
         """login api errors for invalid data"""
@@ -91,7 +98,10 @@ class GatewayTests(TestCase):
             'false',
             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):
         """login api fails to sign user with not-usable password in"""
@@ -255,7 +265,11 @@ class GatewayTests(TestCase):
                 '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/')
         self.assertEqual(response.status_code, 200)
@@ -271,7 +285,7 @@ class UserCredentialsTests(TestCase):
         self.assertEqual(response.status_code, 200)
 
 
-class SendActivationAPITests(TestCase):
+class SendActivationApiTests(TestCase):
     def setUp(self):
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         self.user.requires_activation = 1
@@ -320,14 +334,22 @@ class SendActivationAPITests(TestCase):
                 '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)
 
     def test_submit_empty(self):
         """request activation link api errors for no body"""
         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)
 
@@ -338,7 +360,10 @@ class SendActivationAPITests(TestCase):
             'false',
             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):
         """request activation link api errors for invalid email"""
@@ -348,7 +373,11 @@ class SendActivationAPITests(TestCase):
                 '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)
 
@@ -363,7 +392,11 @@ class SendActivationAPITests(TestCase):
                 '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):
         """request activation link api errors for admin-activated users"""
@@ -376,7 +409,11 @@ class SendActivationAPITests(TestCase):
                 '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)
 
@@ -394,7 +431,7 @@ class SendActivationAPITests(TestCase):
         self.assertTrue(mail.outbox)
 
 
-class SendPasswordFormAPITests(TestCase):
+class SendPasswordFormApiTests(TestCase):
     def setUp(self):
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
 
@@ -441,14 +478,22 @@ class SendPasswordFormAPITests(TestCase):
                 '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)
 
     def test_submit_empty(self):
         """request change password form link api errors for no body"""
         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)
 
@@ -460,7 +505,11 @@ class SendPasswordFormAPITests(TestCase):
                 '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)
 
@@ -471,7 +520,10 @@ class SendPasswordFormAPITests(TestCase):
             'false',
             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):
         """request change password form link api errors for inactive users"""
@@ -484,7 +536,14 @@ class SendPasswordFormAPITests(TestCase):
                 '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.save()
@@ -495,12 +554,19 @@ class SendPasswordFormAPITests(TestCase):
                 '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)
 
 
-class ChangePasswordAPITests(TestCase):
+class ChangePasswordApiTests(TestCase):
     def setUp(self):
         self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
 
@@ -539,13 +605,18 @@ class ChangePasswordAPITests(TestCase):
             'false',
             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):
         """api errors on invalid user id link"""
         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):
         """request errors because user is banned"""
@@ -558,7 +629,10 @@ class ChangePasswordAPITests(TestCase):
         response = self.client.post(
             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):
         """change password api errors for inactive users"""
@@ -568,7 +642,10 @@ class ChangePasswordAPITests(TestCase):
         response = self.client.post(
             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.save()
@@ -576,7 +653,10 @@ class ChangePasswordAPITests(TestCase):
         response = self.client.post(
             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):
         """change password api errors for disabled users"""
@@ -586,11 +666,17 @@ class ChangePasswordAPITests(TestCase):
         response = self.client.post(
             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):
         """change password api errors for empty body"""
         response = self.client.post(
             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
 
 
-class AuthenticateAPITests(TestCase):
+class AuthenticateApiTests(TestCase):
     def setUp(self):
         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()
 
 
-class AuthenticateAPITests(TestCase):
+class AuthenticateApiTests(TestCase):
     def setUp(self):
         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()
 
         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):
         """requests to api error if user tries to access other user"""
         self.logout_user()
 
         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(
             UserModel.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
         )
 
         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):
         """empty request errors with code 400"""
         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):
         """no gravatar RPC fails"""
@@ -105,7 +118,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.user.save()
 
         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):
         """gravatar RPC passes"""
@@ -113,21 +129,30 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.user.save()
 
         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)
 
     def test_generation_request(self):
         """generated avatar is set"""
         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)
 
     def test_avatar_upload_and_crop(self):
         """avatar can be uploaded and cropped"""
         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:
             response = self.client.post(self.link, data={'avatar': 'upload', 'image': avatar})
@@ -160,7 +185,9 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
         response_json = response.json()
         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.assertOldAvatarsAreDeleted(self.user)
@@ -183,7 +210,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
             }),
             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(
             self.link,
@@ -199,7 +229,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
             }),
             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)
 
         # delete user avatars, test if it deletes src and tmp
@@ -217,8 +250,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         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):
         """gallery validates image to set"""
@@ -234,7 +269,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
                 '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
         response = self.client.post(
@@ -244,7 +282,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
                 '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
         response = self.client.get(self.link)
@@ -261,13 +302,19 @@ class UserAvatarTests(AuthenticatedUserTestCase):
                 '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
         AvatarGallery.objects.filter(pk=test_avatar).update(gallery=gallery.DEFAULT_GALLERY)
 
         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):
         """its possible to set avatar from gallery"""
@@ -287,8 +334,10 @@ class UserAvatarTests(AuthenticatedUserTestCase):
                 '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)
 
 
@@ -309,7 +358,10 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         })
 
         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):
         """moderate avatar"""

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

@@ -41,7 +41,10 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
                 '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):
         """api errors correctly for invalid input"""
@@ -82,7 +85,10 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
                 '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):
         """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"""
         response = self.client.post(self.api_link)
         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):
         """invalid request data errors with code 400"""
@@ -38,19 +43,30 @@ class UserCreateTests(UserTestCase):
             content_type="application/json",
         )
         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):
         """authentiated user request errors with code 403"""
         self.login_user(self.get_authenticated_user())
         response = self.client.post(self.api_link)
         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):
         """registrations off request errors with code 403"""
         settings.override_setting('account_activation', 'closed')
 
         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):
         """api validates ip ban"""
@@ -234,8 +250,10 @@ class UserCreateTests(UserTestCase):
                 '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):
         """api uses django's validate_password to validate registrations"""
@@ -247,10 +265,14 @@ class UserCreateTests(UserTestCase):
                 '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):
         """api uses validate_password to validate registrations"""
@@ -262,8 +284,11 @@ class UserCreateTests(UserTestCase):
                 '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')
     def test_registration_validates_captcha(self):
@@ -391,8 +416,11 @@ class UserCreateTests(UserTestCase):
                 '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):
         """api creates active and signed in user on POST"""
@@ -406,10 +434,12 @@ class UserCreateTests(UserTestCase):
                 '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')
 
@@ -438,10 +468,12 @@ class UserCreateTests(UserTestCase):
                 '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()
         self.assertFalse(auth_json['is_authenticated'])
@@ -463,10 +495,12 @@ class UserCreateTests(UserTestCase):
                 '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()
         self.assertFalse(auth_json['is_authenticated'])
@@ -488,16 +522,17 @@ class UserCreateTests(UserTestCase):
                 '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')
 
         test_user = UserModel.objects.get_by_email('bob@bob.com')
         self.assertEqual(Online.objects.filter(user=test_user).count(), 1)
-
         self.assertTrue(test_user.check_password(' pass123 '))
 
         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)
-        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):
         """locked edit signature returns 403"""
@@ -29,7 +32,11 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         self.user.save()
 
         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):
         """GET to api returns json with no signature"""
@@ -79,7 +86,10 @@ class UserSignatureTests(AuthenticatedUserTestCase):
                 '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):
         """POST with good signature changes user signature"""
@@ -97,12 +107,10 @@ class UserSignatureTests(AuthenticatedUserTestCase):
             },
         )
         self.assertEqual(response.status_code, 200)
-
         self.assertEqual(
             response.json()['signature']['html'], '<p>Hello, <strong>bros</strong>!</p>'
         )
         self.assertEqual(response.json()['signature']['plain'], 'Hello, **bros**!')
 
         self.reload_user()
-
         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)
         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)
 
         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(
             self.link,
@@ -58,15 +56,17 @@ class UserUsernameTests(AuthenticatedUserTestCase):
                 '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')
 
     def test_change_username_no_input(self):
         """api returns error 400 if new username is empty"""
         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):
         """api returns error 400 if new username is wrong"""
@@ -76,8 +76,10 @@ class UserUsernameTests(AuthenticatedUserTestCase):
                 '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):
         """api changes username and records change"""
@@ -122,14 +124,20 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         })
 
         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, {
             'can_rename_users': 0,
         })
 
         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):
         """moderate username"""
@@ -155,8 +163,10 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             }),
             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, {
             'can_rename_users': 1,
@@ -169,12 +179,10 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             }),
             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, {
             'can_rename_users': 1,
@@ -187,11 +195,10 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             }),
             content_type='application/json',
         )
-
         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, {
             '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})
 
         response = self.client.get('%s?user=%s&search=new' % (self.link, self.user.pk))
-
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
 
         response = self.client.get('%s?user=%s&search=usernew' % (self.link, self.user.pk))
-
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, '[]')
+        self.assertEqual(response.json()["count"], 0)
 
     def test_list_denies_permission(self):
         """list denies permission for other user (or all) if no access"""
         override_acl(self.user, {'can_see_users_name_history': False})
 
         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)
-        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()
 
         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):
         """you can't follow yourself"""
         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):
         """no permission to follow users"""
@@ -422,7 +428,10 @@ class UserFollowTests(AuthenticatedUserTestCase):
         })
 
         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):
         """follow and unfollow other user"""
@@ -472,7 +481,10 @@ class UserBanTests(AuthenticatedUserTestCase):
         override_acl(self.user, {'can_see_ban_details': 0})
 
         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):
         """api returns empty json"""
@@ -480,7 +492,7 @@ class UserBanTests(AuthenticatedUserTestCase):
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(smart_str(response.content), '{}')
+        self.assertEqual(response.json(), {})
 
     def test_ban_details(self):
         """api returns ban json"""
@@ -603,7 +615,9 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         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):
         """raises 403 error when user has too many posts"""
@@ -619,8 +633,9 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         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):
         """raises 403 error when user is too old"""
@@ -636,8 +651,10 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         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):
         """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)
-        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):
         """raises 403 error when attempting to delete admin"""
@@ -664,7 +684,10 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.other_user.save()
 
         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):
         """raises 403 error when attempting to delete superadmin"""
@@ -679,7 +702,10 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.other_user.save()
 
         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):
         """returns 200 and deletes user with content"""