test_thread_postmove_api.py 20 KB

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