test_thread_postmove_api.py 18 KB

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