test_thread_postsplit_api.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  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 Post, Thread
  10. from misago.threads.serializers.moderation import POSTS_LIMIT
  11. from misago.users.testutils import AuthenticatedUserTestCase
  12. class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
  13. def setUp(self):
  14. super(ThreadPostSplitApiTestCase, self).setUp()
  15. self.category = Category.objects.get(slug='first-category')
  16. self.thread = testutils.post_thread(category=self.category)
  17. self.posts = [
  18. testutils.reply_thread(self.thread).pk,
  19. testutils.reply_thread(self.thread).pk,
  20. ]
  21. self.api_link = reverse(
  22. 'misago:api:thread-post-split', kwargs={
  23. 'thread_pk': self.thread.pk,
  24. }
  25. )
  26. Category(
  27. name='Category B',
  28. slug='category-b',
  29. ).insert_at(
  30. self.category,
  31. position='last-child',
  32. save=True,
  33. )
  34. self.category_b = Category.objects.get(slug='category-b')
  35. self.override_acl()
  36. self.override_other_acl()
  37. def refresh_thread(self):
  38. self.thread = Thread.objects.get(pk=self.thread.pk)
  39. def override_acl(self, extra_acl=None):
  40. new_acl = self.user.acl_cache
  41. new_acl['categories'][self.category.pk].update({
  42. 'can_see': 1,
  43. 'can_browse': 1,
  44. 'can_start_threads': 1,
  45. 'can_reply_threads': 1,
  46. 'can_edit_posts': 1,
  47. 'can_approve_content': 0,
  48. 'can_move_posts': 1,
  49. })
  50. if extra_acl:
  51. new_acl['categories'][self.category.pk].update(extra_acl)
  52. override_acl(self.user, new_acl)
  53. def override_other_acl(self, acl=None):
  54. other_category_acl = self.user.acl_cache['categories'][self.category.pk].copy()
  55. other_category_acl.update({
  56. 'can_see': 1,
  57. 'can_browse': 1,
  58. 'can_start_threads': 0,
  59. 'can_reply_threads': 0,
  60. 'can_edit_posts': 1,
  61. 'can_approve_content': 0,
  62. 'can_move_posts': 1,
  63. })
  64. if acl:
  65. other_category_acl.update(acl)
  66. categories_acl = self.user.acl_cache['categories']
  67. categories_acl[self.category_b.pk] = other_category_acl
  68. visible_categories = [self.category.pk]
  69. if other_category_acl['can_see']:
  70. visible_categories.append(self.category_b.pk)
  71. override_acl(
  72. self.user, {
  73. 'visible_categories': visible_categories,
  74. 'categories': categories_acl,
  75. }
  76. )
  77. def test_anonymous_user(self):
  78. """you need to authenticate to split posts"""
  79. self.logout_user()
  80. response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
  81. self.assertEqual(response.status_code, 403)
  82. def test_no_permission(self):
  83. """api validates permission to split"""
  84. self.override_acl({'can_move_posts': 0})
  85. response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
  86. self.assertContains(response, "You can't split posts from this thread.", status_code=403)
  87. def test_empty_data(self):
  88. """api handles empty data"""
  89. response = self.client.post(self.api_link)
  90. self.assertContains(
  91. response, "You have to specify at least one post to split.", status_code=400
  92. )
  93. def test_invalid_data(self):
  94. """api handles post that is invalid type"""
  95. self.override_acl()
  96. response = self.client.post(self.api_link, '[]', content_type="application/json")
  97. self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
  98. self.override_acl()
  99. response = self.client.post(self.api_link, '123', content_type="application/json")
  100. self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
  101. self.override_acl()
  102. response = self.client.post(self.api_link, '"string"', content_type="application/json")
  103. self.assertContains(response, "Invalid data. Expected a dictionary", status_code=400)
  104. self.override_acl()
  105. response = self.client.post(self.api_link, 'malformed', content_type="application/json")
  106. self.assertContains(response, "JSON parse error", status_code=400)
  107. def test_no_posts_ids(self):
  108. """api rejects no posts ids"""
  109. response = self.client.post(
  110. self.api_link,
  111. json.dumps({}),
  112. content_type="application/json",
  113. )
  114. self.assertContains(
  115. response, "You have to specify at least one post to split.", status_code=400
  116. )
  117. def test_empty_posts_ids(self):
  118. """api rejects empty posts ids list"""
  119. response = self.client.post(
  120. self.api_link,
  121. json.dumps({
  122. 'posts': [],
  123. }),
  124. content_type="application/json",
  125. )
  126. self.assertContains(
  127. response, "You have to specify at least one post to split.", status_code=400
  128. )
  129. def test_invalid_posts_data(self):
  130. """api handles invalid data"""
  131. response = self.client.post(
  132. self.api_link,
  133. json.dumps({
  134. 'posts': 'string',
  135. }),
  136. content_type="application/json",
  137. )
  138. self.assertContains(
  139. response, "Expected a list of items but got type", status_code=400
  140. )
  141. def test_invalid_posts_ids(self):
  142. """api handles invalid post id"""
  143. response = self.client.post(
  144. self.api_link,
  145. json.dumps({
  146. 'posts': [1, 2, 'string'],
  147. }),
  148. content_type="application/json",
  149. )
  150. self.assertContains(
  151. response, "One or more post ids received were invalid.", status_code=400
  152. )
  153. def test_split_limit(self):
  154. """api rejects more posts than split limit"""
  155. response = self.client.post(
  156. self.api_link,
  157. json.dumps({
  158. 'posts': list(range(POSTS_LIMIT + 1)),
  159. }),
  160. content_type="application/json",
  161. )
  162. self.assertContains(
  163. response, "No more than {} posts can be split".format(POSTS_LIMIT), status_code=400
  164. )
  165. def test_split_invisible(self):
  166. """api validates posts visibility"""
  167. response = self.client.post(
  168. self.api_link,
  169. json.dumps({
  170. 'posts': [testutils.reply_thread(self.thread, is_unapproved=True).pk],
  171. }),
  172. content_type="application/json",
  173. )
  174. self.assertContains(
  175. response, "One or more posts to split could not be found.", status_code=400
  176. )
  177. def test_split_event(self):
  178. """api rejects events split"""
  179. response = self.client.post(
  180. self.api_link,
  181. json.dumps({
  182. 'posts': [testutils.reply_thread(self.thread, is_event=True).pk],
  183. }),
  184. content_type="application/json",
  185. )
  186. self.assertContains(response, "Events can't be split.", status_code=400)
  187. def test_split_first_post(self):
  188. """api rejects first post split"""
  189. response = self.client.post(
  190. self.api_link,
  191. json.dumps({
  192. 'posts': [self.thread.first_post_id],
  193. }),
  194. content_type="application/json",
  195. )
  196. self.assertContains(response, "You can't split thread's first post.", status_code=400)
  197. def test_split_hidden_posts(self):
  198. """api recjects attempt to split urneadable hidden post"""
  199. response = self.client.post(
  200. self.api_link,
  201. json.dumps({
  202. 'posts': [testutils.reply_thread(self.thread, is_hidden=True).pk],
  203. }),
  204. content_type="application/json",
  205. )
  206. self.assertContains(
  207. response, "You can't split posts the content you can't see.", status_code=400
  208. )
  209. def test_split_posts_closed_thread_no_permission(self):
  210. """api recjects attempt to split posts from closed thread"""
  211. self.thread.is_closed = True
  212. self.thread.save()
  213. self.override_acl({'can_close_threads': 0})
  214. response = self.client.post(
  215. self.api_link,
  216. json.dumps({
  217. 'posts': [testutils.reply_thread(self.thread).pk],
  218. }),
  219. content_type="application/json",
  220. )
  221. self.assertContains(
  222. response, "This thread is closed. You can't split posts in it.", status_code=400
  223. )
  224. def test_split_posts_closed_category_no_permission(self):
  225. """api recjects attempt to split posts from closed thread"""
  226. self.category.is_closed = True
  227. self.category.save()
  228. self.override_acl({'can_close_threads': 0})
  229. response = self.client.post(
  230. self.api_link,
  231. json.dumps({
  232. 'posts': [testutils.reply_thread(self.thread).pk],
  233. }),
  234. content_type="application/json",
  235. )
  236. self.assertContains(
  237. response, "This category is closed. You can't split posts in it.", status_code=400
  238. )
  239. def test_split_other_thread_posts(self):
  240. """api recjects attempt to split other thread's post"""
  241. other_thread = testutils.post_thread(self.category)
  242. response = self.client.post(
  243. self.api_link,
  244. json.dumps({
  245. 'posts': [testutils.reply_thread(other_thread, is_hidden=True).pk],
  246. }),
  247. content_type="application/json",
  248. )
  249. self.assertContains(
  250. response, "One or more posts to split could not be found.", status_code=400
  251. )
  252. def test_split_empty_new_thread_data(self):
  253. """api handles empty form data"""
  254. response = self.client.post(
  255. self.api_link,
  256. json.dumps({
  257. 'posts': self.posts,
  258. }),
  259. content_type="application/json",
  260. )
  261. self.assertEqual(response.status_code, 400)
  262. response_json = response.json()
  263. self.assertEqual(
  264. response_json, {
  265. 'title': ['This field is required.'],
  266. 'category': ['This field is required.'],
  267. }
  268. )
  269. def test_split_invalid_final_title(self):
  270. """api rejects split because final thread title was invalid"""
  271. response = self.client.post(
  272. self.api_link,
  273. json.dumps({
  274. 'posts': self.posts,
  275. 'title': '$$$',
  276. 'category': self.category.id,
  277. }),
  278. content_type="application/json",
  279. )
  280. self.assertEqual(response.status_code, 400)
  281. response_json = response.json()
  282. self.assertEqual(
  283. response_json, {
  284. 'title': ["Thread title should be at least 5 characters long (it has 3)."],
  285. }
  286. )
  287. def test_split_invalid_category(self):
  288. """api rejects split because final category was invalid"""
  289. self.override_other_acl({'can_see': 0})
  290. response = self.client.post(
  291. self.api_link,
  292. json.dumps({
  293. 'posts': self.posts,
  294. 'title': 'Valid thread title',
  295. 'category': self.category_b.id,
  296. }),
  297. content_type="application/json",
  298. )
  299. self.assertEqual(response.status_code, 400)
  300. response_json = response.json()
  301. self.assertEqual(
  302. response_json, {
  303. 'category': ["Requested category could not be found."],
  304. }
  305. )
  306. def test_split_unallowed_start_thread(self):
  307. """api rejects split because category isn't allowing starting threads"""
  308. self.override_acl({'can_start_threads': 0})
  309. response = self.client.post(
  310. self.api_link,
  311. json.dumps({
  312. 'posts': self.posts,
  313. 'title': 'Valid thread title',
  314. 'category': self.category.id,
  315. }),
  316. content_type="application/json",
  317. )
  318. self.assertEqual(response.status_code, 400)
  319. response_json = response.json()
  320. self.assertEqual(
  321. response_json, {
  322. 'category': ["You can't create new threads in selected category."],
  323. }
  324. )
  325. def test_split_invalid_weight(self):
  326. """api rejects split because final weight was invalid"""
  327. response = self.client.post(
  328. self.api_link,
  329. json.dumps({
  330. 'posts': self.posts,
  331. 'title': 'Valid thread title',
  332. 'category': self.category.id,
  333. 'weight': 4,
  334. }),
  335. content_type="application/json",
  336. )
  337. self.assertEqual(response.status_code, 400)
  338. response_json = response.json()
  339. self.assertEqual(
  340. response_json, {
  341. 'weight': ["Ensure this value is less than or equal to 2."],
  342. }
  343. )
  344. def test_split_unallowed_global_weight(self):
  345. """api rejects split because global weight was unallowed"""
  346. response = self.client.post(
  347. self.api_link,
  348. json.dumps({
  349. 'posts': self.posts,
  350. 'title': 'Valid thread title',
  351. 'category': self.category.id,
  352. 'weight': 2,
  353. }),
  354. content_type="application/json",
  355. )
  356. self.assertEqual(response.status_code, 400)
  357. response_json = response.json()
  358. self.assertEqual(
  359. response_json, {
  360. 'weight': ["You don't have permission to pin threads globally in this category."],
  361. }
  362. )
  363. def test_split_unallowed_local_weight(self):
  364. """api rejects split because local weight was unallowed"""
  365. response = self.client.post(
  366. self.api_link,
  367. json.dumps({
  368. 'posts': self.posts,
  369. 'title': 'Valid thread title',
  370. 'category': self.category.id,
  371. 'weight': 1,
  372. }),
  373. content_type="application/json"
  374. )
  375. self.assertEqual(response.status_code, 400)
  376. response_json = response.json()
  377. self.assertEqual(
  378. response_json, {
  379. 'weight': ["You don't have permission to pin threads in this category."],
  380. }
  381. )
  382. def test_split_allowed_local_weight(self):
  383. """api allows local weight"""
  384. self.override_acl({'can_pin_threads': 1})
  385. response = self.client.post(
  386. self.api_link,
  387. json.dumps({
  388. 'posts': self.posts,
  389. 'title': '$$$',
  390. 'category': self.category.id,
  391. 'weight': 1,
  392. }),
  393. content_type="application/json",
  394. )
  395. self.assertEqual(response.status_code, 400)
  396. response_json = response.json()
  397. self.assertEqual(
  398. response_json, {
  399. 'title': ["Thread title should be at least 5 characters long (it has 3)."],
  400. }
  401. )
  402. def test_split_allowed_global_weight(self):
  403. """api allows global weight"""
  404. self.override_acl({'can_pin_threads': 2})
  405. response = self.client.post(
  406. self.api_link,
  407. json.dumps({
  408. 'posts': self.posts,
  409. 'title': '$$$',
  410. 'category': self.category.id,
  411. 'weight': 2,
  412. }),
  413. content_type="application/json",
  414. )
  415. self.assertEqual(response.status_code, 400)
  416. response_json = response.json()
  417. self.assertEqual(
  418. response_json, {
  419. 'title': ["Thread title should be at least 5 characters long (it has 3)."],
  420. }
  421. )
  422. def test_split_unallowed_close(self):
  423. """api rejects split because closing thread was unallowed"""
  424. response = self.client.post(
  425. self.api_link,
  426. json.dumps({
  427. 'posts': self.posts,
  428. 'title': 'Valid thread title',
  429. 'category': self.category.id,
  430. 'is_closed': True,
  431. }),
  432. content_type="application/json",
  433. )
  434. self.assertEqual(response.status_code, 400)
  435. response_json = response.json()
  436. self.assertEqual(
  437. response_json, {
  438. 'is_closed': ["You don't have permission to close threads in this category."],
  439. }
  440. )
  441. def test_split_with_close(self):
  442. """api allows for closing thread"""
  443. self.override_acl({'can_close_threads': True})
  444. response = self.client.post(
  445. self.api_link,
  446. json.dumps({
  447. 'posts': self.posts,
  448. 'title': '$$$',
  449. 'category': self.category.id,
  450. 'weight': 0,
  451. 'is_closed': True,
  452. }),
  453. content_type="application/json",
  454. )
  455. self.assertEqual(response.status_code, 400)
  456. response_json = response.json()
  457. self.assertEqual(
  458. response_json, {
  459. 'title': ["Thread title should be at least 5 characters long (it has 3)."],
  460. }
  461. )
  462. def test_split_unallowed_hidden(self):
  463. """api rejects split because hidden thread was unallowed"""
  464. response = self.client.post(
  465. self.api_link,
  466. json.dumps({
  467. 'posts': self.posts,
  468. 'title': 'Valid thread title',
  469. 'category': self.category.id,
  470. 'is_hidden': True,
  471. }),
  472. content_type="application/json",
  473. )
  474. self.assertEqual(response.status_code, 400)
  475. response_json = response.json()
  476. self.assertEqual(
  477. response_json, {
  478. 'is_hidden': ["You don't have permission to hide threads in this category."],
  479. }
  480. )
  481. def test_split_with_hide(self):
  482. """api allows for hiding thread"""
  483. self.override_acl({'can_hide_threads': True})
  484. response = self.client.post(
  485. self.api_link,
  486. json.dumps({
  487. 'posts': self.posts,
  488. 'title': '$$$',
  489. 'category': self.category.id,
  490. 'weight': 0,
  491. 'is_hidden': True,
  492. }),
  493. content_type="application/json",
  494. )
  495. self.assertEqual(response.status_code, 400)
  496. response_json = response.json()
  497. self.assertEqual(
  498. response_json, {
  499. 'title': ["Thread title should be at least 5 characters long (it has 3)."],
  500. }
  501. )
  502. def test_split(self):
  503. """api splits posts to new thread"""
  504. self.refresh_thread()
  505. self.assertEqual(self.thread.replies, 2)
  506. response = self.client.post(
  507. self.api_link,
  508. json.dumps({
  509. 'posts': self.posts,
  510. 'title': 'Split thread.',
  511. 'category': self.category.id,
  512. }),
  513. content_type="application/json",
  514. )
  515. self.assertEqual(response.status_code, 200)
  516. # thread was created
  517. split_thread = self.category.thread_set.get(slug='split-thread')
  518. self.assertEqual(split_thread.replies, 1)
  519. # posts were removed from old thread
  520. self.refresh_thread()
  521. self.assertEqual(self.thread.replies, 0)
  522. # posts were moved to new thread
  523. self.assertEqual(split_thread.post_set.filter(pk__in=self.posts).count(), 2)
  524. def test_split_best_answer(self):
  525. """api splits best answer to new thread"""
  526. best_answer = testutils.reply_thread(self.thread)
  527. self.thread.set_best_answer(self.user, best_answer)
  528. self.thread.synchronize()
  529. self.thread.save()
  530. self.refresh_thread()
  531. self.assertEqual(self.thread.best_answer, best_answer)
  532. self.assertEqual(self.thread.replies, 3)
  533. response = self.client.post(
  534. self.api_link,
  535. json.dumps({
  536. 'posts': [best_answer.pk],
  537. 'title': 'Split thread.',
  538. 'category': self.category.id,
  539. }),
  540. content_type="application/json",
  541. )
  542. self.assertEqual(response.status_code, 200)
  543. # best_answer was moved and unmarked
  544. self.refresh_thread()
  545. self.assertEqual(self.thread.replies, 2)
  546. self.assertIsNone(self.thread.best_answer)
  547. split_thread = self.category.thread_set.get(slug='split-thread')
  548. self.assertEqual(split_thread.replies, 0)
  549. self.assertIsNone(split_thread.best_answer)
  550. def test_split_kitchensink(self):
  551. """api splits posts with kitchensink"""
  552. self.refresh_thread()
  553. self.assertEqual(self.thread.replies, 2)
  554. self.override_other_acl({
  555. 'can_start_threads': 2,
  556. 'can_close_threads': True,
  557. 'can_hide_threads': True,
  558. 'can_pin_threads': 2,
  559. })
  560. poststracker.save_read(self.user, self.thread.first_post)
  561. for post in self.posts:
  562. poststracker.save_read(self.user, Post.objects.select_related().get(pk=post))
  563. response = self.client.post(
  564. self.api_link,
  565. json.dumps({
  566. 'posts': self.posts,
  567. 'title': 'Split thread',
  568. 'category': self.category_b.id,
  569. 'weight': 2,
  570. 'is_closed': 1,
  571. 'is_hidden': 1,
  572. }),
  573. content_type="application/json",
  574. )
  575. self.assertEqual(response.status_code, 200)
  576. # thread was created
  577. split_thread = self.category_b.thread_set.get(slug='split-thread')
  578. self.assertEqual(split_thread.replies, 1)
  579. self.assertEqual(split_thread.weight, 2)
  580. self.assertTrue(split_thread.is_closed)
  581. self.assertTrue(split_thread.is_hidden)
  582. # posts were removed from old thread
  583. self.refresh_thread()
  584. self.assertEqual(self.thread.replies, 0)
  585. # posts were moved to new thread
  586. self.assertEqual(split_thread.post_set.filter(pk__in=self.posts).count(), 2)
  587. # postreads were removed
  588. postreads = self.user.postread_set.filter(post__is_event=False).order_by('id')
  589. postreads_threads = list(postreads.values_list('thread_id', flat=True))
  590. self.assertEqual(postreads_threads, [self.thread.pk])
  591. postreads_categories = list(postreads.values_list('category_id', flat=True))
  592. self.assertEqual(postreads_categories, [self.category.pk])