test_thread_postsplit_api.py 24 KB

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