test_threads_merge_api.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098
  1. import json
  2. from django.urls import reverse
  3. from .. import test
  4. from ...acl import useracl
  5. from ...acl.objectacl import add_acl_to_obj
  6. from ...categories.models import Category
  7. from ...conftest import get_cache_versions
  8. from ...readtracker import poststracker
  9. from ..models import Poll, PollVote, Post, Thread
  10. from ..serializers import ThreadsListSerializer
  11. from ..serializers.moderation import THREADS_LIMIT
  12. from ..test import patch_category_acl, patch_other_category_acl
  13. from .test_threads_api import ThreadsApiTestCase
  14. cache_versions = get_cache_versions()
  15. class ThreadsMergeApiTests(ThreadsApiTestCase):
  16. def setUp(self):
  17. super().setUp()
  18. self.api_link = reverse("misago:api:thread-merge")
  19. Category(name="Other Category", slug="other-category").insert_at(
  20. self.category, position="last-child", save=True
  21. )
  22. self.other_category = Category.objects.get(slug="other-category")
  23. def test_merge_no_threads(self):
  24. """api validates if we are trying to merge no threads"""
  25. response = self.client.post(self.api_link, content_type="application/json")
  26. self.assertEqual(response.status_code, 403)
  27. self.assertEqual(
  28. response.json(),
  29. {"detail": "You have to select at least two threads to merge."},
  30. )
  31. def test_merge_empty_threads(self):
  32. """api validates if we are trying to empty threads list"""
  33. response = self.client.post(
  34. self.api_link, json.dumps({"threads": []}), content_type="application/json"
  35. )
  36. self.assertEqual(response.status_code, 403)
  37. self.assertEqual(
  38. response.json(),
  39. {"detail": "You have to select at least two threads to merge."},
  40. )
  41. def test_merge_invalid_threads(self):
  42. """api validates if we are trying to merge invalid thread ids"""
  43. response = self.client.post(
  44. self.api_link,
  45. json.dumps({"threads": "abcd"}),
  46. content_type="application/json",
  47. )
  48. self.assertEqual(response.status_code, 403)
  49. self.assertEqual(
  50. response.json(), {"detail": 'Expected a list of items but got type "str".'}
  51. )
  52. response = self.client.post(
  53. self.api_link,
  54. json.dumps({"threads": ["a", "-", "c"]}),
  55. content_type="application/json",
  56. )
  57. self.assertEqual(response.status_code, 403)
  58. self.assertEqual(
  59. response.json(),
  60. {"detail": ["One or more thread ids received were invalid."]},
  61. )
  62. def test_merge_single_thread(self):
  63. """api validates if we are trying to merge single thread"""
  64. response = self.client.post(
  65. self.api_link,
  66. json.dumps({"threads": [self.thread.id]}),
  67. content_type="application/json",
  68. )
  69. self.assertEqual(response.status_code, 403)
  70. self.assertEqual(
  71. response.json(),
  72. {"detail": "You have to select at least two threads to merge."},
  73. )
  74. def test_merge_with_nonexisting_thread(self):
  75. """api validates if we are trying to merge with invalid thread"""
  76. response = self.client.post(
  77. self.api_link,
  78. json.dumps({"threads": [self.thread.id, self.thread.id + 1000]}),
  79. content_type="application/json",
  80. )
  81. self.assertEqual(response.status_code, 403)
  82. self.assertEqual(
  83. response.json(),
  84. {"detail": "One or more threads to merge could not be found."},
  85. )
  86. def test_merge_with_invisible_thread(self):
  87. """api validates if we are trying to merge with inaccesible thread"""
  88. unaccesible_thread = test.post_thread(category=self.other_category)
  89. response = self.client.post(
  90. self.api_link,
  91. json.dumps({"threads": [self.thread.id, unaccesible_thread.id]}),
  92. content_type="application/json",
  93. )
  94. self.assertEqual(response.status_code, 403)
  95. self.assertEqual(
  96. response.json(),
  97. {"detail": "One or more threads to merge could not be found."},
  98. )
  99. def test_merge_no_permission(self):
  100. """api validates permission to merge threads"""
  101. thread = test.post_thread(category=self.category)
  102. response = self.client.post(
  103. self.api_link,
  104. json.dumps(
  105. {
  106. "category": self.category.id,
  107. "title": "Lorem ipsum dolor",
  108. "threads": [self.thread.id, thread.id],
  109. }
  110. ),
  111. content_type="application/json",
  112. )
  113. self.assertEqual(response.status_code, 403)
  114. self.assertEqual(
  115. response.json(),
  116. [
  117. {
  118. "id": thread.pk,
  119. "title": thread.title,
  120. "errors": ["You can't merge threads in this category."],
  121. },
  122. {
  123. "id": self.thread.pk,
  124. "title": self.thread.title,
  125. "errors": ["You can't merge threads in this category."],
  126. },
  127. ],
  128. )
  129. @patch_other_category_acl()
  130. @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
  131. def test_thread_category_is_closed(self):
  132. """api validates if thread's category is open"""
  133. other_thread = test.post_thread(self.category)
  134. self.category.is_closed = True
  135. self.category.save()
  136. response = self.client.post(
  137. self.api_link,
  138. json.dumps(
  139. {
  140. "category": self.other_category.id,
  141. "title": "Lorem ipsum dolor",
  142. "threads": [self.thread.id, other_thread.id],
  143. }
  144. ),
  145. content_type="application/json",
  146. )
  147. self.assertEqual(response.status_code, 403)
  148. self.assertEqual(
  149. response.json(),
  150. [
  151. {
  152. "id": other_thread.id,
  153. "title": other_thread.title,
  154. "errors": [
  155. "This category is closed. You can't merge it's threads."
  156. ],
  157. },
  158. {
  159. "id": self.thread.id,
  160. "title": self.thread.title,
  161. "errors": [
  162. "This category is closed. You can't merge it's threads."
  163. ],
  164. },
  165. ],
  166. )
  167. @patch_other_category_acl()
  168. @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
  169. def test_thread_is_closed(self):
  170. """api validates if thread is open"""
  171. other_thread = test.post_thread(self.category)
  172. other_thread.is_closed = True
  173. other_thread.save()
  174. response = self.client.post(
  175. self.api_link,
  176. json.dumps(
  177. {
  178. "category": self.other_category.id,
  179. "title": "Lorem ipsum dolor",
  180. "threads": [self.thread.id, other_thread.id],
  181. }
  182. ),
  183. content_type="application/json",
  184. )
  185. self.assertEqual(response.status_code, 403)
  186. self.assertEqual(
  187. response.json(),
  188. [
  189. {
  190. "id": other_thread.id,
  191. "title": other_thread.title,
  192. "errors": [
  193. "This thread is closed. You can't merge it with other threads."
  194. ],
  195. }
  196. ],
  197. )
  198. @patch_category_acl({"can_merge_threads": True})
  199. def test_merge_too_many_threads(self):
  200. """api rejects too many threads to merge"""
  201. threads = []
  202. for _ in range(THREADS_LIMIT + 1):
  203. threads.append(test.post_thread(category=self.category).pk)
  204. response = self.client.post(
  205. self.api_link,
  206. json.dumps({"threads": threads}),
  207. content_type="application/json",
  208. )
  209. self.assertEqual(response.status_code, 403)
  210. self.assertEqual(
  211. response.json(),
  212. {
  213. "detail": "No more than %s threads can be merged at single time."
  214. % THREADS_LIMIT
  215. },
  216. )
  217. @patch_category_acl({"can_merge_threads": True})
  218. def test_merge_no_final_thread(self):
  219. """api rejects merge because no data to merge threads was specified"""
  220. thread = test.post_thread(category=self.category)
  221. response = self.client.post(
  222. self.api_link,
  223. json.dumps({"threads": [self.thread.id, thread.id]}),
  224. content_type="application/json",
  225. )
  226. self.assertEqual(response.status_code, 400)
  227. self.assertEqual(
  228. response.json(),
  229. {
  230. "title": ["This field is required."],
  231. "category": ["This field is required."],
  232. },
  233. )
  234. @patch_category_acl({"can_merge_threads": True})
  235. def test_merge_invalid_final_title(self):
  236. """api rejects merge because final thread title was invalid"""
  237. thread = test.post_thread(category=self.category)
  238. response = self.client.post(
  239. self.api_link,
  240. json.dumps(
  241. {
  242. "threads": [self.thread.id, thread.id],
  243. "title": "$$$",
  244. "category": self.category.id,
  245. }
  246. ),
  247. content_type="application/json",
  248. )
  249. self.assertEqual(response.status_code, 400)
  250. self.assertEqual(
  251. response.json(),
  252. {
  253. "title": [
  254. "Thread title should be at least 5 characters long (it has 3)."
  255. ]
  256. },
  257. )
  258. @patch_category_acl({"can_merge_threads": True})
  259. def test_merge_invalid_category(self):
  260. """api rejects merge because final category was invalid"""
  261. thread = test.post_thread(category=self.category)
  262. response = self.client.post(
  263. self.api_link,
  264. json.dumps(
  265. {
  266. "threads": [self.thread.id, thread.id],
  267. "title": "Valid thread title",
  268. "category": self.other_category.id,
  269. }
  270. ),
  271. content_type="application/json",
  272. )
  273. self.assertEqual(response.status_code, 400)
  274. self.assertEqual(
  275. response.json(), {"category": ["Requested category could not be found."]}
  276. )
  277. @patch_category_acl({"can_merge_threads": True, "can_start_threads": False})
  278. def test_merge_unallowed_start_thread(self):
  279. """api rejects merge because category isn't allowing starting threads"""
  280. thread = test.post_thread(category=self.category)
  281. response = self.client.post(
  282. self.api_link,
  283. json.dumps(
  284. {
  285. "threads": [self.thread.id, thread.id],
  286. "title": "Valid thread title",
  287. "category": self.category.id,
  288. }
  289. ),
  290. content_type="application/json",
  291. )
  292. self.assertEqual(response.status_code, 400)
  293. self.assertEqual(
  294. response.json(),
  295. {"category": ["You can't create new threads in selected category."]},
  296. )
  297. @patch_category_acl({"can_merge_threads": True})
  298. def test_merge_invalid_weight(self):
  299. """api rejects merge because final weight was invalid"""
  300. thread = test.post_thread(category=self.category)
  301. response = self.client.post(
  302. self.api_link,
  303. json.dumps(
  304. {
  305. "threads": [self.thread.id, thread.id],
  306. "title": "Valid thread title",
  307. "category": self.category.id,
  308. "weight": 4,
  309. }
  310. ),
  311. content_type="application/json",
  312. )
  313. self.assertEqual(response.status_code, 400)
  314. self.assertEqual(
  315. response.json(),
  316. {"weight": ["Ensure this value is less than or equal to 2."]},
  317. )
  318. @patch_category_acl({"can_merge_threads": True})
  319. def test_merge_unallowed_global_weight(self):
  320. """api rejects merge because global weight was unallowed"""
  321. thread = test.post_thread(category=self.category)
  322. response = self.client.post(
  323. self.api_link,
  324. json.dumps(
  325. {
  326. "threads": [self.thread.id, thread.id],
  327. "title": "Valid thread title",
  328. "category": self.category.id,
  329. "weight": 2,
  330. }
  331. ),
  332. content_type="application/json",
  333. )
  334. self.assertEqual(response.status_code, 400)
  335. self.assertEqual(
  336. response.json(),
  337. {
  338. "weight": [
  339. "You don't have permission to pin threads globally "
  340. "in this category."
  341. ]
  342. },
  343. )
  344. @patch_category_acl({"can_merge_threads": True})
  345. def test_merge_unallowed_local_weight(self):
  346. """api rejects merge because local weight was unallowed"""
  347. thread = test.post_thread(category=self.category)
  348. response = self.client.post(
  349. self.api_link,
  350. json.dumps(
  351. {
  352. "threads": [self.thread.id, thread.id],
  353. "title": "Valid thread title",
  354. "category": self.category.id,
  355. "weight": 1,
  356. }
  357. ),
  358. content_type="application/json",
  359. )
  360. self.assertEqual(response.status_code, 400)
  361. self.assertEqual(
  362. response.json(),
  363. {"weight": ["You don't have permission to pin threads in this category."]},
  364. )
  365. @patch_category_acl({"can_merge_threads": True, "can_pin_threads": 1})
  366. def test_merge_allowed_local_weight(self):
  367. """api allows local weight"""
  368. thread = test.post_thread(category=self.category)
  369. response = self.client.post(
  370. self.api_link,
  371. json.dumps(
  372. {
  373. "threads": [self.thread.id, thread.id],
  374. "title": "$$$",
  375. "category": self.category.id,
  376. "weight": 1,
  377. }
  378. ),
  379. content_type="application/json",
  380. )
  381. self.assertEqual(response.status_code, 400)
  382. self.assertEqual(
  383. response.json(),
  384. {
  385. "title": [
  386. "Thread title should be at least 5 characters long (it has 3)."
  387. ]
  388. },
  389. )
  390. @patch_category_acl({"can_merge_threads": True, "can_pin_threads": 2})
  391. def test_merge_allowed_global_weight(self):
  392. """api allows global weight"""
  393. thread = test.post_thread(category=self.category)
  394. response = self.client.post(
  395. self.api_link,
  396. json.dumps(
  397. {
  398. "threads": [self.thread.id, thread.id],
  399. "title": "$$$",
  400. "category": self.category.id,
  401. "weight": 2,
  402. }
  403. ),
  404. content_type="application/json",
  405. )
  406. self.assertEqual(response.status_code, 400)
  407. self.assertEqual(
  408. response.json(),
  409. {
  410. "title": [
  411. "Thread title should be at least 5 characters long (it has 3)."
  412. ]
  413. },
  414. )
  415. @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
  416. def test_merge_unallowed_close(self):
  417. """api rejects merge because closing thread was unallowed"""
  418. thread = test.post_thread(category=self.category)
  419. response = self.client.post(
  420. self.api_link,
  421. json.dumps(
  422. {
  423. "threads": [self.thread.id, thread.id],
  424. "title": "Valid thread title",
  425. "category": self.category.id,
  426. "is_closed": True,
  427. }
  428. ),
  429. content_type="application/json",
  430. )
  431. self.assertEqual(response.status_code, 400)
  432. self.assertEqual(
  433. response.json(),
  434. {
  435. "is_closed": [
  436. "You don't have permission to close threads in this category."
  437. ]
  438. },
  439. )
  440. @patch_category_acl({"can_merge_threads": True, "can_close_threads": True})
  441. def test_merge_with_close(self):
  442. """api allows for closing thread"""
  443. thread = test.post_thread(category=self.category)
  444. response = self.client.post(
  445. self.api_link,
  446. json.dumps(
  447. {
  448. "threads": [self.thread.id, thread.id],
  449. "title": "$$$",
  450. "category": self.category.id,
  451. "weight": 0,
  452. "is_closed": True,
  453. }
  454. ),
  455. content_type="application/json",
  456. )
  457. self.assertEqual(response.status_code, 400)
  458. self.assertEqual(
  459. response.json(),
  460. {
  461. "title": [
  462. "Thread title should be at least 5 characters long (it has 3)."
  463. ]
  464. },
  465. )
  466. @patch_category_acl({"can_merge_threads": True, "can_hide_threads": 0})
  467. def test_merge_unallowed_hidden(self):
  468. """api rejects merge because hidden thread was unallowed"""
  469. thread = test.post_thread(category=self.category)
  470. response = self.client.post(
  471. self.api_link,
  472. json.dumps(
  473. {
  474. "threads": [self.thread.id, thread.id],
  475. "title": "Valid thread title",
  476. "category": self.category.id,
  477. "is_hidden": True,
  478. }
  479. ),
  480. content_type="application/json",
  481. )
  482. self.assertEqual(response.status_code, 400)
  483. self.assertEqual(
  484. response.json(),
  485. {
  486. "is_hidden": [
  487. "You don't have permission to hide threads in this category."
  488. ]
  489. },
  490. )
  491. @patch_category_acl({"can_merge_threads": True, "can_hide_threads": 1})
  492. def test_merge_with_hide(self):
  493. """api allows for hiding thread"""
  494. thread = test.post_thread(category=self.category)
  495. response = self.client.post(
  496. self.api_link,
  497. json.dumps(
  498. {
  499. "threads": [self.thread.id, thread.id],
  500. "title": "$$$",
  501. "category": self.category.id,
  502. "weight": 0,
  503. "is_hidden": True,
  504. }
  505. ),
  506. content_type="application/json",
  507. )
  508. self.assertEqual(response.status_code, 400)
  509. self.assertEqual(
  510. response.json(),
  511. {
  512. "title": [
  513. "Thread title should be at least 5 characters long (it has 3)."
  514. ]
  515. },
  516. )
  517. @patch_category_acl({"can_merge_threads": True})
  518. def test_merge(self):
  519. """api performs basic merge"""
  520. posts_ids = [p.id for p in Post.objects.all()]
  521. thread = test.post_thread(category=self.category)
  522. response = self.client.post(
  523. self.api_link,
  524. json.dumps(
  525. {
  526. "threads": [self.thread.id, thread.id],
  527. "title": "Merged thread!",
  528. "category": self.category.id,
  529. }
  530. ),
  531. content_type="application/json",
  532. )
  533. self.assertEqual(response.status_code, 200)
  534. # is response json with new thread?
  535. response_json = response.json()
  536. new_thread = Thread.objects.get(pk=response_json["id"])
  537. new_thread.is_read = False
  538. new_thread.subscription = None
  539. user_acl = useracl.get_user_acl(self.user, cache_versions)
  540. add_acl_to_obj(user_acl, new_thread.category)
  541. add_acl_to_obj(user_acl, new_thread)
  542. self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
  543. # did posts move to new thread?
  544. for post in Post.objects.filter(id__in=posts_ids):
  545. self.assertEqual(post.thread_id, new_thread.id)
  546. # are old threads gone?
  547. self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])
  548. @patch_category_acl(
  549. {
  550. "can_merge_threads": True,
  551. "can_close_threads": True,
  552. "can_hide_threads": 1,
  553. "can_pin_threads": 2,
  554. }
  555. )
  556. def test_merge_kitchensink(self):
  557. """api performs merge"""
  558. posts_ids = [p.id for p in Post.objects.all()]
  559. thread = test.post_thread(category=self.category)
  560. poststracker.save_read(self.user, self.thread.first_post)
  561. poststracker.save_read(self.user, thread.first_post)
  562. self.user.subscription_set.create(
  563. thread=self.thread,
  564. category=self.thread.category,
  565. last_read_on=self.thread.last_post_on,
  566. send_email=False,
  567. )
  568. self.user.subscription_set.create(
  569. thread=thread,
  570. category=thread.category,
  571. last_read_on=thread.last_post_on,
  572. send_email=False,
  573. )
  574. response = self.client.post(
  575. self.api_link,
  576. json.dumps(
  577. {
  578. "threads": [self.thread.id, thread.id],
  579. "title": "Merged thread!",
  580. "category": self.category.id,
  581. "is_closed": 1,
  582. "is_hidden": 1,
  583. "weight": 2,
  584. }
  585. ),
  586. content_type="application/json",
  587. )
  588. self.assertEqual(response.status_code, 200)
  589. # is response json with new thread?
  590. response_json = response.json()
  591. new_thread = Thread.objects.get(pk=response_json["id"])
  592. new_thread.is_read = False
  593. new_thread.subscription = None
  594. self.assertEqual(new_thread.weight, 2)
  595. self.assertTrue(new_thread.is_closed)
  596. self.assertTrue(new_thread.is_hidden)
  597. user_acl = useracl.get_user_acl(self.user, cache_versions)
  598. add_acl_to_obj(user_acl, new_thread.category)
  599. add_acl_to_obj(user_acl, new_thread)
  600. self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
  601. # did posts move to new thread?
  602. for post in Post.objects.filter(id__in=posts_ids):
  603. self.assertEqual(post.thread_id, new_thread.id)
  604. # are old threads gone?
  605. self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])
  606. # posts reads are kept
  607. postreads = self.user.postread_set.filter(post__is_event=False).order_by("id")
  608. self.assertEqual(
  609. list(postreads.values_list("post_id", flat=True)),
  610. [self.thread.first_post_id, thread.first_post_id],
  611. )
  612. self.assertEqual(postreads.filter(thread=new_thread).count(), 2)
  613. self.assertEqual(postreads.filter(category=self.category).count(), 2)
  614. # subscriptions are kept
  615. self.assertEqual(self.user.subscription_set.count(), 1)
  616. self.user.subscription_set.get(thread=new_thread)
  617. self.user.subscription_set.get(category=self.category)
  618. @patch_category_acl({"can_merge_threads": True})
  619. def test_merge_threads_merged_best_answer(self):
  620. """api merges two threads successfully, moving best answer to old thread"""
  621. other_thread = test.post_thread(self.category)
  622. best_answer = test.reply_thread(self.thread)
  623. self.thread.set_best_answer(self.user, best_answer)
  624. self.thread.save()
  625. response = self.client.post(
  626. self.api_link,
  627. json.dumps(
  628. {
  629. "threads": [self.thread.id, other_thread.id],
  630. "title": "Merged thread!",
  631. "category": self.category.id,
  632. }
  633. ),
  634. content_type="application/json",
  635. )
  636. self.assertEqual(response.status_code, 200)
  637. # best answer is set on new thread
  638. new_thread = Thread.objects.get(pk=response.json()["id"])
  639. self.assertEqual(new_thread.best_answer_id, best_answer.id)
  640. @patch_category_acl({"can_merge_threads": True})
  641. def test_merge_threads_merge_conflict_best_answer(self):
  642. """api errors on merge conflict, returning list of available best answers"""
  643. best_answer = test.reply_thread(self.thread)
  644. self.thread.set_best_answer(self.user, best_answer)
  645. self.thread.save()
  646. other_thread = test.post_thread(self.category)
  647. other_best_answer = test.reply_thread(other_thread)
  648. other_thread.set_best_answer(self.user, other_best_answer)
  649. other_thread.save()
  650. response = self.client.post(
  651. self.api_link,
  652. json.dumps(
  653. {
  654. "threads": [self.thread.id, other_thread.id],
  655. "title": "Merged thread!",
  656. "category": self.category.id,
  657. }
  658. ),
  659. content_type="application/json",
  660. )
  661. self.assertEqual(response.status_code, 400)
  662. self.assertEqual(
  663. response.json(),
  664. {
  665. "best_answers": [
  666. ["0", "Unmark all best answers"],
  667. [str(self.thread.id), self.thread.title],
  668. [str(other_thread.id), other_thread.title],
  669. ]
  670. },
  671. )
  672. # best answers were untouched
  673. self.assertEqual(self.thread.post_set.count(), 2)
  674. self.assertEqual(other_thread.post_set.count(), 2)
  675. self.assertEqual(
  676. Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id
  677. )
  678. self.assertEqual(
  679. Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id
  680. )
  681. @patch_category_acl({"can_merge_threads": True})
  682. def test_threads_merge_conflict_best_answer_invalid_resolution(self):
  683. """api errors on invalid merge conflict resolution"""
  684. best_answer = test.reply_thread(self.thread)
  685. self.thread.set_best_answer(self.user, best_answer)
  686. self.thread.save()
  687. other_thread = test.post_thread(self.category)
  688. other_best_answer = test.reply_thread(other_thread)
  689. other_thread.set_best_answer(self.user, other_best_answer)
  690. other_thread.save()
  691. response = self.client.post(
  692. self.api_link,
  693. json.dumps(
  694. {
  695. "threads": [self.thread.id, other_thread.id],
  696. "title": "Merged thread!",
  697. "category": self.category.id,
  698. "best_answer": other_thread.id + 10,
  699. }
  700. ),
  701. content_type="application/json",
  702. )
  703. self.assertEqual(response.status_code, 400)
  704. self.assertEqual(response.json(), {"best_answer": ["Invalid choice."]})
  705. # best answers were untouched
  706. self.assertEqual(self.thread.post_set.count(), 2)
  707. self.assertEqual(other_thread.post_set.count(), 2)
  708. self.assertEqual(
  709. Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id
  710. )
  711. self.assertEqual(
  712. Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id
  713. )
  714. @patch_category_acl({"can_merge_threads": True})
  715. def test_threads_merge_conflict_unmark_all_best_answers(self):
  716. """api unmarks all best answers when unmark all choice is selected"""
  717. best_answer = test.reply_thread(self.thread)
  718. self.thread.set_best_answer(self.user, best_answer)
  719. self.thread.save()
  720. other_thread = test.post_thread(self.category)
  721. other_best_answer = test.reply_thread(other_thread)
  722. other_thread.set_best_answer(self.user, other_best_answer)
  723. other_thread.save()
  724. response = self.client.post(
  725. self.api_link,
  726. json.dumps(
  727. {
  728. "threads": [self.thread.id, other_thread.id],
  729. "title": "Merged thread!",
  730. "category": self.category.id,
  731. "best_answer": 0,
  732. }
  733. ),
  734. content_type="application/json",
  735. )
  736. self.assertEqual(response.status_code, 200)
  737. # best answer is not set on new thread
  738. new_thread = Thread.objects.get(pk=response.json()["id"])
  739. self.assertFalse(new_thread.has_best_answer)
  740. self.assertIsNone(new_thread.best_answer_id)
  741. @patch_category_acl({"can_merge_threads": True})
  742. def test_threads_merge_conflict_keep_first_best_answer(self):
  743. """api unmarks other best answer on merge"""
  744. best_answer = test.reply_thread(self.thread)
  745. self.thread.set_best_answer(self.user, best_answer)
  746. self.thread.save()
  747. other_thread = test.post_thread(self.category)
  748. other_best_answer = test.reply_thread(other_thread)
  749. other_thread.set_best_answer(self.user, other_best_answer)
  750. other_thread.save()
  751. response = self.client.post(
  752. self.api_link,
  753. json.dumps(
  754. {
  755. "threads": [self.thread.id, other_thread.id],
  756. "title": "Merged thread!",
  757. "category": self.category.id,
  758. "best_answer": self.thread.pk,
  759. }
  760. ),
  761. content_type="application/json",
  762. )
  763. self.assertEqual(response.status_code, 200)
  764. # selected best answer is set on new thread
  765. new_thread = Thread.objects.get(pk=response.json()["id"])
  766. self.assertEqual(new_thread.best_answer_id, best_answer.id)
  767. @patch_category_acl({"can_merge_threads": True})
  768. def test_threads_merge_conflict_keep_other_best_answer(self):
  769. """api unmarks first best answer on merge"""
  770. best_answer = test.reply_thread(self.thread)
  771. self.thread.set_best_answer(self.user, best_answer)
  772. self.thread.save()
  773. other_thread = test.post_thread(self.category)
  774. other_best_answer = test.reply_thread(other_thread)
  775. other_thread.set_best_answer(self.user, other_best_answer)
  776. other_thread.save()
  777. response = self.client.post(
  778. self.api_link,
  779. json.dumps(
  780. {
  781. "threads": [self.thread.id, other_thread.id],
  782. "title": "Merged thread!",
  783. "category": self.category.id,
  784. "best_answer": other_thread.pk,
  785. }
  786. ),
  787. content_type="application/json",
  788. )
  789. self.assertEqual(response.status_code, 200)
  790. # selected best answer is set on new thread
  791. new_thread = Thread.objects.get(pk=response.json()["id"])
  792. self.assertEqual(new_thread.best_answer_id, other_best_answer.id)
  793. @patch_category_acl({"can_merge_threads": True})
  794. def test_merge_threads_kept_poll(self):
  795. """api merges two threads successfully, keeping poll from other thread"""
  796. other_thread = test.post_thread(self.category)
  797. poll = test.post_poll(other_thread, self.user)
  798. response = self.client.post(
  799. self.api_link,
  800. json.dumps(
  801. {
  802. "threads": [self.thread.id, other_thread.id],
  803. "title": "Merged thread!",
  804. "category": self.category.id,
  805. }
  806. ),
  807. content_type="application/json",
  808. )
  809. self.assertEqual(response.status_code, 200)
  810. new_thread = Thread.objects.get(pk=response.json()["id"])
  811. # poll and its votes were kept
  812. self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=new_thread).count(), 1)
  813. self.assertEqual(
  814. PollVote.objects.filter(poll=poll, thread=new_thread).count(), 4
  815. )
  816. self.assertEqual(Poll.objects.count(), 1)
  817. self.assertEqual(PollVote.objects.count(), 4)
  818. @patch_category_acl({"can_merge_threads": True})
  819. def test_merge_threads_moved_poll(self):
  820. """api merges two threads successfully, moving poll from old thread"""
  821. other_thread = test.post_thread(self.category)
  822. poll = test.post_poll(self.thread, self.user)
  823. response = self.client.post(
  824. self.api_link,
  825. json.dumps(
  826. {
  827. "threads": [self.thread.id, other_thread.id],
  828. "title": "Merged thread!",
  829. "category": self.category.id,
  830. }
  831. ),
  832. content_type="application/json",
  833. )
  834. self.assertEqual(response.status_code, 200)
  835. new_thread = Thread.objects.get(pk=response.json()["id"])
  836. # poll and its votes were kept
  837. self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=new_thread).count(), 1)
  838. self.assertEqual(
  839. PollVote.objects.filter(poll=poll, thread=new_thread).count(), 4
  840. )
  841. self.assertEqual(Poll.objects.count(), 1)
  842. self.assertEqual(PollVote.objects.count(), 4)
  843. @patch_category_acl({"can_merge_threads": True})
  844. def test_threads_merge_conflict_poll(self):
  845. """api errors on merge conflict, returning list of available polls"""
  846. other_thread = test.post_thread(self.category)
  847. poll = test.post_poll(self.thread, self.user)
  848. other_poll = test.post_poll(other_thread, self.user)
  849. response = self.client.post(
  850. self.api_link,
  851. json.dumps(
  852. {
  853. "threads": [self.thread.id, other_thread.id],
  854. "title": "Merged thread!",
  855. "category": self.category.id,
  856. }
  857. ),
  858. content_type="application/json",
  859. )
  860. self.assertEqual(response.status_code, 400)
  861. self.assertEqual(
  862. response.json(),
  863. {
  864. "polls": [
  865. ["0", "Delete all polls"],
  866. [
  867. str(other_poll.pk),
  868. "%s (%s)" % (other_poll.question, other_poll.thread.title),
  869. ],
  870. [str(poll.pk), "%s (%s)" % (poll.question, poll.thread.title)],
  871. ]
  872. },
  873. )
  874. # polls and votes were untouched
  875. self.assertEqual(Poll.objects.count(), 2)
  876. self.assertEqual(PollVote.objects.count(), 8)
  877. @patch_category_acl({"can_merge_threads": True})
  878. def test_threads_merge_conflict_poll_invalid_resolution(self):
  879. """api errors on invalid merge conflict resolution"""
  880. other_thread = test.post_thread(self.category)
  881. test.post_poll(self.thread, self.user)
  882. test.post_poll(other_thread, self.user)
  883. response = self.client.post(
  884. self.api_link,
  885. json.dumps(
  886. {
  887. "threads": [self.thread.id, other_thread.id],
  888. "title": "Merged thread!",
  889. "category": self.category.id,
  890. "poll": other_thread.poll.id + 10,
  891. }
  892. ),
  893. content_type="application/json",
  894. )
  895. self.assertEqual(response.status_code, 400)
  896. self.assertEqual(response.json(), {"poll": ["Invalid choice."]})
  897. # polls and votes were untouched
  898. self.assertEqual(Poll.objects.count(), 2)
  899. self.assertEqual(PollVote.objects.count(), 8)
  900. @patch_category_acl({"can_merge_threads": True})
  901. def test_threads_merge_conflict_delete_all_polls(self):
  902. """api deletes all polls when delete all choice is selected"""
  903. other_thread = test.post_thread(self.category)
  904. test.post_poll(self.thread, self.user)
  905. test.post_poll(other_thread, self.user)
  906. response = self.client.post(
  907. self.api_link,
  908. json.dumps(
  909. {
  910. "threads": [self.thread.id, other_thread.id],
  911. "title": "Merged thread!",
  912. "category": self.category.id,
  913. "poll": 0,
  914. }
  915. ),
  916. content_type="application/json",
  917. )
  918. self.assertEqual(response.status_code, 200)
  919. # polls and votes are gone
  920. self.assertEqual(Poll.objects.count(), 0)
  921. self.assertEqual(PollVote.objects.count(), 0)
  922. @patch_category_acl({"can_merge_threads": True})
  923. def test_threads_merge_conflict_keep_first_poll(self):
  924. """api deletes other poll on merge"""
  925. other_thread = test.post_thread(self.category)
  926. poll = test.post_poll(self.thread, self.user)
  927. other_poll = test.post_poll(other_thread, self.user)
  928. response = self.client.post(
  929. self.api_link,
  930. json.dumps(
  931. {
  932. "threads": [self.thread.id, other_thread.id],
  933. "title": "Merged thread!",
  934. "category": self.category.id,
  935. "poll": poll.pk,
  936. }
  937. ),
  938. content_type="application/json",
  939. )
  940. self.assertEqual(response.status_code, 200)
  941. # other poll and its votes are gone
  942. self.assertEqual(Poll.objects.count(), 1)
  943. self.assertEqual(PollVote.objects.count(), 4)
  944. Poll.objects.get(pk=poll.pk)
  945. with self.assertRaises(Poll.DoesNotExist):
  946. Poll.objects.get(pk=other_poll.pk)
  947. @patch_category_acl({"can_merge_threads": True})
  948. def test_threads_merge_conflict_keep_other_poll(self):
  949. """api deletes first poll on merge"""
  950. other_thread = test.post_thread(self.category)
  951. poll = test.post_poll(self.thread, self.user)
  952. other_poll = test.post_poll(other_thread, self.user)
  953. response = self.client.post(
  954. self.api_link,
  955. json.dumps(
  956. {
  957. "threads": [self.thread.id, other_thread.id],
  958. "title": "Merged thread!",
  959. "category": self.category.id,
  960. "poll": other_poll.pk,
  961. }
  962. ),
  963. content_type="application/json",
  964. )
  965. self.assertEqual(response.status_code, 200)
  966. # other poll and its votes are gone
  967. self.assertEqual(Poll.objects.count(), 1)
  968. self.assertEqual(PollVote.objects.count(), 4)
  969. Poll.objects.get(pk=other_poll.pk)
  970. with self.assertRaises(Poll.DoesNotExist):
  971. Poll.objects.get(pk=poll.pk)