test_thread_postmerge_api.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  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 Post
  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. @override_dynamic_settings(posts_per_page=5, posts_per_page_orphans=3)
  141. @patch_category_acl({"can_merge_posts": True})
  142. def test_merge_limit(self):
  143. """api rejects more posts than merge limit"""
  144. response = self.client.post(
  145. self.api_link,
  146. json.dumps({"posts": list(range(9))}),
  147. content_type="application/json",
  148. )
  149. self.assertEqual(response.status_code, 400)
  150. self.assertEqual(
  151. response.json(),
  152. {"detail": "No more than 8 posts can be merged at a single time."},
  153. )
  154. @patch_category_acl({"can_merge_posts": True})
  155. def test_merge_event(self):
  156. """api recjects events"""
  157. event = test.reply_thread(self.thread, is_event=True, poster=self.user)
  158. response = self.client.post(
  159. self.api_link,
  160. json.dumps({"posts": [self.post.pk, event.pk]}),
  161. content_type="application/json",
  162. )
  163. self.assertEqual(response.status_code, 400)
  164. self.assertEqual(response.json(), {"detail": "Events can't be merged."})
  165. @patch_category_acl({"can_merge_posts": True})
  166. def test_merge_notfound_pk(self):
  167. """api recjects nonexistant pk's"""
  168. response = self.client.post(
  169. self.api_link,
  170. json.dumps({"posts": [self.post.pk, self.post.pk * 1000]}),
  171. content_type="application/json",
  172. )
  173. self.assertEqual(response.status_code, 400)
  174. self.assertEqual(
  175. response.json(),
  176. {"detail": "One or more posts to merge could not be found."},
  177. )
  178. @patch_category_acl({"can_merge_posts": True})
  179. def test_merge_cross_threads(self):
  180. """api recjects attempt to merge with post made in other thread"""
  181. other_thread = test.post_thread(category=self.category)
  182. other_post = test.reply_thread(other_thread, poster=self.user)
  183. response = self.client.post(
  184. self.api_link,
  185. json.dumps({"posts": [self.post.pk, other_post.pk]}),
  186. content_type="application/json",
  187. )
  188. self.assertEqual(response.status_code, 400)
  189. self.assertEqual(
  190. response.json(),
  191. {"detail": "One or more posts to merge could not be found."},
  192. )
  193. @patch_category_acl({"can_merge_posts": True})
  194. def test_merge_authenticated_with_guest_post(self):
  195. """api recjects attempt to merge with post made by deleted user"""
  196. other_post = test.reply_thread(self.thread)
  197. response = self.client.post(
  198. self.api_link,
  199. json.dumps({"posts": [self.post.pk, other_post.pk]}),
  200. content_type="application/json",
  201. )
  202. self.assertEqual(response.status_code, 400)
  203. self.assertEqual(
  204. response.json(),
  205. {"detail": "Posts made by different users can't be merged."},
  206. )
  207. @patch_category_acl({"can_merge_posts": True})
  208. def test_merge_guest_with_authenticated_post(self):
  209. """api recjects attempt to merge with post made by deleted user"""
  210. other_post = test.reply_thread(self.thread)
  211. response = self.client.post(
  212. self.api_link,
  213. json.dumps({"posts": [other_post.pk, self.post.pk]}),
  214. content_type="application/json",
  215. )
  216. self.assertEqual(response.status_code, 400)
  217. self.assertEqual(
  218. response.json(),
  219. {"detail": "Posts made by different users can't be merged."},
  220. )
  221. @patch_category_acl({"can_merge_posts": True})
  222. def test_merge_guest_posts_different_usernames(self):
  223. """api recjects attempt to merge posts made by different guests"""
  224. response = self.client.post(
  225. self.api_link,
  226. json.dumps(
  227. {
  228. "posts": [
  229. test.reply_thread(self.thread, poster="Bob").pk,
  230. test.reply_thread(self.thread, poster="Miku").pk,
  231. ]
  232. }
  233. ),
  234. content_type="application/json",
  235. )
  236. self.assertEqual(response.status_code, 400)
  237. self.assertEqual(
  238. response.json(),
  239. {"detail": "Posts made by different users can't be merged."},
  240. )
  241. @patch_category_acl({"can_merge_posts": True, "can_hide_posts": 1})
  242. def test_merge_different_visibility(self):
  243. """api recjects attempt to merge posts with different visibility"""
  244. response = self.client.post(
  245. self.api_link,
  246. json.dumps(
  247. {
  248. "posts": [
  249. test.reply_thread(
  250. self.thread, poster=self.user, is_hidden=True
  251. ).pk,
  252. test.reply_thread(
  253. self.thread, poster=self.user, is_hidden=False
  254. ).pk,
  255. ]
  256. }
  257. ),
  258. content_type="application/json",
  259. )
  260. self.assertEqual(response.status_code, 400)
  261. self.assertEqual(
  262. response.json(),
  263. {"detail": "Posts with different visibility can't be merged."},
  264. )
  265. @patch_category_acl({"can_merge_posts": True, "can_approve_content": True})
  266. def test_merge_different_approval(self):
  267. """api recjects attempt to merge posts with different approval"""
  268. response = self.client.post(
  269. self.api_link,
  270. json.dumps(
  271. {
  272. "posts": [
  273. test.reply_thread(
  274. self.thread, poster=self.user, is_unapproved=True
  275. ).pk,
  276. test.reply_thread(
  277. self.thread, poster=self.user, is_unapproved=False
  278. ).pk,
  279. ]
  280. }
  281. ),
  282. content_type="application/json",
  283. )
  284. self.assertEqual(response.status_code, 400)
  285. self.assertEqual(
  286. response.json(),
  287. {"detail": "Posts with different visibility can't be merged."},
  288. )
  289. @patch_category_acl({"can_merge_posts": True, "can_close_threads": False})
  290. def test_closed_thread_no_permission(self):
  291. """api validates permission to merge in closed thread"""
  292. self.thread.is_closed = True
  293. self.thread.save()
  294. posts = [
  295. test.reply_thread(self.thread, poster=self.user).pk,
  296. test.reply_thread(self.thread, poster=self.user).pk,
  297. ]
  298. response = self.client.post(
  299. self.api_link, json.dumps({"posts": posts}), content_type="application/json"
  300. )
  301. self.assertEqual(response.status_code, 400)
  302. self.assertEqual(
  303. response.json(),
  304. {"detail": "This thread is closed. You can't merge posts in it."},
  305. )
  306. @patch_category_acl({"can_merge_posts": True, "can_close_threads": True})
  307. def test_closed_thread(self):
  308. """api validates permission to merge in closed thread"""
  309. self.thread.is_closed = True
  310. self.thread.save()
  311. posts = [
  312. test.reply_thread(self.thread, poster=self.user).pk,
  313. test.reply_thread(self.thread, poster=self.user).pk,
  314. ]
  315. response = self.client.post(
  316. self.api_link, json.dumps({"posts": posts}), content_type="application/json"
  317. )
  318. self.assertEqual(response.status_code, 200)
  319. @patch_category_acl({"can_merge_posts": True, "can_close_threads": False})
  320. def test_closed_category_no_permission(self):
  321. """api validates permission to merge in closed category"""
  322. self.category.is_closed = True
  323. self.category.save()
  324. posts = [
  325. test.reply_thread(self.thread, poster=self.user).pk,
  326. test.reply_thread(self.thread, poster=self.user).pk,
  327. ]
  328. response = self.client.post(
  329. self.api_link, json.dumps({"posts": posts}), content_type="application/json"
  330. )
  331. self.assertEqual(response.status_code, 400)
  332. self.assertEqual(
  333. response.json(),
  334. {"detail": "This category is closed. You can't merge posts in it."},
  335. )
  336. @patch_category_acl({"can_merge_posts": True, "can_close_threads": True})
  337. def test_closed_category(self):
  338. """api validates permission to merge in closed category"""
  339. self.category.is_closed = True
  340. self.category.save()
  341. posts = [
  342. test.reply_thread(self.thread, poster=self.user).pk,
  343. test.reply_thread(self.thread, poster=self.user).pk,
  344. ]
  345. response = self.client.post(
  346. self.api_link, json.dumps({"posts": posts}), content_type="application/json"
  347. )
  348. self.assertEqual(response.status_code, 200)
  349. @patch_category_acl({"can_merge_posts": True})
  350. def test_merge_best_answer_first_post(self):
  351. """api recjects attempt to merge best_answer with first post"""
  352. self.thread.first_post.poster = self.user
  353. self.thread.first_post.save()
  354. self.post.poster = self.user
  355. self.post.save()
  356. self.thread.set_best_answer(self.user, self.post)
  357. self.thread.save()
  358. response = self.client.post(
  359. self.api_link,
  360. json.dumps({"posts": [self.thread.first_post.pk, self.post.pk]}),
  361. content_type="application/json",
  362. )
  363. self.assertEqual(response.status_code, 400)
  364. self.assertEqual(
  365. response.json(),
  366. {
  367. "detail": (
  368. "Post marked as best answer can't be "
  369. "merged with thread's first post."
  370. )
  371. },
  372. )
  373. @patch_category_acl({"can_merge_posts": True})
  374. def test_merge_posts(self):
  375. """api merges two posts"""
  376. post_a = test.reply_thread(self.thread, poster=self.user, message="Battęry")
  377. post_b = test.reply_thread(self.thread, poster=self.user, message="Hórse")
  378. thread_replies = self.thread.replies
  379. response = self.client.post(
  380. self.api_link,
  381. json.dumps({"posts": [post_a.pk, post_b.pk]}),
  382. content_type="application/json",
  383. )
  384. self.assertEqual(response.status_code, 200)
  385. self.thread.refresh_from_db()
  386. self.assertEqual(self.thread.replies, thread_replies - 1)
  387. with self.assertRaises(Post.DoesNotExist):
  388. Post.objects.get(pk=post_b.pk)
  389. merged_post = Post.objects.get(pk=post_a.pk)
  390. self.assertEqual(merged_post.parsed, "%s\n%s" % (post_a.parsed, post_b.parsed))
  391. @patch_category_acl({"can_merge_posts": True})
  392. def test_merge_guest_posts(self):
  393. """api recjects attempt to merge posts made by same guest"""
  394. response = self.client.post(
  395. self.api_link,
  396. json.dumps(
  397. {
  398. "posts": [
  399. test.reply_thread(self.thread, poster="Bob").pk,
  400. test.reply_thread(self.thread, poster="Bob").pk,
  401. ]
  402. }
  403. ),
  404. content_type="application/json",
  405. )
  406. self.assertEqual(response.status_code, 200)
  407. @patch_category_acl({"can_merge_posts": True, "can_hide_posts": 1})
  408. def test_merge_hidden_posts(self):
  409. """api merges two hidden posts"""
  410. response = self.client.post(
  411. self.api_link,
  412. json.dumps(
  413. {
  414. "posts": [
  415. test.reply_thread(
  416. self.thread, poster=self.user, is_hidden=True
  417. ).pk,
  418. test.reply_thread(
  419. self.thread, poster=self.user, is_hidden=True
  420. ).pk,
  421. ]
  422. }
  423. ),
  424. content_type="application/json",
  425. )
  426. self.assertEqual(response.status_code, 200)
  427. @patch_category_acl({"can_merge_posts": True, "can_approve_content": True})
  428. def test_merge_unapproved_posts(self):
  429. """api merges two unapproved posts"""
  430. response = self.client.post(
  431. self.api_link,
  432. json.dumps(
  433. {
  434. "posts": [
  435. test.reply_thread(
  436. self.thread, poster=self.user, is_unapproved=True
  437. ).pk,
  438. test.reply_thread(
  439. self.thread, poster=self.user, is_unapproved=True
  440. ).pk,
  441. ]
  442. }
  443. ),
  444. content_type="application/json",
  445. )
  446. self.assertEqual(response.status_code, 200)
  447. @patch_category_acl({"can_merge_posts": True, "can_hide_threads": True})
  448. def test_merge_with_hidden_thread(self):
  449. """api excludes thread's first post from visibility checks"""
  450. self.thread.first_post.is_hidden = True
  451. self.thread.first_post.poster = self.user
  452. self.thread.first_post.save()
  453. post_visible = test.reply_thread(self.thread, poster=self.user, is_hidden=False)
  454. response = self.client.post(
  455. self.api_link,
  456. json.dumps({"posts": [self.thread.first_post.pk, post_visible.pk]}),
  457. content_type="application/json",
  458. )
  459. self.assertEqual(response.status_code, 200)
  460. @patch_category_acl({"can_merge_posts": True})
  461. def test_merge_protected(self):
  462. """api preserves protected status after merge"""
  463. response = self.client.post(
  464. self.api_link,
  465. json.dumps(
  466. {
  467. "posts": [
  468. test.reply_thread(
  469. self.thread, poster="Bob", is_protected=True
  470. ).pk,
  471. test.reply_thread(
  472. self.thread, poster="Bob", is_protected=False
  473. ).pk,
  474. ]
  475. }
  476. ),
  477. content_type="application/json",
  478. )
  479. self.assertEqual(response.status_code, 200)
  480. merged_post = self.thread.post_set.order_by("-id")[0]
  481. self.assertTrue(merged_post.is_protected)
  482. @patch_category_acl({"can_merge_posts": True})
  483. def test_merge_best_answer(self):
  484. """api merges best answer with other post"""
  485. best_answer = test.reply_thread(self.thread, poster="Bob")
  486. self.thread.set_best_answer(self.user, best_answer)
  487. self.thread.save()
  488. response = self.client.post(
  489. self.api_link,
  490. json.dumps(
  491. {
  492. "posts": [
  493. best_answer.pk,
  494. test.reply_thread(self.thread, poster="Bob").pk,
  495. ]
  496. }
  497. ),
  498. content_type="application/json",
  499. )
  500. self.assertEqual(response.status_code, 200)
  501. self.thread.refresh_from_db()
  502. self.assertEqual(self.thread.best_answer, best_answer)
  503. @patch_category_acl({"can_merge_posts": True})
  504. def test_merge_best_answer_in(self):
  505. """api merges best answer into other post"""
  506. other_post = test.reply_thread(self.thread, poster="Bob")
  507. best_answer = test.reply_thread(self.thread, poster="Bob")
  508. self.thread.set_best_answer(self.user, best_answer)
  509. self.thread.save()
  510. response = self.client.post(
  511. self.api_link,
  512. json.dumps({"posts": [best_answer.pk, other_post.pk]}),
  513. content_type="application/json",
  514. )
  515. self.assertEqual(response.status_code, 200)
  516. self.thread.refresh_from_db()
  517. self.assertEqual(self.thread.best_answer, other_post)
  518. @patch_category_acl({"can_merge_posts": True})
  519. def test_merge_best_answer_in_protected(self):
  520. """api merges best answer into protected post"""
  521. best_answer = test.reply_thread(self.thread, poster="Bob")
  522. self.thread.set_best_answer(self.user, best_answer)
  523. self.thread.save()
  524. response = self.client.post(
  525. self.api_link,
  526. json.dumps(
  527. {
  528. "posts": [
  529. best_answer.pk,
  530. test.reply_thread(
  531. self.thread, poster="Bob", is_protected=True
  532. ).pk,
  533. ]
  534. }
  535. ),
  536. content_type="application/json",
  537. )
  538. self.assertEqual(response.status_code, 200)
  539. self.thread.refresh_from_db()
  540. self.assertEqual(self.thread.best_answer, best_answer)
  541. self.thread.best_answer.refresh_from_db()
  542. self.assertTrue(self.thread.best_answer.is_protected)
  543. self.assertTrue(self.thread.best_answer_is_protected)
  544. @patch_category_acl({"can_merge_posts": True})
  545. def test_merge_remove_reads(self):
  546. """two posts merge removes read tracker from post"""
  547. post_a = test.reply_thread(self.thread, poster=self.user, message="Battęry")
  548. post_b = test.reply_thread(self.thread, poster=self.user, message="Hórse")
  549. poststracker.save_read(self.user, post_a)
  550. poststracker.save_read(self.user, post_b)
  551. response = self.client.post(
  552. self.api_link,
  553. json.dumps({"posts": [post_a.pk, post_b.pk]}),
  554. content_type="application/json",
  555. )
  556. self.assertEqual(response.status_code, 200)
  557. # both post's were removed from readtracker
  558. self.assertEqual(self.user.postread_set.count(), 0)