test_thread_postmerge_api.py 21 KB

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