test_threads_merge_api.py 38 KB

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