test_thread_postsplit_api.py 24 KB

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