test_thread_postsplit_api.py 24 KB

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