test_threads_merge_api.py 43 KB

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