test_threads_merge_api.py 38 KB

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