test_thread_postmove_api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. import json
  2. from django.urls import reverse
  3. from .. import test
  4. from ...categories.models import Category
  5. from ...readtracker import poststracker
  6. from ...users.test import AuthenticatedUserTestCase
  7. from ..models import Thread
  8. from ..serializers.moderation import POSTS_LIMIT
  9. from ..test import patch_category_acl, patch_other_category_acl
  10. class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
  11. def setUp(self):
  12. super().setUp()
  13. self.category = Category.objects.get(slug="first-category")
  14. self.thread = test.post_thread(category=self.category)
  15. self.api_link = reverse(
  16. "misago:api:thread-post-move", kwargs={"thread_pk": self.thread.pk}
  17. )
  18. Category(name="Other category", slug="other-category").insert_at(
  19. self.category, position="last-child", save=True
  20. )
  21. self.other_category = Category.objects.get(slug="other-category")
  22. def test_anonymous_user(self):
  23. """you need to authenticate to move posts"""
  24. self.logout_user()
  25. response = self.client.post(
  26. self.api_link, json.dumps({}), content_type="application/json"
  27. )
  28. self.assertEqual(response.status_code, 403)
  29. self.assertEqual(
  30. response.json(), {"detail": "This action is not available to guests."}
  31. )
  32. @patch_category_acl({"can_move_posts": True})
  33. def test_invalid_data(self):
  34. """api handles post that is invalid type"""
  35. response = self.client.post(
  36. self.api_link, "[]", content_type="application/json"
  37. )
  38. self.assertEqual(response.status_code, 400)
  39. self.assertEqual(
  40. response.json(),
  41. {"detail": "Invalid data. Expected a dictionary, but got list."},
  42. )
  43. response = self.client.post(
  44. self.api_link, "123", content_type="application/json"
  45. )
  46. self.assertEqual(response.status_code, 400)
  47. self.assertEqual(
  48. response.json(),
  49. {"detail": "Invalid data. Expected a dictionary, but got int."},
  50. )
  51. response = self.client.post(
  52. self.api_link, '"string"', content_type="application/json"
  53. )
  54. self.assertEqual(response.status_code, 400)
  55. self.assertEqual(
  56. response.json(),
  57. {"detail": "Invalid data. Expected a dictionary, but got str."},
  58. )
  59. response = self.client.post(
  60. self.api_link, "malformed", content_type="application/json"
  61. )
  62. self.assertEqual(response.status_code, 400)
  63. self.assertEqual(
  64. response.json(),
  65. {"detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)"},
  66. )
  67. @patch_category_acl({"can_move_posts": False})
  68. def test_no_permission(self):
  69. """api validates permission to move"""
  70. response = self.client.post(
  71. self.api_link, json.dumps({}), content_type="application/json"
  72. )
  73. self.assertEqual(response.status_code, 403)
  74. self.assertEqual(
  75. response.json(), {"detail": "You can't move posts in this thread."}
  76. )
  77. @patch_category_acl({"can_move_posts": True})
  78. def test_move_no_new_thread_url(self):
  79. """api validates if new thread url was given"""
  80. response = self.client.post(self.api_link)
  81. self.assertEqual(response.status_code, 400)
  82. self.assertEqual(response.json(), {"detail": "Enter link to new thread."})
  83. @patch_category_acl({"can_move_posts": True})
  84. def test_invalid_new_thread_url(self):
  85. """api validates new thread url"""
  86. response = self.client.post(
  87. self.api_link, {"new_thread": self.user.get_absolute_url()}
  88. )
  89. self.assertEqual(response.status_code, 400)
  90. self.assertEqual(
  91. response.json(), {"detail": "This is not a valid thread link."}
  92. )
  93. @patch_category_acl({"can_move_posts": True})
  94. def test_current_new_thread_url(self):
  95. """api validates if new thread url points to current thread"""
  96. response = self.client.post(
  97. self.api_link, {"new_thread": self.thread.get_absolute_url()}
  98. )
  99. self.assertEqual(response.status_code, 400)
  100. self.assertEqual(
  101. response.json(),
  102. {"detail": "Thread to move posts to is same as current one."},
  103. )
  104. @patch_other_category_acl({"can_see": False})
  105. @patch_category_acl({"can_move_posts": True})
  106. def test_other_thread_exists(self):
  107. """api validates if other thread exists"""
  108. other_thread = test.post_thread(self.other_category)
  109. response = self.client.post(
  110. self.api_link, {"new_thread": other_thread.get_absolute_url()}
  111. )
  112. self.assertEqual(response.status_code, 400)
  113. self.assertEqual(
  114. response.json(),
  115. {
  116. "detail": (
  117. "The thread you have entered link to doesn't exist "
  118. "or you don't have permission to see it."
  119. )
  120. },
  121. )
  122. @patch_other_category_acl({"can_browse": False})
  123. @patch_category_acl({"can_move_posts": True})
  124. def test_other_thread_is_invisible(self):
  125. """api validates if other thread is visible"""
  126. other_thread = test.post_thread(self.other_category)
  127. response = self.client.post(
  128. self.api_link, {"new_thread": other_thread.get_absolute_url()}
  129. )
  130. self.assertEqual(response.status_code, 400)
  131. self.assertEqual(
  132. response.json(),
  133. {
  134. "detail": (
  135. "The thread you have entered link to doesn't exist "
  136. "or you don't have permission to see it."
  137. )
  138. },
  139. )
  140. @patch_other_category_acl({"can_reply_threads": False})
  141. @patch_category_acl({"can_move_posts": True})
  142. def test_other_thread_isnt_replyable(self):
  143. """api validates if other thread can be replied"""
  144. other_thread = test.post_thread(self.other_category)
  145. response = self.client.post(
  146. self.api_link, {"new_thread": other_thread.get_absolute_url()}
  147. )
  148. self.assertEqual(response.status_code, 400)
  149. self.assertEqual(
  150. response.json(),
  151. {"detail": "You can't move posts to threads you can't reply."},
  152. )
  153. @patch_category_acl({"can_move_posts": True})
  154. def test_empty_data(self):
  155. """api handles empty data"""
  156. test.post_thread(self.category)
  157. response = self.client.post(self.api_link)
  158. self.assertEqual(response.status_code, 400)
  159. self.assertEqual(response.json(), {"detail": "Enter link to new thread."})
  160. @patch_category_acl({"can_move_posts": True})
  161. def test_empty_posts_data_json(self):
  162. """api handles empty json data"""
  163. other_thread = test.post_thread(self.category)
  164. response = self.client.post(
  165. self.api_link,
  166. json.dumps({"new_thread": other_thread.get_absolute_url()}),
  167. content_type="application/json",
  168. )
  169. self.assertEqual(response.status_code, 400)
  170. self.assertEqual(
  171. response.json(),
  172. {"detail": "You have to specify at least one post to move."},
  173. )
  174. @patch_category_acl({"can_move_posts": True})
  175. def test_empty_posts_data_form(self):
  176. """api handles empty form data"""
  177. other_thread = test.post_thread(self.category)
  178. response = self.client.post(
  179. self.api_link, {"new_thread": other_thread.get_absolute_url()}
  180. )
  181. self.assertEqual(response.status_code, 400)
  182. self.assertEqual(
  183. response.json(),
  184. {"detail": "You have to specify at least one post to move."},
  185. )
  186. @patch_category_acl({"can_move_posts": True})
  187. def test_no_posts_ids(self):
  188. """api rejects no posts ids"""
  189. other_thread = test.post_thread(self.category)
  190. response = self.client.post(
  191. self.api_link,
  192. json.dumps({"new_thread": other_thread.get_absolute_url(), "posts": []}),
  193. content_type="application/json",
  194. )
  195. self.assertEqual(response.status_code, 400)
  196. self.assertEqual(
  197. response.json(),
  198. {"detail": "You have to specify at least one post to move."},
  199. )
  200. @patch_category_acl({"can_move_posts": True})
  201. def test_invalid_posts_data(self):
  202. """api handles invalid data"""
  203. other_thread = test.post_thread(self.category)
  204. response = self.client.post(
  205. self.api_link,
  206. json.dumps(
  207. {"new_thread": other_thread.get_absolute_url(), "posts": "string"}
  208. ),
  209. content_type="application/json",
  210. )
  211. self.assertEqual(response.status_code, 400)
  212. self.assertEqual(
  213. response.json(), {"detail": 'Expected a list of items but got type "str".'}
  214. )
  215. @patch_category_acl({"can_move_posts": True})
  216. def test_invalid_posts_ids(self):
  217. """api handles invalid post id"""
  218. other_thread = test.post_thread(self.category)
  219. response = self.client.post(
  220. self.api_link,
  221. json.dumps(
  222. {
  223. "new_thread": other_thread.get_absolute_url(),
  224. "posts": [1, 2, "string"],
  225. }
  226. ),
  227. content_type="application/json",
  228. )
  229. self.assertEqual(response.status_code, 400)
  230. self.assertEqual(
  231. response.json(), {"detail": "One or more post ids received were invalid."}
  232. )
  233. @patch_category_acl({"can_move_posts": True})
  234. def test_move_limit(self):
  235. """api rejects more posts than move limit"""
  236. other_thread = test.post_thread(self.category)
  237. response = self.client.post(
  238. self.api_link,
  239. json.dumps(
  240. {
  241. "new_thread": other_thread.get_absolute_url(),
  242. "posts": list(range(POSTS_LIMIT + 1)),
  243. }
  244. ),
  245. content_type="application/json",
  246. )
  247. self.assertEqual(response.status_code, 400)
  248. self.assertEqual(
  249. response.json(),
  250. {
  251. "detail": "No more than %s posts can be moved at single time."
  252. % POSTS_LIMIT
  253. },
  254. )
  255. @patch_category_acl({"can_move_posts": True})
  256. def test_move_invisible(self):
  257. """api validates posts visibility"""
  258. other_thread = test.post_thread(self.category)
  259. response = self.client.post(
  260. self.api_link,
  261. json.dumps(
  262. {
  263. "new_thread": other_thread.get_absolute_url(),
  264. "posts": [test.reply_thread(self.thread, is_unapproved=True).pk],
  265. }
  266. ),
  267. content_type="application/json",
  268. )
  269. self.assertEqual(response.status_code, 400)
  270. self.assertEqual(
  271. response.json(), {"detail": "One or more posts to move could not be found."}
  272. )
  273. @patch_category_acl({"can_move_posts": True})
  274. def test_move_other_thread_posts(self):
  275. """api recjects attempt to move other thread's post"""
  276. other_thread = test.post_thread(self.category)
  277. response = self.client.post(
  278. self.api_link,
  279. json.dumps(
  280. {
  281. "new_thread": other_thread.get_absolute_url(),
  282. "posts": [test.reply_thread(other_thread, is_hidden=True).pk],
  283. }
  284. ),
  285. content_type="application/json",
  286. )
  287. self.assertEqual(response.status_code, 400)
  288. self.assertEqual(
  289. response.json(), {"detail": "One or more posts to move could not be found."}
  290. )
  291. @patch_category_acl({"can_move_posts": True})
  292. def test_move_event(self):
  293. """api rejects events move"""
  294. other_thread = test.post_thread(self.category)
  295. response = self.client.post(
  296. self.api_link,
  297. json.dumps(
  298. {
  299. "new_thread": other_thread.get_absolute_url(),
  300. "posts": [test.reply_thread(self.thread, is_event=True).pk],
  301. }
  302. ),
  303. content_type="application/json",
  304. )
  305. self.assertEqual(response.status_code, 400)
  306. self.assertEqual(response.json(), {"detail": "Events can't be moved."})
  307. @patch_category_acl({"can_move_posts": True})
  308. def test_move_first_post(self):
  309. """api rejects first post move"""
  310. other_thread = test.post_thread(self.category)
  311. response = self.client.post(
  312. self.api_link,
  313. json.dumps(
  314. {
  315. "new_thread": other_thread.get_absolute_url(),
  316. "posts": [self.thread.first_post_id],
  317. }
  318. ),
  319. content_type="application/json",
  320. )
  321. self.assertEqual(response.status_code, 400)
  322. self.assertEqual(
  323. response.json(), {"detail": "You can't move thread's first post."}
  324. )
  325. @patch_category_acl({"can_move_posts": True})
  326. def test_move_hidden_posts(self):
  327. """api recjects attempt to move urneadable hidden post"""
  328. other_thread = test.post_thread(self.category)
  329. response = self.client.post(
  330. self.api_link,
  331. json.dumps(
  332. {
  333. "new_thread": other_thread.get_absolute_url(),
  334. "posts": [test.reply_thread(self.thread, is_hidden=True).pk],
  335. }
  336. ),
  337. content_type="application/json",
  338. )
  339. self.assertEqual(response.status_code, 400)
  340. self.assertEqual(
  341. response.json(),
  342. {"detail": "You can't move posts the content you can't see."},
  343. )
  344. @patch_category_acl({"can_move_posts": True, "can_close_threads": False})
  345. def test_move_posts_closed_thread_no_permission(self):
  346. """api recjects attempt to move posts from closed thread"""
  347. other_thread = test.post_thread(self.category)
  348. self.thread.is_closed = True
  349. self.thread.save()
  350. response = self.client.post(
  351. self.api_link,
  352. json.dumps(
  353. {
  354. "new_thread": other_thread.get_absolute_url(),
  355. "posts": [test.reply_thread(self.thread).pk],
  356. }
  357. ),
  358. content_type="application/json",
  359. )
  360. self.assertEqual(response.status_code, 400)
  361. self.assertEqual(
  362. response.json(),
  363. {"detail": "This thread is closed. You can't move posts in it."},
  364. )
  365. @patch_other_category_acl({"can_reply_threads": True, "can_close_threads": False})
  366. @patch_category_acl({"can_move_posts": True})
  367. def test_move_posts_closed_category_no_permission(self):
  368. """api recjects attempt to move posts from closed thread"""
  369. other_thread = test.post_thread(self.other_category)
  370. self.category.is_closed = True
  371. self.category.save()
  372. response = self.client.post(
  373. self.api_link,
  374. json.dumps(
  375. {
  376. "new_thread": other_thread.get_absolute_url(),
  377. "posts": [test.reply_thread(self.thread).pk],
  378. }
  379. ),
  380. content_type="application/json",
  381. )
  382. self.assertEqual(response.status_code, 400)
  383. self.assertEqual(
  384. response.json(),
  385. {"detail": "This category is closed. You can't move posts in it."},
  386. )
  387. @patch_other_category_acl({"can_reply_threads": True})
  388. @patch_category_acl({"can_move_posts": True})
  389. def test_move_posts(self):
  390. """api moves posts to other thread"""
  391. other_thread = test.post_thread(self.other_category)
  392. posts = (
  393. test.reply_thread(self.thread).pk,
  394. test.reply_thread(self.thread).pk,
  395. test.reply_thread(self.thread).pk,
  396. test.reply_thread(self.thread).pk,
  397. )
  398. self.thread.refresh_from_db()
  399. self.assertEqual(self.thread.replies, 4)
  400. response = self.client.post(
  401. self.api_link,
  402. json.dumps({"new_thread": other_thread.get_absolute_url(), "posts": posts}),
  403. content_type="application/json",
  404. )
  405. self.assertEqual(response.status_code, 200)
  406. # replies were moved
  407. self.thread.refresh_from_db()
  408. self.assertEqual(self.thread.replies, 0)
  409. other_thread = Thread.objects.get(pk=other_thread.pk)
  410. self.assertEqual(other_thread.post_set.filter(pk__in=posts).count(), 4)
  411. self.assertEqual(other_thread.replies, 4)
  412. @patch_other_category_acl({"can_reply_threads": True})
  413. @patch_category_acl({"can_move_posts": True})
  414. def test_move_best_answer(self):
  415. """api moves best answer to other thread"""
  416. other_thread = test.post_thread(self.other_category)
  417. best_answer = test.reply_thread(self.thread)
  418. self.thread.set_best_answer(self.user, best_answer)
  419. self.thread.synchronize()
  420. self.thread.save()
  421. self.thread.refresh_from_db()
  422. self.assertEqual(self.thread.best_answer, best_answer)
  423. self.assertEqual(self.thread.replies, 1)
  424. response = self.client.post(
  425. self.api_link,
  426. json.dumps(
  427. {
  428. "new_thread": other_thread.get_absolute_url(),
  429. "posts": [best_answer.pk],
  430. }
  431. ),
  432. content_type="application/json",
  433. )
  434. self.assertEqual(response.status_code, 200)
  435. # best_answer was moved and unmarked
  436. self.thread.refresh_from_db()
  437. self.assertEqual(self.thread.replies, 0)
  438. self.assertIsNone(self.thread.best_answer)
  439. other_thread = Thread.objects.get(pk=other_thread.pk)
  440. self.assertEqual(other_thread.replies, 1)
  441. self.assertIsNone(other_thread.best_answer)
  442. @patch_other_category_acl({"can_reply_threads": True})
  443. @patch_category_acl({"can_move_posts": True})
  444. def test_move_posts_reads(self):
  445. """api moves posts reads together with posts"""
  446. other_thread = test.post_thread(self.other_category)
  447. posts = (test.reply_thread(self.thread), test.reply_thread(self.thread))
  448. self.thread.refresh_from_db()
  449. self.assertEqual(self.thread.replies, 2)
  450. poststracker.save_read(self.user, self.thread.first_post)
  451. for post in posts:
  452. poststracker.save_read(self.user, post)
  453. response = self.client.post(
  454. self.api_link,
  455. json.dumps(
  456. {
  457. "new_thread": other_thread.get_absolute_url(),
  458. "posts": [p.pk for p in posts],
  459. }
  460. ),
  461. content_type="application/json",
  462. )
  463. self.assertEqual(response.status_code, 200)
  464. other_thread = Thread.objects.get(pk=other_thread.pk)
  465. # postreads were removed
  466. postreads = self.user.postread_set.order_by("id")
  467. postreads_threads = list(postreads.values_list("thread_id", flat=True))
  468. self.assertEqual(postreads_threads, [self.thread.pk])
  469. postreads_categories = list(postreads.values_list("category_id", flat=True))
  470. self.assertEqual(postreads_categories, [self.category.pk])