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 ...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(), {"detail": "One or more thread ids received were invalid."}
  60. )
  61. def test_merge_single_thread(self):
  62. """api validates if we are trying to merge single thread"""
  63. response = self.client.post(
  64. self.api_link,
  65. json.dumps({"threads": [self.thread.id]}),
  66. content_type="application/json",
  67. )
  68. self.assertEqual(response.status_code, 403)
  69. self.assertEqual(
  70. response.json(),
  71. {"detail": "You have to select at least two threads to merge."},
  72. )
  73. def test_merge_with_nonexisting_thread(self):
  74. """api validates if we are trying to merge with invalid thread"""
  75. response = self.client.post(
  76. self.api_link,
  77. json.dumps({"threads": [self.thread.id, self.thread.id + 1000]}),
  78. content_type="application/json",
  79. )
  80. self.assertEqual(response.status_code, 403)
  81. self.assertEqual(
  82. response.json(),
  83. {"detail": "One or more threads to merge could not be found."},
  84. )
  85. def test_merge_with_invisible_thread(self):
  86. """api validates if we are trying to merge with inaccesible thread"""
  87. unaccesible_thread = test.post_thread(category=self.other_category)
  88. response = self.client.post(
  89. self.api_link,
  90. json.dumps({"threads": [self.thread.id, unaccesible_thread.id]}),
  91. content_type="application/json",
  92. )
  93. self.assertEqual(response.status_code, 403)
  94. self.assertEqual(
  95. response.json(),
  96. {"detail": "One or more threads to merge could not be found."},
  97. )
  98. def test_merge_no_permission(self):
  99. """api validates permission to merge threads"""
  100. thread = test.post_thread(category=self.category)
  101. response = self.client.post(
  102. self.api_link,
  103. json.dumps(
  104. {
  105. "category": self.category.id,
  106. "title": "Lorem ipsum dolor",
  107. "threads": [self.thread.id, thread.id],
  108. }
  109. ),
  110. content_type="application/json",
  111. )
  112. self.assertEqual(response.status_code, 403)
  113. self.assertEqual(
  114. response.json(),
  115. [
  116. {
  117. "id": thread.pk,
  118. "title": thread.title,
  119. "errors": ["You can't merge threads in this category."],
  120. },
  121. {
  122. "id": self.thread.pk,
  123. "title": self.thread.title,
  124. "errors": ["You can't merge threads in this category."],
  125. },
  126. ],
  127. )
  128. @patch_other_category_acl()
  129. @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
  130. def test_thread_category_is_closed(self):
  131. """api validates if thread's category is open"""
  132. other_thread = test.post_thread(self.category)
  133. self.category.is_closed = True
  134. self.category.save()
  135. response = self.client.post(
  136. self.api_link,
  137. json.dumps(
  138. {
  139. "category": self.other_category.id,
  140. "title": "Lorem ipsum dolor",
  141. "threads": [self.thread.id, other_thread.id],
  142. }
  143. ),
  144. content_type="application/json",
  145. )
  146. self.assertEqual(response.status_code, 403)
  147. self.assertEqual(
  148. response.json(),
  149. [
  150. {
  151. "id": other_thread.id,
  152. "title": other_thread.title,
  153. "errors": [
  154. "This category is closed. You can't merge it's threads."
  155. ],
  156. },
  157. {
  158. "id": self.thread.id,
  159. "title": self.thread.title,
  160. "errors": [
  161. "This category is closed. You can't merge it's threads."
  162. ],
  163. },
  164. ],
  165. )
  166. @patch_other_category_acl()
  167. @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
  168. def test_thread_is_closed(self):
  169. """api validates if thread is open"""
  170. other_thread = test.post_thread(self.category)
  171. other_thread.is_closed = True
  172. other_thread.save()
  173. response = self.client.post(
  174. self.api_link,
  175. json.dumps(
  176. {
  177. "category": self.other_category.id,
  178. "title": "Lorem ipsum dolor",
  179. "threads": [self.thread.id, other_thread.id],
  180. }
  181. ),
  182. content_type="application/json",
  183. )
  184. self.assertEqual(response.status_code, 403)
  185. self.assertEqual(
  186. response.json(),
  187. [
  188. {
  189. "id": other_thread.id,
  190. "title": other_thread.title,
  191. "errors": [
  192. "This thread is closed. You can't merge it with other threads."
  193. ],
  194. }
  195. ],
  196. )
  197. @patch_category_acl({"can_merge_threads": True})
  198. def test_merge_too_many_threads(self):
  199. """api rejects too many threads to merge"""
  200. threads = []
  201. for _ in range(THREADS_LIMIT + 1):
  202. threads.append(test.post_thread(category=self.category).pk)
  203. response = self.client.post(
  204. self.api_link,
  205. json.dumps({"threads": threads}),
  206. content_type="application/json",
  207. )
  208. self.assertEqual(response.status_code, 403)
  209. self.assertEqual(
  210. response.json(),
  211. {
  212. "detail": "No more than %s threads can be merged at single time."
  213. % THREADS_LIMIT
  214. },
  215. )
  216. @patch_category_acl({"can_merge_threads": True})
  217. def test_merge_no_final_thread(self):
  218. """api rejects merge because no data to merge threads was specified"""
  219. thread = test.post_thread(category=self.category)
  220. response = self.client.post(
  221. self.api_link,
  222. json.dumps({"threads": [self.thread.id, thread.id]}),
  223. content_type="application/json",
  224. )
  225. self.assertEqual(response.status_code, 400)
  226. self.assertEqual(
  227. response.json(),
  228. {
  229. "title": ["This field is required."],
  230. "category": ["This field is required."],
  231. },
  232. )
  233. @patch_category_acl({"can_merge_threads": True})
  234. def test_merge_invalid_final_title(self):
  235. """api rejects merge because final thread title was invalid"""
  236. thread = test.post_thread(category=self.category)
  237. response = self.client.post(
  238. self.api_link,
  239. json.dumps(
  240. {
  241. "threads": [self.thread.id, thread.id],
  242. "title": "$$$",
  243. "category": self.category.id,
  244. }
  245. ),
  246. content_type="application/json",
  247. )
  248. self.assertEqual(response.status_code, 400)
  249. self.assertEqual(
  250. response.json(),
  251. {
  252. "title": [
  253. "Thread title should be at least 5 characters long (it has 3)."
  254. ]
  255. },
  256. )
  257. @patch_category_acl({"can_merge_threads": True})
  258. def test_merge_invalid_category(self):
  259. """api rejects merge because final category was invalid"""
  260. thread = test.post_thread(category=self.category)
  261. response = self.client.post(
  262. self.api_link,
  263. json.dumps(
  264. {
  265. "threads": [self.thread.id, thread.id],
  266. "title": "Valid thread title",
  267. "category": self.other_category.id,
  268. }
  269. ),
  270. content_type="application/json",
  271. )
  272. self.assertEqual(response.status_code, 400)
  273. self.assertEqual(
  274. response.json(), {"category": ["Requested category could not be found."]}
  275. )
  276. @patch_category_acl({"can_merge_threads": True, "can_start_threads": False})
  277. def test_merge_unallowed_start_thread(self):
  278. """api rejects merge because category isn't allowing starting threads"""
  279. thread = test.post_thread(category=self.category)
  280. response = self.client.post(
  281. self.api_link,
  282. json.dumps(
  283. {
  284. "threads": [self.thread.id, thread.id],
  285. "title": "Valid thread title",
  286. "category": self.category.id,
  287. }
  288. ),
  289. content_type="application/json",
  290. )
  291. self.assertEqual(response.status_code, 400)
  292. self.assertEqual(
  293. response.json(),
  294. {"category": ["You can't create new threads in selected category."]},
  295. )
  296. @patch_category_acl({"can_merge_threads": True})
  297. def test_merge_invalid_weight(self):
  298. """api rejects merge because final weight was invalid"""
  299. thread = test.post_thread(category=self.category)
  300. response = self.client.post(
  301. self.api_link,
  302. json.dumps(
  303. {
  304. "threads": [self.thread.id, thread.id],
  305. "title": "Valid thread title",
  306. "category": self.category.id,
  307. "weight": 4,
  308. }
  309. ),
  310. content_type="application/json",
  311. )
  312. self.assertEqual(response.status_code, 400)
  313. self.assertEqual(
  314. response.json(),
  315. {"weight": ["Ensure this value is less than or equal to 2."]},
  316. )
  317. @patch_category_acl({"can_merge_threads": True})
  318. def test_merge_unallowed_global_weight(self):
  319. """api rejects merge because global weight was unallowed"""
  320. thread = test.post_thread(category=self.category)
  321. response = self.client.post(
  322. self.api_link,
  323. json.dumps(
  324. {
  325. "threads": [self.thread.id, thread.id],
  326. "title": "Valid thread title",
  327. "category": self.category.id,
  328. "weight": 2,
  329. }
  330. ),
  331. content_type="application/json",
  332. )
  333. self.assertEqual(response.status_code, 400)
  334. self.assertEqual(
  335. response.json(),
  336. {
  337. "weight": [
  338. "You don't have permission to pin threads globally 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)