test_threads_merge_api.py 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097
  1. import json
  2. from django.urls import reverse
  3. from misago.acl import useracl
  4. from misago.acl.objectacl import add_acl_to_obj
  5. from misago.categories.models import Category
  6. from misago.conftest import get_cache_versions
  7. from misago.readtracker import poststracker
  8. from misago.threads import test
  9. from misago.threads.models import Poll, PollVote, Post, Thread
  10. from misago.threads.serializers import ThreadsListSerializer
  11. from misago.threads.serializers.moderation import THREADS_LIMIT
  12. from misago.threads.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)