test_thread_postmove_api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. import json
  2. from django.urls import reverse
  3. from .. import test
  4. from ...categories.models import Category
  5. from ...conf.test import override_dynamic_settings
  6. from ...readtracker import poststracker
  7. from ...users.test import AuthenticatedUserTestCase
  8. from ..models import Thread
  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. @override_dynamic_settings(posts_per_page=5, posts_per_page_orphans=3)
  234. @patch_category_acl({"can_move_posts": True})
  235. def test_move_limit(self):
  236. """api rejects more posts than move limit"""
  237. other_thread = test.post_thread(self.category)
  238. response = self.client.post(
  239. self.api_link,
  240. json.dumps(
  241. {"new_thread": other_thread.get_absolute_url(), "posts": list(range(9))}
  242. ),
  243. content_type="application/json",
  244. )
  245. self.assertEqual(response.status_code, 400)
  246. self.assertEqual(
  247. response.json(),
  248. {"detail": "No more than 8 posts can be moved at a single time."},
  249. )
  250. @patch_category_acl({"can_move_posts": True})
  251. def test_move_invisible(self):
  252. """api validates posts visibility"""
  253. other_thread = test.post_thread(self.category)
  254. response = self.client.post(
  255. self.api_link,
  256. json.dumps(
  257. {
  258. "new_thread": other_thread.get_absolute_url(),
  259. "posts": [test.reply_thread(self.thread, is_unapproved=True).pk],
  260. }
  261. ),
  262. content_type="application/json",
  263. )
  264. self.assertEqual(response.status_code, 400)
  265. self.assertEqual(
  266. response.json(), {"detail": "One or more posts to move could not be found."}
  267. )
  268. @patch_category_acl({"can_move_posts": True})
  269. def test_move_other_thread_posts(self):
  270. """api recjects attempt to move other thread's post"""
  271. other_thread = test.post_thread(self.category)
  272. response = self.client.post(
  273. self.api_link,
  274. json.dumps(
  275. {
  276. "new_thread": other_thread.get_absolute_url(),
  277. "posts": [test.reply_thread(other_thread, is_hidden=True).pk],
  278. }
  279. ),
  280. content_type="application/json",
  281. )
  282. self.assertEqual(response.status_code, 400)
  283. self.assertEqual(
  284. response.json(), {"detail": "One or more posts to move could not be found."}
  285. )
  286. @patch_category_acl({"can_move_posts": True})
  287. def test_move_event(self):
  288. """api rejects events move"""
  289. other_thread = test.post_thread(self.category)
  290. response = self.client.post(
  291. self.api_link,
  292. json.dumps(
  293. {
  294. "new_thread": other_thread.get_absolute_url(),
  295. "posts": [test.reply_thread(self.thread, is_event=True).pk],
  296. }
  297. ),
  298. content_type="application/json",
  299. )
  300. self.assertEqual(response.status_code, 400)
  301. self.assertEqual(response.json(), {"detail": "Events can't be moved."})
  302. @patch_category_acl({"can_move_posts": True})
  303. def test_move_first_post(self):
  304. """api rejects first post move"""
  305. other_thread = test.post_thread(self.category)
  306. response = self.client.post(
  307. self.api_link,
  308. json.dumps(
  309. {
  310. "new_thread": other_thread.get_absolute_url(),
  311. "posts": [self.thread.first_post_id],
  312. }
  313. ),
  314. content_type="application/json",
  315. )
  316. self.assertEqual(response.status_code, 400)
  317. self.assertEqual(
  318. response.json(), {"detail": "You can't move thread's first post."}
  319. )
  320. @patch_category_acl({"can_move_posts": True})
  321. def test_move_hidden_posts(self):
  322. """api recjects attempt to move urneadable hidden post"""
  323. other_thread = test.post_thread(self.category)
  324. response = self.client.post(
  325. self.api_link,
  326. json.dumps(
  327. {
  328. "new_thread": other_thread.get_absolute_url(),
  329. "posts": [test.reply_thread(self.thread, is_hidden=True).pk],
  330. }
  331. ),
  332. content_type="application/json",
  333. )
  334. self.assertEqual(response.status_code, 400)
  335. self.assertEqual(
  336. response.json(),
  337. {"detail": "You can't move posts the content you can't see."},
  338. )
  339. @patch_category_acl({"can_move_posts": True, "can_close_threads": False})
  340. def test_move_posts_closed_thread_no_permission(self):
  341. """api recjects attempt to move posts from closed thread"""
  342. other_thread = test.post_thread(self.category)
  343. self.thread.is_closed = True
  344. self.thread.save()
  345. response = self.client.post(
  346. self.api_link,
  347. json.dumps(
  348. {
  349. "new_thread": other_thread.get_absolute_url(),
  350. "posts": [test.reply_thread(self.thread).pk],
  351. }
  352. ),
  353. content_type="application/json",
  354. )
  355. self.assertEqual(response.status_code, 400)
  356. self.assertEqual(
  357. response.json(),
  358. {"detail": "This thread is closed. You can't move posts in it."},
  359. )
  360. @patch_other_category_acl({"can_reply_threads": True, "can_close_threads": False})
  361. @patch_category_acl({"can_move_posts": True})
  362. def test_move_posts_closed_category_no_permission(self):
  363. """api recjects attempt to move posts from closed thread"""
  364. other_thread = test.post_thread(self.other_category)
  365. self.category.is_closed = True
  366. self.category.save()
  367. response = self.client.post(
  368. self.api_link,
  369. json.dumps(
  370. {
  371. "new_thread": other_thread.get_absolute_url(),
  372. "posts": [test.reply_thread(self.thread).pk],
  373. }
  374. ),
  375. content_type="application/json",
  376. )
  377. self.assertEqual(response.status_code, 400)
  378. self.assertEqual(
  379. response.json(),
  380. {"detail": "This category is closed. You can't move posts in it."},
  381. )
  382. @patch_other_category_acl({"can_reply_threads": True})
  383. @patch_category_acl({"can_move_posts": True})
  384. def test_move_posts(self):
  385. """api moves posts to other thread"""
  386. other_thread = test.post_thread(self.other_category)
  387. posts = (
  388. test.reply_thread(self.thread).pk,
  389. test.reply_thread(self.thread).pk,
  390. test.reply_thread(self.thread).pk,
  391. test.reply_thread(self.thread).pk,
  392. )
  393. self.thread.refresh_from_db()
  394. self.assertEqual(self.thread.replies, 4)
  395. response = self.client.post(
  396. self.api_link,
  397. json.dumps({"new_thread": other_thread.get_absolute_url(), "posts": posts}),
  398. content_type="application/json",
  399. )
  400. self.assertEqual(response.status_code, 200)
  401. # replies were moved
  402. self.thread.refresh_from_db()
  403. self.assertEqual(self.thread.replies, 0)
  404. other_thread = Thread.objects.get(pk=other_thread.pk)
  405. self.assertEqual(other_thread.post_set.filter(pk__in=posts).count(), 4)
  406. self.assertEqual(other_thread.replies, 4)
  407. @patch_other_category_acl({"can_reply_threads": True})
  408. @patch_category_acl({"can_move_posts": True})
  409. def test_move_best_answer(self):
  410. """api moves best answer to other thread"""
  411. other_thread = test.post_thread(self.other_category)
  412. best_answer = test.reply_thread(self.thread)
  413. self.thread.set_best_answer(self.user, best_answer)
  414. self.thread.synchronize()
  415. self.thread.save()
  416. self.thread.refresh_from_db()
  417. self.assertEqual(self.thread.best_answer, best_answer)
  418. self.assertEqual(self.thread.replies, 1)
  419. response = self.client.post(
  420. self.api_link,
  421. json.dumps(
  422. {
  423. "new_thread": other_thread.get_absolute_url(),
  424. "posts": [best_answer.pk],
  425. }
  426. ),
  427. content_type="application/json",
  428. )
  429. self.assertEqual(response.status_code, 200)
  430. # best_answer was moved and unmarked
  431. self.thread.refresh_from_db()
  432. self.assertEqual(self.thread.replies, 0)
  433. self.assertIsNone(self.thread.best_answer)
  434. other_thread = Thread.objects.get(pk=other_thread.pk)
  435. self.assertEqual(other_thread.replies, 1)
  436. self.assertIsNone(other_thread.best_answer)
  437. @patch_other_category_acl({"can_reply_threads": True})
  438. @patch_category_acl({"can_move_posts": True})
  439. def test_move_posts_reads(self):
  440. """api moves posts reads together with posts"""
  441. other_thread = test.post_thread(self.other_category)
  442. posts = (test.reply_thread(self.thread), test.reply_thread(self.thread))
  443. self.thread.refresh_from_db()
  444. self.assertEqual(self.thread.replies, 2)
  445. poststracker.save_read(self.user, self.thread.first_post)
  446. for post in posts:
  447. poststracker.save_read(self.user, post)
  448. response = self.client.post(
  449. self.api_link,
  450. json.dumps(
  451. {
  452. "new_thread": other_thread.get_absolute_url(),
  453. "posts": [p.pk for p in posts],
  454. }
  455. ),
  456. content_type="application/json",
  457. )
  458. self.assertEqual(response.status_code, 200)
  459. other_thread = Thread.objects.get(pk=other_thread.pk)
  460. # postreads were removed
  461. postreads = self.user.postread_set.order_by("id")
  462. postreads_threads = list(postreads.values_list("thread_id", flat=True))
  463. self.assertEqual(postreads_threads, [self.thread.pk])
  464. postreads_categories = list(postreads.values_list("category_id", flat=True))
  465. self.assertEqual(postreads_categories, [self.category.pk])