test_thread_postmerge_api.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  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 Post
  8. from ..serializers.moderation import POSTS_LIMIT
  9. from ..test import patch_category_acl
  10. class ThreadPostMergeApiTestCase(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.post = test.reply_thread(self.thread, poster=self.user)
  16. self.api_link = reverse(
  17. "misago:api:thread-post-merge", kwargs={"thread_pk": self.thread.pk}
  18. )
  19. def test_anonymous_user(self):
  20. """you need to authenticate to merge posts"""
  21. self.logout_user()
  22. response = self.client.post(
  23. self.api_link, json.dumps({}), content_type="application/json"
  24. )
  25. self.assertEqual(response.status_code, 403)
  26. self.assertEqual(
  27. response.json(), {"detail": "This action is not available to guests."}
  28. )
  29. @patch_category_acl({"can_merge_posts": False})
  30. def test_no_permission(self):
  31. """api validates permission to merge"""
  32. response = self.client.post(
  33. self.api_link, json.dumps({}), content_type="application/json"
  34. )
  35. self.assertEqual(response.status_code, 403)
  36. self.assertEqual(
  37. response.json(), {"detail": "You can't merge posts in this thread."}
  38. )
  39. @patch_category_acl({"can_merge_posts": True})
  40. def test_empty_data_json(self):
  41. """api handles empty json data"""
  42. response = self.client.post(
  43. self.api_link, json.dumps({}), content_type="application/json"
  44. )
  45. self.assertEqual(response.status_code, 400)
  46. self.assertEqual(
  47. response.json(),
  48. {"detail": "You have to select at least two posts to merge."},
  49. )
  50. @patch_category_acl({"can_merge_posts": True})
  51. def test_empty_data_form(self):
  52. """api handles empty form data"""
  53. response = self.client.post(self.api_link, {})
  54. self.assertEqual(response.status_code, 400)
  55. self.assertEqual(
  56. response.json(),
  57. {"detail": "You have to select at least two posts to merge."},
  58. )
  59. @patch_category_acl({"can_merge_posts": True})
  60. def test_invalid_data(self):
  61. """api handles post that is invalid type"""
  62. response = self.client.post(
  63. self.api_link, "[]", content_type="application/json"
  64. )
  65. self.assertEqual(response.status_code, 400)
  66. self.assertEqual(
  67. response.json(),
  68. {"detail": "Invalid data. Expected a dictionary, but got list."},
  69. )
  70. response = self.client.post(
  71. self.api_link, "123", content_type="application/json"
  72. )
  73. self.assertEqual(response.status_code, 400)
  74. self.assertEqual(
  75. response.json(),
  76. {"detail": "Invalid data. Expected a dictionary, but got int."},
  77. )
  78. response = self.client.post(
  79. self.api_link, '"string"', content_type="application/json"
  80. )
  81. self.assertEqual(response.status_code, 400)
  82. self.assertEqual(
  83. response.json(),
  84. {"detail": "Invalid data. Expected a dictionary, but got str."},
  85. )
  86. response = self.client.post(
  87. self.api_link, "malformed", content_type="application/json"
  88. )
  89. self.assertEqual(response.status_code, 400)
  90. self.assertEqual(
  91. response.json(),
  92. {"detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)"},
  93. )
  94. @patch_category_acl({"can_merge_posts": True})
  95. def test_no_posts_ids(self):
  96. """api rejects no posts ids"""
  97. response = self.client.post(
  98. self.api_link, json.dumps({"posts": []}), content_type="application/json"
  99. )
  100. self.assertEqual(response.status_code, 400)
  101. self.assertEqual(
  102. response.json(),
  103. {"detail": "You have to select at least two posts to merge."},
  104. )
  105. @patch_category_acl({"can_merge_posts": True})
  106. def test_invalid_posts_data(self):
  107. """api handles invalid data"""
  108. response = self.client.post(
  109. self.api_link,
  110. json.dumps({"posts": "string"}),
  111. content_type="application/json",
  112. )
  113. self.assertEqual(response.status_code, 400)
  114. self.assertEqual(
  115. response.json(), {"detail": 'Expected a list of items but got type "str".'}
  116. )
  117. @patch_category_acl({"can_merge_posts": True})
  118. def test_invalid_posts_ids(self):
  119. """api handles invalid post id"""
  120. response = self.client.post(
  121. self.api_link,
  122. json.dumps({"posts": [1, 2, "string"]}),
  123. content_type="application/json",
  124. )
  125. self.assertEqual(response.status_code, 400)
  126. self.assertEqual(
  127. response.json(), {"detail": "One or more post ids received were invalid."}
  128. )
  129. @patch_category_acl({"can_merge_posts": True})
  130. def test_one_post_id(self):
  131. """api rejects one post id"""
  132. response = self.client.post(
  133. self.api_link, json.dumps({"posts": [1]}), content_type="application/json"
  134. )
  135. self.assertEqual(response.status_code, 400)
  136. self.assertEqual(
  137. response.json(),
  138. {"detail": "You have to select at least two posts to merge."},
  139. )
  140. @patch_category_acl({"can_merge_posts": True})
  141. def test_merge_limit(self):
  142. """api rejects more posts than merge limit"""
  143. response = self.client.post(
  144. self.api_link,
  145. json.dumps({"posts": list(range(POSTS_LIMIT + 1))}),
  146. content_type="application/json",
  147. )
  148. self.assertEqual(response.status_code, 400)
  149. self.assertEqual(
  150. response.json(),
  151. {
  152. "detail": "No more than %s posts can be merged at single time."
  153. % POSTS_LIMIT
  154. },
  155. )
  156. @patch_category_acl({"can_merge_posts": True})
  157. def test_merge_event(self):
  158. """api recjects events"""
  159. event = test.reply_thread(self.thread, is_event=True, poster=self.user)
  160. response = self.client.post(
  161. self.api_link,
  162. json.dumps({"posts": [self.post.pk, event.pk]}),
  163. content_type="application/json",
  164. )
  165. self.assertEqual(response.status_code, 400)
  166. self.assertEqual(response.json(), {"detail": "Events can't be merged."})
  167. @patch_category_acl({"can_merge_posts": True})
  168. def test_merge_notfound_pk(self):
  169. """api recjects nonexistant pk's"""
  170. response = self.client.post(
  171. self.api_link,
  172. json.dumps({"posts": [self.post.pk, self.post.pk * 1000]}),
  173. content_type="application/json",
  174. )
  175. self.assertEqual(response.status_code, 400)
  176. self.assertEqual(
  177. response.json(),
  178. {"detail": "One or more posts to merge could not be found."},
  179. )
  180. @patch_category_acl({"can_merge_posts": True})
  181. def test_merge_cross_threads(self):
  182. """api recjects attempt to merge with post made in other thread"""
  183. other_thread = test.post_thread(category=self.category)
  184. other_post = test.reply_thread(other_thread, poster=self.user)
  185. response = self.client.post(
  186. self.api_link,
  187. json.dumps({"posts": [self.post.pk, other_post.pk]}),
  188. content_type="application/json",
  189. )
  190. self.assertEqual(response.status_code, 400)
  191. self.assertEqual(
  192. response.json(),
  193. {"detail": "One or more posts to merge could not be found."},
  194. )
  195. @patch_category_acl({"can_merge_posts": True})
  196. def test_merge_authenticated_with_guest_post(self):
  197. """api recjects attempt to merge with post made by deleted user"""
  198. other_post = test.reply_thread(self.thread)
  199. response = self.client.post(
  200. self.api_link,
  201. json.dumps({"posts": [self.post.pk, other_post.pk]}),
  202. content_type="application/json",
  203. )
  204. self.assertEqual(response.status_code, 400)
  205. self.assertEqual(
  206. response.json(),
  207. {"detail": "Posts made by different users can't be merged."},
  208. )
  209. @patch_category_acl({"can_merge_posts": True})
  210. def test_merge_guest_with_authenticated_post(self):
  211. """api recjects attempt to merge with post made by deleted user"""
  212. other_post = test.reply_thread(self.thread)
  213. response = self.client.post(
  214. self.api_link,
  215. json.dumps({"posts": [other_post.pk, self.post.pk]}),
  216. content_type="application/json",
  217. )
  218. self.assertEqual(response.status_code, 400)
  219. self.assertEqual(
  220. response.json(),
  221. {"detail": "Posts made by different users can't be merged."},
  222. )
  223. @patch_category_acl({"can_merge_posts": True})
  224. def test_merge_guest_posts_different_usernames(self):
  225. """api recjects attempt to merge posts made by different guests"""
  226. response = self.client.post(
  227. self.api_link,
  228. json.dumps(
  229. {
  230. "posts": [
  231. test.reply_thread(self.thread, poster="Bob").pk,
  232. test.reply_thread(self.thread, poster="Miku").pk,
  233. ]
  234. }
  235. ),
  236. content_type="application/json",
  237. )
  238. self.assertEqual(response.status_code, 400)
  239. self.assertEqual(
  240. response.json(),
  241. {"detail": "Posts made by different users can't be merged."},
  242. )
  243. @patch_category_acl({"can_merge_posts": True, "can_hide_posts": 1})
  244. def test_merge_different_visibility(self):
  245. """api recjects attempt to merge posts with different visibility"""
  246. response = self.client.post(
  247. self.api_link,
  248. json.dumps(
  249. {
  250. "posts": [
  251. test.reply_thread(
  252. self.thread, poster=self.user, is_hidden=True
  253. ).pk,
  254. test.reply_thread(
  255. self.thread, poster=self.user, is_hidden=False
  256. ).pk,
  257. ]
  258. }
  259. ),
  260. content_type="application/json",
  261. )
  262. self.assertEqual(response.status_code, 400)
  263. self.assertEqual(
  264. response.json(),
  265. {"detail": "Posts with different visibility can't be merged."},
  266. )
  267. @patch_category_acl({"can_merge_posts": True, "can_approve_content": True})
  268. def test_merge_different_approval(self):
  269. """api recjects attempt to merge posts with different approval"""
  270. response = self.client.post(
  271. self.api_link,
  272. json.dumps(
  273. {
  274. "posts": [
  275. test.reply_thread(
  276. self.thread, poster=self.user, is_unapproved=True
  277. ).pk,
  278. test.reply_thread(
  279. self.thread, poster=self.user, is_unapproved=False
  280. ).pk,
  281. ]
  282. }
  283. ),
  284. content_type="application/json",
  285. )
  286. self.assertEqual(response.status_code, 400)
  287. self.assertEqual(
  288. response.json(),
  289. {"detail": "Posts with different visibility can't be merged."},
  290. )
  291. @patch_category_acl({"can_merge_posts": True, "can_close_threads": False})
  292. def test_closed_thread_no_permission(self):
  293. """api validates permission to merge in closed thread"""
  294. self.thread.is_closed = True
  295. self.thread.save()
  296. posts = [
  297. test.reply_thread(self.thread, poster=self.user).pk,
  298. test.reply_thread(self.thread, poster=self.user).pk,
  299. ]
  300. response = self.client.post(
  301. self.api_link, json.dumps({"posts": posts}), content_type="application/json"
  302. )
  303. self.assertEqual(response.status_code, 400)
  304. self.assertEqual(
  305. response.json(),
  306. {"detail": "This thread is closed. You can't merge posts in it."},
  307. )
  308. @patch_category_acl({"can_merge_posts": True, "can_close_threads": True})
  309. def test_closed_thread(self):
  310. """api validates permission to merge in closed thread"""
  311. self.thread.is_closed = True
  312. self.thread.save()
  313. posts = [
  314. test.reply_thread(self.thread, poster=self.user).pk,
  315. test.reply_thread(self.thread, poster=self.user).pk,
  316. ]
  317. response = self.client.post(
  318. self.api_link, json.dumps({"posts": posts}), content_type="application/json"
  319. )
  320. self.assertEqual(response.status_code, 200)
  321. @patch_category_acl({"can_merge_posts": True, "can_close_threads": False})
  322. def test_closed_category_no_permission(self):
  323. """api validates permission to merge in closed category"""
  324. self.category.is_closed = True
  325. self.category.save()
  326. posts = [
  327. test.reply_thread(self.thread, poster=self.user).pk,
  328. test.reply_thread(self.thread, poster=self.user).pk,
  329. ]
  330. response = self.client.post(
  331. self.api_link, json.dumps({"posts": posts}), content_type="application/json"
  332. )
  333. self.assertEqual(response.status_code, 400)
  334. self.assertEqual(
  335. response.json(),
  336. {"detail": "This category is closed. You can't merge posts in it."},
  337. )
  338. @patch_category_acl({"can_merge_posts": True, "can_close_threads": True})
  339. def test_closed_category(self):
  340. """api validates permission to merge in closed category"""
  341. self.category.is_closed = True
  342. self.category.save()
  343. posts = [
  344. test.reply_thread(self.thread, poster=self.user).pk,
  345. test.reply_thread(self.thread, poster=self.user).pk,
  346. ]
  347. response = self.client.post(
  348. self.api_link, json.dumps({"posts": posts}), content_type="application/json"
  349. )
  350. self.assertEqual(response.status_code, 200)
  351. @patch_category_acl({"can_merge_posts": True})
  352. def test_merge_best_answer_first_post(self):
  353. """api recjects attempt to merge best_answer with first post"""
  354. self.thread.first_post.poster = self.user
  355. self.thread.first_post.save()
  356. self.post.poster = self.user
  357. self.post.save()
  358. self.thread.set_best_answer(self.user, self.post)
  359. self.thread.save()
  360. response = self.client.post(
  361. self.api_link,
  362. json.dumps({"posts": [self.thread.first_post.pk, self.post.pk]}),
  363. content_type="application/json",
  364. )
  365. self.assertEqual(response.status_code, 400)
  366. self.assertEqual(
  367. response.json(),
  368. {
  369. "detail": (
  370. "Post marked as best answer can't be "
  371. "merged with thread's first post."
  372. )
  373. },
  374. )
  375. @patch_category_acl({"can_merge_posts": True})
  376. def test_merge_posts(self):
  377. """api merges two posts"""
  378. post_a = test.reply_thread(self.thread, poster=self.user, message="Battęry")
  379. post_b = test.reply_thread(self.thread, poster=self.user, message="Hórse")
  380. thread_replies = self.thread.replies
  381. response = self.client.post(
  382. self.api_link,
  383. json.dumps({"posts": [post_a.pk, post_b.pk]}),
  384. content_type="application/json",
  385. )
  386. self.assertEqual(response.status_code, 200)
  387. self.thread.refresh_from_db()
  388. self.assertEqual(self.thread.replies, thread_replies - 1)
  389. with self.assertRaises(Post.DoesNotExist):
  390. Post.objects.get(pk=post_b.pk)
  391. merged_post = Post.objects.get(pk=post_a.pk)
  392. self.assertEqual(merged_post.parsed, "%s\n%s" % (post_a.parsed, post_b.parsed))
  393. @patch_category_acl({"can_merge_posts": True})
  394. def test_merge_guest_posts(self):
  395. """api recjects attempt to merge posts made by same guest"""
  396. response = self.client.post(
  397. self.api_link,
  398. json.dumps(
  399. {
  400. "posts": [
  401. test.reply_thread(self.thread, poster="Bob").pk,
  402. test.reply_thread(self.thread, poster="Bob").pk,
  403. ]
  404. }
  405. ),
  406. content_type="application/json",
  407. )
  408. self.assertEqual(response.status_code, 200)
  409. @patch_category_acl({"can_merge_posts": True, "can_hide_posts": 1})
  410. def test_merge_hidden_posts(self):
  411. """api merges two hidden posts"""
  412. response = self.client.post(
  413. self.api_link,
  414. json.dumps(
  415. {
  416. "posts": [
  417. test.reply_thread(
  418. self.thread, poster=self.user, is_hidden=True
  419. ).pk,
  420. test.reply_thread(
  421. self.thread, poster=self.user, is_hidden=True
  422. ).pk,
  423. ]
  424. }
  425. ),
  426. content_type="application/json",
  427. )
  428. self.assertEqual(response.status_code, 200)
  429. @patch_category_acl({"can_merge_posts": True, "can_approve_content": True})
  430. def test_merge_unapproved_posts(self):
  431. """api merges two unapproved posts"""
  432. response = self.client.post(
  433. self.api_link,
  434. json.dumps(
  435. {
  436. "posts": [
  437. test.reply_thread(
  438. self.thread, poster=self.user, is_unapproved=True
  439. ).pk,
  440. test.reply_thread(
  441. self.thread, poster=self.user, is_unapproved=True
  442. ).pk,
  443. ]
  444. }
  445. ),
  446. content_type="application/json",
  447. )
  448. self.assertEqual(response.status_code, 200)
  449. @patch_category_acl({"can_merge_posts": True, "can_hide_threads": True})
  450. def test_merge_with_hidden_thread(self):
  451. """api excludes thread's first post from visibility checks"""
  452. self.thread.first_post.is_hidden = True
  453. self.thread.first_post.poster = self.user
  454. self.thread.first_post.save()
  455. post_visible = test.reply_thread(self.thread, poster=self.user, is_hidden=False)
  456. response = self.client.post(
  457. self.api_link,
  458. json.dumps({"posts": [self.thread.first_post.pk, post_visible.pk]}),
  459. content_type="application/json",
  460. )
  461. self.assertEqual(response.status_code, 200)
  462. @patch_category_acl({"can_merge_posts": True})
  463. def test_merge_protected(self):
  464. """api preserves protected status after merge"""
  465. response = self.client.post(
  466. self.api_link,
  467. json.dumps(
  468. {
  469. "posts": [
  470. test.reply_thread(
  471. self.thread, poster="Bob", is_protected=True
  472. ).pk,
  473. test.reply_thread(
  474. self.thread, poster="Bob", is_protected=False
  475. ).pk,
  476. ]
  477. }
  478. ),
  479. content_type="application/json",
  480. )
  481. self.assertEqual(response.status_code, 200)
  482. merged_post = self.thread.post_set.order_by("-id")[0]
  483. self.assertTrue(merged_post.is_protected)
  484. @patch_category_acl({"can_merge_posts": True})
  485. def test_merge_best_answer(self):
  486. """api merges best answer with other post"""
  487. best_answer = test.reply_thread(self.thread, poster="Bob")
  488. self.thread.set_best_answer(self.user, best_answer)
  489. self.thread.save()
  490. response = self.client.post(
  491. self.api_link,
  492. json.dumps(
  493. {
  494. "posts": [
  495. best_answer.pk,
  496. test.reply_thread(self.thread, poster="Bob").pk,
  497. ]
  498. }
  499. ),
  500. content_type="application/json",
  501. )
  502. self.assertEqual(response.status_code, 200)
  503. self.thread.refresh_from_db()
  504. self.assertEqual(self.thread.best_answer, best_answer)
  505. @patch_category_acl({"can_merge_posts": True})
  506. def test_merge_best_answer_in(self):
  507. """api merges best answer into other post"""
  508. other_post = test.reply_thread(self.thread, poster="Bob")
  509. best_answer = test.reply_thread(self.thread, poster="Bob")
  510. self.thread.set_best_answer(self.user, best_answer)
  511. self.thread.save()
  512. response = self.client.post(
  513. self.api_link,
  514. json.dumps({"posts": [best_answer.pk, other_post.pk]}),
  515. content_type="application/json",
  516. )
  517. self.assertEqual(response.status_code, 200)
  518. self.thread.refresh_from_db()
  519. self.assertEqual(self.thread.best_answer, other_post)
  520. @patch_category_acl({"can_merge_posts": True})
  521. def test_merge_best_answer_in_protected(self):
  522. """api merges best answer into protected post"""
  523. best_answer = test.reply_thread(self.thread, poster="Bob")
  524. self.thread.set_best_answer(self.user, best_answer)
  525. self.thread.save()
  526. response = self.client.post(
  527. self.api_link,
  528. json.dumps(
  529. {
  530. "posts": [
  531. best_answer.pk,
  532. test.reply_thread(
  533. self.thread, poster="Bob", is_protected=True
  534. ).pk,
  535. ]
  536. }
  537. ),
  538. content_type="application/json",
  539. )
  540. self.assertEqual(response.status_code, 200)
  541. self.thread.refresh_from_db()
  542. self.assertEqual(self.thread.best_answer, best_answer)
  543. self.thread.best_answer.refresh_from_db()
  544. self.assertTrue(self.thread.best_answer.is_protected)
  545. self.assertTrue(self.thread.best_answer_is_protected)
  546. @patch_category_acl({"can_merge_posts": True})
  547. def test_merge_remove_reads(self):
  548. """two posts merge removes read tracker from post"""
  549. post_a = test.reply_thread(self.thread, poster=self.user, message="Battęry")
  550. post_b = test.reply_thread(self.thread, poster=self.user, message="Hórse")
  551. poststracker.save_read(self.user, post_a)
  552. poststracker.save_read(self.user, post_b)
  553. response = self.client.post(
  554. self.api_link,
  555. json.dumps({"posts": [post_a.pk, post_b.pk]}),
  556. content_type="application/json",
  557. )
  558. self.assertEqual(response.status_code, 200)
  559. # both post's were removed from readtracker
  560. self.assertEqual(self.user.postread_set.count(), 0)