test_project_group_index.py 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619
  1. from __future__ import annotations
  2. import time
  3. from collections.abc import Sequence
  4. from datetime import timedelta
  5. from functools import cached_property
  6. from unittest.mock import Mock, call, patch
  7. from urllib.parse import quote
  8. from uuid import uuid4
  9. from django.conf import settings
  10. from django.utils import timezone
  11. from sentry.integrations.models.external_issue import ExternalIssue
  12. from sentry.integrations.models.organization_integration import OrganizationIntegration
  13. from sentry.issues.grouptype import PerformanceSlowDBQueryGroupType
  14. from sentry.models.activity import Activity
  15. from sentry.models.apitoken import ApiToken
  16. from sentry.models.group import Group, GroupStatus
  17. from sentry.models.groupassignee import GroupAssignee
  18. from sentry.models.groupbookmark import GroupBookmark
  19. from sentry.models.grouphash import GroupHash
  20. from sentry.models.groupinbox import GroupInboxReason, add_group_to_inbox
  21. from sentry.models.grouplink import GroupLink
  22. from sentry.models.groupresolution import GroupResolution
  23. from sentry.models.groupseen import GroupSeen
  24. from sentry.models.groupshare import GroupShare
  25. from sentry.models.groupsnooze import GroupSnooze
  26. from sentry.models.groupsubscription import GroupSubscription
  27. from sentry.models.grouptombstone import GroupTombstone
  28. from sentry.models.project import Project
  29. from sentry.models.release import Release
  30. from sentry.silo.base import SiloMode
  31. from sentry.testutils.cases import APITestCase, SnubaTestCase
  32. from sentry.testutils.helpers import parse_link_header, with_feature
  33. from sentry.testutils.helpers.datetime import before_now, iso_format
  34. from sentry.testutils.silo import assume_test_silo_mode
  35. from sentry.types.activity import ActivityType
  36. from sentry.users.models.user_option import UserOption
  37. from sentry.utils import json
  38. class GroupListTest(APITestCase, SnubaTestCase):
  39. def setUp(self):
  40. super().setUp()
  41. self.min_ago = before_now(minutes=1)
  42. def _parse_links(self, header):
  43. # links come in {url: {...attrs}}, but we need {rel: {...attrs}}
  44. links = {}
  45. for url, attrs in parse_link_header(header).items():
  46. links[attrs["rel"]] = attrs
  47. attrs["href"] = url
  48. return links
  49. @cached_property
  50. def path(self):
  51. return f"/api/0/projects/{self.project.organization.slug}/{self.project.slug}/issues/"
  52. def test_sort_by_date_with_tag(self):
  53. # XXX(dcramer): this tests a case where an ambiguous column name existed
  54. group1 = self.create_group(last_seen=before_now(seconds=1))
  55. self.login_as(user=self.user)
  56. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  57. assert response.status_code == 200
  58. assert len(response.data) == 1
  59. assert response.data[0]["id"] == str(group1.id)
  60. def test_invalid_query(self):
  61. self.create_group(last_seen=before_now(seconds=1))
  62. self.login_as(user=self.user)
  63. response = self.client.get(f"{self.path}?sort_by=date&query=timesSeen:>1t", format="json")
  64. assert response.status_code == 400
  65. assert "Error parsing search query" in response.data["detail"]
  66. def test_simple_pagination(self):
  67. event1 = self.store_event(
  68. data={
  69. "fingerprint": ["put-me-in-group-1"],
  70. "timestamp": iso_format(self.min_ago - timedelta(seconds=2)),
  71. },
  72. project_id=self.project.id,
  73. )
  74. event2 = self.store_event(
  75. data={
  76. "fingerprint": ["put-me-in-group-2"],
  77. "timestamp": iso_format(self.min_ago - timedelta(seconds=1)),
  78. },
  79. project_id=self.project.id,
  80. )
  81. self.login_as(user=self.user)
  82. response = self.client.get(f"{self.path}?sort_by=date&limit=1", format="json")
  83. assert response.status_code == 200
  84. assert len(response.data) == 1
  85. assert response.data[0]["id"] == str(event2.group.id)
  86. links = self._parse_links(response["Link"])
  87. assert links["previous"]["results"] == "false"
  88. assert links["next"]["results"] == "true"
  89. response = self.client.get(links["next"]["href"], format="json")
  90. assert response.status_code == 200
  91. assert len(response.data) == 1
  92. assert response.data[0]["id"] == str(event1.group.id)
  93. links = self._parse_links(response["Link"])
  94. assert links["previous"]["results"] == "true"
  95. assert links["next"]["results"] == "false"
  96. def test_stats_period(self):
  97. # TODO(dcramer): this test really only checks if validation happens
  98. # on statsPeriod
  99. self.create_group(last_seen=before_now(seconds=1))
  100. self.create_group(last_seen=timezone.now())
  101. self.login_as(user=self.user)
  102. response = self.client.get(f"{self.path}?statsPeriod=24h", format="json")
  103. assert response.status_code == 200
  104. response = self.client.get(f"{self.path}?statsPeriod=14d", format="json")
  105. assert response.status_code == 200
  106. response = self.client.get(f"{self.path}?statsPeriod=", format="json")
  107. assert response.status_code == 200
  108. time.sleep(1)
  109. response = self.client.get(f"{self.path}?statsPeriod=48h", format="json")
  110. assert response.status_code == 400
  111. def test_environment(self):
  112. self.store_event(
  113. data={
  114. "fingerprint": ["put-me-in-group1"],
  115. "timestamp": iso_format(self.min_ago),
  116. "environment": "production",
  117. },
  118. project_id=self.project.id,
  119. )
  120. self.store_event(
  121. data={
  122. "fingerprint": ["put-me-in-group2"],
  123. "timestamp": iso_format(self.min_ago),
  124. "environment": "staging",
  125. },
  126. project_id=self.project.id,
  127. )
  128. self.login_as(user=self.user)
  129. response = self.client.get(self.path + "?environment=production", format="json")
  130. assert response.status_code == 200
  131. assert len(response.data) == 1
  132. response = self.client.get(self.path + "?environment=garbage", format="json")
  133. assert response.status_code == 200
  134. assert len(response.data) == 0
  135. def test_auto_resolved(self):
  136. project = self.project
  137. project.update_option("sentry:resolve_age", 1)
  138. self.create_group(last_seen=before_now(days=1))
  139. group2 = self.create_group(last_seen=timezone.now())
  140. self.login_as(user=self.user)
  141. response = self.client.get(self.path + "?query=is:unresolved", format="json")
  142. assert response.status_code == 200
  143. assert len(response.data) == 1
  144. assert response.data[0]["id"] == str(group2.id)
  145. def test_lookup_by_event_id(self):
  146. project = self.project
  147. project.update_option("sentry:resolve_age", 1)
  148. event_id = "c" * 32
  149. event = self.store_event(
  150. data={"event_id": event_id, "timestamp": iso_format(self.min_ago)},
  151. project_id=self.project.id,
  152. )
  153. self.login_as(user=self.user)
  154. response = self.client.get("{}?query={}".format(self.path, "c" * 32), format="json")
  155. assert response.status_code == 200
  156. assert len(response.data) == 1
  157. assert response.data[0]["id"] == str(event.group.id)
  158. assert response.data[0]["matchingEventId"] == event.event_id
  159. def test_lookup_by_event_with_matching_environment(self):
  160. project = self.project
  161. project.update_option("sentry:resolve_age", 1)
  162. self.create_environment(name="test", project=project)
  163. event = self.store_event(
  164. data={"environment": "test", "timestamp": iso_format(self.min_ago)},
  165. project_id=self.project.id,
  166. )
  167. self.login_as(user=self.user)
  168. response = self.client.get(
  169. f"{self.path}?query={event.event_id}&environment=test", format="json"
  170. )
  171. assert response.status_code == 200
  172. assert len(response.data) == 1
  173. assert response.data[0]["id"] == str(event.group.id)
  174. assert response.data[0]["matchingEventId"] == event.event_id
  175. assert response.data[0]["matchingEventEnvironment"] == "test"
  176. def test_lookup_by_event_id_with_whitespace(self):
  177. project = self.project
  178. project.update_option("sentry:resolve_age", 1)
  179. event = self.store_event(
  180. data={"event_id": "c" * 32, "timestamp": iso_format(self.min_ago)},
  181. project_id=self.project.id,
  182. )
  183. self.login_as(user=self.user)
  184. response = self.client.get(
  185. "{}?query=%20%20{}%20%20".format(self.path, "c" * 32), format="json"
  186. )
  187. assert response.status_code == 200
  188. assert len(response.data) == 1
  189. assert response.data[0]["id"] == str(event.group.id)
  190. def test_lookup_by_unknown_event_id(self):
  191. project = self.project
  192. project.update_option("sentry:resolve_age", 1)
  193. self.create_group()
  194. self.create_group()
  195. self.login_as(user=self.user)
  196. response = self.client.get("{}?query={}".format(self.path, "c" * 32), format="json")
  197. assert response.status_code == 200
  198. assert len(response.data) == 0
  199. def test_lookup_by_short_id(self):
  200. group = self.group
  201. short_id = group.qualified_short_id
  202. self.login_as(user=self.user)
  203. response = self.client.get(f"{self.path}?query={short_id}&shortIdLookup=1", format="json")
  204. assert response.status_code == 200
  205. assert len(response.data) == 1
  206. def test_lookup_by_short_id_no_perms(self):
  207. organization = self.create_organization()
  208. project = self.create_project(organization=organization)
  209. project2 = self.create_project(organization=organization)
  210. team = self.create_team(organization=organization)
  211. project2.add_team(team)
  212. group = self.create_group(project=project)
  213. user = self.create_user()
  214. self.create_member(organization=organization, user=user, teams=[team])
  215. short_id = group.qualified_short_id
  216. self.login_as(user=user)
  217. path = f"/api/0/projects/{organization.slug}/{project2.slug}/issues/"
  218. response = self.client.get(f"{path}?query={short_id}&shortIdLookup=1", format="json")
  219. assert response.status_code == 200
  220. assert len(response.data) == 0
  221. def test_lookup_by_first_release(self):
  222. self.login_as(self.user)
  223. project = self.project
  224. project2 = self.create_project(name="baz", organization=project.organization)
  225. release = Release.objects.create(organization=project.organization, version="12345")
  226. release.add_project(project)
  227. release.add_project(project2)
  228. group = self.store_event(
  229. data={"release": release.version, "timestamp": iso_format(before_now(seconds=1))},
  230. project_id=project.id,
  231. ).group
  232. self.store_event(
  233. data={"release": release.version, "timestamp": iso_format(before_now(seconds=1))},
  234. project_id=project2.id,
  235. )
  236. url = "{}?query={}".format(self.path, 'first-release:"%s"' % release.version)
  237. response = self.client.get(url, format="json")
  238. issues = json.loads(response.content)
  239. assert response.status_code == 200
  240. assert len(issues) == 1
  241. assert int(issues[0]["id"]) == group.id
  242. def test_lookup_by_release(self):
  243. self.login_as(self.user)
  244. version = "12345"
  245. event = self.store_event(
  246. data={"tags": {"sentry:release": version}}, project_id=self.project.id
  247. )
  248. group = event.group
  249. url = "{}?query={}".format(self.path, quote('release:"%s"' % version))
  250. response = self.client.get(url, format="json")
  251. issues = json.loads(response.content)
  252. assert response.status_code == 200
  253. assert len(issues) == 1
  254. assert int(issues[0]["id"]) == group.id
  255. def test_lookup_by_release_wildcard(self):
  256. self.login_as(self.user)
  257. version = "12345"
  258. event = self.store_event(
  259. data={"tags": {"sentry:release": version}}, project_id=self.project.id
  260. )
  261. group = event.group
  262. release_wildcard = version[:3] + "*"
  263. url = "{}?query={}".format(self.path, quote('release:"%s"' % release_wildcard))
  264. response = self.client.get(url, format="json")
  265. issues = json.loads(response.content)
  266. assert response.status_code == 200
  267. assert len(issues) == 1
  268. assert int(issues[0]["id"]) == group.id
  269. def test_pending_delete_pending_merge_excluded(self):
  270. self.create_group(status=GroupStatus.PENDING_DELETION)
  271. group = self.create_group()
  272. self.create_group(status=GroupStatus.DELETION_IN_PROGRESS)
  273. self.create_group(status=GroupStatus.PENDING_MERGE)
  274. self.login_as(user=self.user)
  275. response = self.client.get(self.path + "?query=is:unresolved", format="json")
  276. assert len(response.data) == 1
  277. assert response.data[0]["id"] == str(group.id)
  278. def test_filters_based_on_retention(self):
  279. self.login_as(user=self.user)
  280. self.create_group(last_seen=timezone.now() - timedelta(days=2))
  281. with self.options({"system.event-retention-days": 1}):
  282. response = self.client.get(self.path)
  283. assert response.status_code == 200, response.content
  284. assert len(response.data) == 0
  285. def test_token_auth(self):
  286. with assume_test_silo_mode(SiloMode.CONTROL):
  287. token = ApiToken.objects.create(user=self.user, scopes=256)
  288. response = self.client.get(
  289. self.path, format="json", HTTP_AUTHORIZATION=f"Bearer {token.token}"
  290. )
  291. assert response.status_code == 200, response.content
  292. def test_filter_not_unresolved(self):
  293. event = self.store_event(
  294. data={"timestamp": iso_format(before_now(seconds=500)), "fingerprint": ["group-1"]},
  295. project_id=self.project.id,
  296. )
  297. event.group.update(status=GroupStatus.RESOLVED, substatus=None)
  298. self.login_as(user=self.user)
  299. response = self.client.get(f"{self.path}?query=!is:unresolved", format="json")
  300. assert response.status_code == 200
  301. assert [int(r["id"]) for r in response.data] == [event.group.id]
  302. def test_single_group_by_hash(self):
  303. event = self.store_event(
  304. data={"timestamp": iso_format(before_now(seconds=500)), "fingerprint": ["group-1"]},
  305. project_id=self.project.id,
  306. )
  307. self.login_as(user=self.user)
  308. response = self.client.get(f"{self.path}?hashes={event.get_primary_hash()}")
  309. assert response.status_code == 200
  310. assert len(response.data) == 1
  311. assert int(response.data[0]["id"]) == event.group.id
  312. def test_multiple_groups_by_hashes(self):
  313. event = self.store_event(
  314. data={"timestamp": iso_format(before_now(seconds=500)), "fingerprint": ["group-1"]},
  315. project_id=self.project.id,
  316. )
  317. event2 = self.store_event(
  318. data={"timestamp": iso_format(before_now(seconds=400)), "fingerprint": ["group-2"]},
  319. project_id=self.project.id,
  320. )
  321. self.login_as(user=self.user)
  322. response = self.client.get(
  323. f"{self.path}?hashes={event.get_primary_hash()}&hashes={event2.get_primary_hash()}"
  324. )
  325. assert response.status_code == 200
  326. assert len(response.data) == 2
  327. response_group_ids = [int(group["id"]) for group in response.data]
  328. assert event.group.id in response_group_ids
  329. assert event2.group.id in response_group_ids
  330. class GroupUpdateTest(APITestCase, SnubaTestCase):
  331. def setUp(self):
  332. super().setUp()
  333. self.min_ago = timezone.now() - timedelta(minutes=1)
  334. @cached_property
  335. def path(self):
  336. return f"/api/0/projects/{self.project.organization.slug}/{self.project.slug}/issues/"
  337. def assertNoResolution(self, group):
  338. assert not GroupResolution.objects.filter(group=group).exists()
  339. def test_global_resolve(self):
  340. group1 = self.create_group(status=GroupStatus.RESOLVED)
  341. group2 = self.create_group(status=GroupStatus.UNRESOLVED)
  342. group3 = self.create_group(status=GroupStatus.IGNORED)
  343. group4 = self.create_group(
  344. project=self.create_project(slug="foo"),
  345. status=GroupStatus.UNRESOLVED,
  346. )
  347. self.login_as(user=self.user)
  348. response = self.client.put(
  349. f"{self.path}?status=unresolved&query=is:unresolved",
  350. data={"status": "resolved"},
  351. format="json",
  352. )
  353. assert response.status_code == 200, response.data
  354. assert response.data == {"status": "resolved", "statusDetails": {}, "inbox": None}
  355. # the previously resolved entry should not be included
  356. new_group1 = Group.objects.get(id=group1.id)
  357. assert new_group1.status == GroupStatus.RESOLVED
  358. assert new_group1.resolved_at is None
  359. # this wont exist because it wasn't affected
  360. assert not GroupSubscription.objects.filter(user_id=self.user.id, group=new_group1).exists()
  361. new_group2 = Group.objects.get(id=group2.id)
  362. assert new_group2.status == GroupStatus.RESOLVED
  363. assert new_group2.resolved_at is not None
  364. assert GroupSubscription.objects.filter(
  365. user_id=self.user.id, group=new_group2, is_active=True
  366. ).exists()
  367. # the ignored entry should not be included
  368. new_group3 = Group.objects.get(id=group3.id)
  369. assert new_group3.status == GroupStatus.IGNORED
  370. assert new_group3.resolved_at is None
  371. assert not GroupSubscription.objects.filter(user_id=self.user.id, group=new_group3)
  372. new_group4 = Group.objects.get(id=group4.id)
  373. assert new_group4.status == GroupStatus.UNRESOLVED
  374. assert new_group4.resolved_at is None
  375. assert not GroupSubscription.objects.filter(user_id=self.user.id, group=new_group4)
  376. def test_bulk_resolve(self):
  377. self.login_as(user=self.user)
  378. for i in range(200):
  379. self.create_group(status=GroupStatus.UNRESOLVED)
  380. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  381. assert len(response.data) == 100
  382. response = self.client.put(
  383. f"{self.path}?status=unresolved&query=is:unresolved",
  384. data={"status": "resolved"},
  385. format="json",
  386. )
  387. assert response.status_code == 200, response.data
  388. assert response.data == {"status": "resolved", "statusDetails": {}, "inbox": None}
  389. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  390. assert len(response.data) == 0
  391. @patch("sentry.integrations.example.integration.ExampleIntegration.sync_status_outbound")
  392. def test_resolve_with_integration(self, mock_sync_status_outbound):
  393. self.login_as(user=self.user)
  394. org = self.organization
  395. with assume_test_silo_mode(SiloMode.CONTROL):
  396. integration = self.create_provider_integration(provider="example", name="Example")
  397. integration.add_organization(org, self.user)
  398. group = self.create_group(status=GroupStatus.UNRESOLVED)
  399. with assume_test_silo_mode(SiloMode.CONTROL):
  400. OrganizationIntegration.objects.filter(
  401. integration_id=integration.id, organization_id=group.organization.id
  402. ).update(
  403. config={
  404. "sync_comments": True,
  405. "sync_status_outbound": True,
  406. "sync_status_inbound": True,
  407. "sync_assignee_outbound": True,
  408. "sync_assignee_inbound": True,
  409. }
  410. )
  411. external_issue = ExternalIssue.objects.get_or_create(
  412. organization_id=org.id, integration_id=integration.id, key="APP-%s" % group.id
  413. )[0]
  414. GroupLink.objects.get_or_create(
  415. group_id=group.id,
  416. project_id=group.project_id,
  417. linked_type=GroupLink.LinkedType.issue,
  418. linked_id=external_issue.id,
  419. relationship=GroupLink.Relationship.references,
  420. )[0]
  421. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  422. assert len(response.data) == 1
  423. with self.tasks():
  424. with self.feature({"organizations:integrations-issue-sync": True}):
  425. response = self.client.put(
  426. f"{self.path}?status=unresolved&query=is:unresolved",
  427. data={"status": "resolved"},
  428. format="json",
  429. )
  430. assert response.status_code == 200, response.data
  431. group = Group.objects.get(id=group.id)
  432. assert group.status == GroupStatus.RESOLVED
  433. assert response.data == {"status": "resolved", "statusDetails": {}, "inbox": None}
  434. mock_sync_status_outbound.assert_called_once_with(
  435. external_issue, True, group.project_id
  436. )
  437. response = self.client.get(f"{self.path}?sort_by=date&query=is:unresolved", format="json")
  438. assert len(response.data) == 0
  439. @patch("sentry.integrations.example.integration.ExampleIntegration.sync_status_outbound")
  440. def test_set_unresolved_with_integration(self, mock_sync_status_outbound):
  441. release = self.create_release(project=self.project, version="abc")
  442. group = self.create_group(status=GroupStatus.RESOLVED)
  443. org = self.organization
  444. with assume_test_silo_mode(SiloMode.CONTROL):
  445. integration = self.create_provider_integration(provider="example", name="Example")
  446. integration.add_organization(org, self.user)
  447. OrganizationIntegration.objects.filter(
  448. integration_id=integration.id, organization_id=group.organization.id
  449. ).update(
  450. config={
  451. "sync_comments": True,
  452. "sync_status_outbound": True,
  453. "sync_status_inbound": True,
  454. "sync_assignee_outbound": True,
  455. "sync_assignee_inbound": True,
  456. }
  457. )
  458. GroupResolution.objects.create(group=group, release=release)
  459. external_issue = ExternalIssue.objects.get_or_create(
  460. organization_id=org.id, integration_id=integration.id, key="APP-%s" % group.id
  461. )[0]
  462. GroupLink.objects.get_or_create(
  463. group_id=group.id,
  464. project_id=group.project_id,
  465. linked_type=GroupLink.LinkedType.issue,
  466. linked_id=external_issue.id,
  467. relationship=GroupLink.Relationship.references,
  468. )[0]
  469. self.login_as(user=self.user)
  470. url = f"{self.path}?id={group.id}"
  471. with self.tasks():
  472. with self.feature({"organizations:integrations-issue-sync": True}):
  473. response = self.client.put(url, data={"status": "unresolved"}, format="json")
  474. assert response.status_code == 200
  475. assert response.data == {"status": "unresolved", "statusDetails": {}}
  476. group = Group.objects.get(id=group.id)
  477. assert group.status == GroupStatus.UNRESOLVED
  478. self.assertNoResolution(group)
  479. assert GroupSubscription.objects.filter(
  480. user_id=self.user.id, group=group, is_active=True
  481. ).exists()
  482. mock_sync_status_outbound.assert_called_once_with(
  483. external_issue, False, group.project_id
  484. )
  485. def test_self_assign_issue(self):
  486. group = self.create_group(status=GroupStatus.UNRESOLVED)
  487. user = self.user
  488. with assume_test_silo_mode(SiloMode.CONTROL):
  489. uo1 = UserOption.objects.create(
  490. key="self_assign_issue", value="1", project_id=None, user=user
  491. )
  492. self.login_as(user=user)
  493. url = f"{self.path}?id={group.id}"
  494. response = self.client.put(url, data={"status": "resolved"}, format="json")
  495. assert response.status_code == 200, response.data
  496. assert response.data["assignedTo"]["id"] == str(user.id)
  497. assert response.data["assignedTo"]["type"] == "user"
  498. assert response.data["status"] == "resolved"
  499. assert GroupAssignee.objects.filter(group=group, user_id=user.id).exists()
  500. assert GroupSubscription.objects.filter(
  501. user_id=user.id, group=group, is_active=True
  502. ).exists()
  503. with assume_test_silo_mode(SiloMode.CONTROL):
  504. uo1.delete()
  505. def test_self_assign_issue_next_release(self):
  506. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  507. release.add_project(self.project)
  508. group = self.create_group(status=GroupStatus.UNRESOLVED)
  509. with assume_test_silo_mode(SiloMode.CONTROL):
  510. uo1 = UserOption.objects.create(
  511. key="self_assign_issue", value="1", project_id=None, user=self.user
  512. )
  513. self.login_as(user=self.user)
  514. url = f"{self.path}?id={group.id}"
  515. response = self.client.put(url, data={"status": "resolvedInNextRelease"}, format="json")
  516. assert response.status_code == 200
  517. assert response.data["status"] == "resolved"
  518. assert response.data["statusDetails"]["inNextRelease"]
  519. assert response.data["assignedTo"]["id"] == str(self.user.id)
  520. assert response.data["assignedTo"]["type"] == "user"
  521. group = Group.objects.get(id=group.id)
  522. assert group.status == GroupStatus.RESOLVED
  523. assert GroupResolution.objects.filter(group=group, release=release).exists()
  524. assert GroupSubscription.objects.filter(
  525. user_id=self.user.id, group=group, is_active=True
  526. ).exists()
  527. activity = Activity.objects.get(
  528. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  529. )
  530. assert activity.data["version"] == ""
  531. with assume_test_silo_mode(SiloMode.CONTROL):
  532. uo1.delete()
  533. def test_selective_status_update(self):
  534. group1 = self.create_group(status=GroupStatus.RESOLVED)
  535. group2 = self.create_group(status=GroupStatus.UNRESOLVED)
  536. group3 = self.create_group(status=GroupStatus.IGNORED)
  537. group4 = self.create_group(
  538. project=self.create_project(slug="foo"),
  539. status=GroupStatus.UNRESOLVED,
  540. )
  541. self.login_as(user=self.user)
  542. url = f"{self.path}?id={group1.id}&id={group2.id}&group4={group4.id}"
  543. response = self.client.put(url, data={"status": "resolved"}, format="json")
  544. assert response.status_code == 200
  545. assert response.data == {"status": "resolved", "statusDetails": {}, "inbox": None}
  546. new_group1 = Group.objects.get(id=group1.id)
  547. assert new_group1.resolved_at is not None
  548. assert new_group1.status == GroupStatus.RESOLVED
  549. new_group2 = Group.objects.get(id=group2.id)
  550. assert new_group2.resolved_at is not None
  551. assert new_group2.status == GroupStatus.RESOLVED
  552. assert GroupSubscription.objects.filter(
  553. user_id=self.user.id, group=new_group2, is_active=True
  554. ).exists()
  555. new_group3 = Group.objects.get(id=group3.id)
  556. assert new_group3.resolved_at is None
  557. assert new_group3.status == GroupStatus.IGNORED
  558. new_group4 = Group.objects.get(id=group4.id)
  559. assert new_group4.resolved_at is None
  560. assert new_group4.status == GroupStatus.UNRESOLVED
  561. def test_set_resolved_in_current_release(self):
  562. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  563. release.add_project(self.project)
  564. group = self.create_group(status=GroupStatus.UNRESOLVED)
  565. self.login_as(user=self.user)
  566. url = f"{self.path}?id={group.id}"
  567. response = self.client.put(
  568. url,
  569. data={"status": "resolved", "statusDetails": {"inRelease": "latest"}},
  570. format="json",
  571. )
  572. assert response.status_code == 200
  573. assert response.data["status"] == "resolved"
  574. assert response.data["statusDetails"]["inRelease"] == release.version
  575. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  576. assert "activity" in response.data
  577. group = Group.objects.get(id=group.id)
  578. assert group.status == GroupStatus.RESOLVED
  579. resolution = GroupResolution.objects.get(group=group)
  580. assert resolution.release == release
  581. assert resolution.type == GroupResolution.Type.in_release
  582. assert resolution.status == GroupResolution.Status.resolved
  583. assert resolution.actor_id == self.user.id
  584. assert GroupSubscription.objects.filter(
  585. user_id=self.user.id, group=group, is_active=True
  586. ).exists()
  587. activity = Activity.objects.get(
  588. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  589. )
  590. assert activity.data["version"] == release.version
  591. def test_set_resolved_in_explicit_release(self):
  592. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  593. release.add_project(self.project)
  594. release2 = Release.objects.create(organization_id=self.project.organization_id, version="b")
  595. release2.add_project(self.project)
  596. group = self.create_group(status=GroupStatus.UNRESOLVED)
  597. self.login_as(user=self.user)
  598. url = f"{self.path}?id={group.id}"
  599. response = self.client.put(
  600. url,
  601. data={"status": "resolved", "statusDetails": {"inRelease": release.version}},
  602. format="json",
  603. )
  604. assert response.status_code == 200
  605. assert response.data["status"] == "resolved"
  606. assert response.data["statusDetails"]["inRelease"] == release.version
  607. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  608. assert "activity" in response.data
  609. group = Group.objects.get(id=group.id)
  610. assert group.status == GroupStatus.RESOLVED
  611. resolution = GroupResolution.objects.get(group=group)
  612. assert resolution.release == release
  613. assert resolution.type == GroupResolution.Type.in_release
  614. assert resolution.status == GroupResolution.Status.resolved
  615. assert resolution.actor_id == self.user.id
  616. assert GroupSubscription.objects.filter(
  617. user_id=self.user.id, group=group, is_active=True
  618. ).exists()
  619. activity = Activity.objects.get(
  620. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  621. )
  622. assert activity.data["version"] == release.version
  623. def test_set_resolved_in_next_release(self):
  624. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  625. release.add_project(self.project)
  626. group = self.create_group(status=GroupStatus.UNRESOLVED)
  627. self.login_as(user=self.user)
  628. url = f"{self.path}?id={group.id}"
  629. response = self.client.put(
  630. url,
  631. data={"status": "resolved", "statusDetails": {"inNextRelease": True}},
  632. format="json",
  633. )
  634. assert response.status_code == 200
  635. assert response.data["status"] == "resolved"
  636. assert response.data["statusDetails"]["inNextRelease"]
  637. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  638. assert "activity" in response.data
  639. group = Group.objects.get(id=group.id)
  640. assert group.status == GroupStatus.RESOLVED
  641. resolution = GroupResolution.objects.get(group=group)
  642. assert resolution.release == release
  643. assert resolution.type == GroupResolution.Type.in_next_release
  644. assert resolution.status == GroupResolution.Status.pending
  645. assert resolution.actor_id == self.user.id
  646. assert GroupSubscription.objects.filter(
  647. user_id=self.user.id, group=group, is_active=True
  648. ).exists()
  649. activity = Activity.objects.get(
  650. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  651. )
  652. assert activity.data["version"] == ""
  653. def test_set_resolved_in_next_release_legacy(self):
  654. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  655. release.add_project(self.project)
  656. group = self.create_group(status=GroupStatus.UNRESOLVED)
  657. self.login_as(user=self.user)
  658. url = f"{self.path}?id={group.id}"
  659. response = self.client.put(url, data={"status": "resolvedInNextRelease"}, format="json")
  660. assert response.status_code == 200
  661. assert response.data["status"] == "resolved"
  662. assert response.data["statusDetails"]["inNextRelease"]
  663. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  664. group = Group.objects.get(id=group.id)
  665. assert group.status == GroupStatus.RESOLVED
  666. resolution = GroupResolution.objects.get(group=group)
  667. assert resolution.release == release
  668. assert resolution.type == GroupResolution.Type.in_next_release
  669. assert resolution.status == GroupResolution.Status.pending
  670. assert resolution.actor_id == self.user.id
  671. assert GroupSubscription.objects.filter(
  672. user_id=self.user.id, group=group, is_active=True
  673. ).exists()
  674. activity = Activity.objects.get(
  675. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  676. )
  677. assert activity.data["version"] == ""
  678. @with_feature("organizations:resolve-in-upcoming-release")
  679. def test_set_resolved_in_upcoming_release(self):
  680. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  681. release.add_project(self.project)
  682. group = self.create_group(status=GroupStatus.UNRESOLVED)
  683. self.login_as(user=self.user)
  684. url = f"{self.path}?id={group.id}"
  685. response = self.client.put(
  686. url,
  687. data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}},
  688. format="json",
  689. )
  690. assert response.status_code == 200
  691. assert response.data["status"] == "resolved"
  692. assert response.data["statusDetails"]["inUpcomingRelease"]
  693. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  694. assert "activity" in response.data
  695. group = Group.objects.get(id=group.id)
  696. assert group.status == GroupStatus.RESOLVED
  697. resolution = GroupResolution.objects.get(group=group)
  698. assert resolution.release == release
  699. assert resolution.type == GroupResolution.Type.in_upcoming_release
  700. assert resolution.status == GroupResolution.Status.pending
  701. assert resolution.actor_id == self.user.id
  702. assert GroupSubscription.objects.filter(
  703. user_id=self.user.id, group=group, is_active=True
  704. ).exists()
  705. activity = Activity.objects.get(
  706. group=group, type=ActivityType.SET_RESOLVED_IN_RELEASE.value
  707. )
  708. assert activity.data["version"] == ""
  709. def test_upcoming_release_flag_validation(self):
  710. release = Release.objects.create(organization_id=self.project.organization_id, version="a")
  711. release.add_project(self.project)
  712. group = self.create_group(status=GroupStatus.UNRESOLVED)
  713. self.login_as(user=self.user)
  714. url = f"{self.path}?id={group.id}"
  715. response = self.client.put(
  716. url,
  717. data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}},
  718. format="json",
  719. )
  720. assert response.status_code == 400
  721. assert (
  722. response.data["statusDetails"]["inUpcomingRelease"][0]
  723. == "Your organization does not have access to this feature."
  724. )
  725. @with_feature("organizations:resolve-in-upcoming-release")
  726. def test_upcoming_release_release_validation(self):
  727. group = self.create_group(status=GroupStatus.UNRESOLVED)
  728. self.login_as(user=self.user)
  729. url = f"{self.path}?id={group.id}"
  730. response = self.client.put(
  731. url,
  732. data={"status": "resolved", "statusDetails": {"inUpcomingRelease": True}},
  733. format="json",
  734. )
  735. assert response.status_code == 400
  736. assert (
  737. response.data["statusDetails"]["inUpcomingRelease"][0]
  738. == "No release data present in the system."
  739. )
  740. def test_set_resolved_in_explicit_commit_unreleased(self):
  741. repo = self.create_repo(project=self.project, name=self.project.name)
  742. commit = self.create_commit(project=self.project, repo=repo)
  743. group = self.create_group(status=GroupStatus.UNRESOLVED)
  744. self.login_as(user=self.user)
  745. url = f"{self.path}?id={group.id}"
  746. response = self.client.put(
  747. url,
  748. data={
  749. "status": "resolved",
  750. "statusDetails": {"inCommit": {"commit": commit.key, "repository": repo.name}},
  751. },
  752. format="json",
  753. )
  754. assert response.status_code == 200
  755. assert response.data["status"] == "resolved"
  756. assert response.data["statusDetails"]["inCommit"]["id"] == commit.key
  757. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  758. group = Group.objects.get(id=group.id)
  759. assert group.status == GroupStatus.RESOLVED
  760. link = GroupLink.objects.get(group_id=group.id)
  761. assert link.linked_type == GroupLink.LinkedType.commit
  762. assert link.relationship == GroupLink.Relationship.resolves
  763. assert link.linked_id == commit.id
  764. assert GroupSubscription.objects.filter(
  765. user_id=self.user.id, group=group, is_active=True
  766. ).exists()
  767. activity = Activity.objects.get(group=group, type=ActivityType.SET_RESOLVED_IN_COMMIT.value)
  768. assert activity.data["commit"] == commit.id
  769. def test_set_resolved_in_explicit_commit_released(self):
  770. release = self.create_release(project=self.project)
  771. repo = self.create_repo(project=self.project, name=self.project.name)
  772. commit = self.create_commit(project=self.project, repo=repo, release=release)
  773. group = self.create_group(status=GroupStatus.UNRESOLVED)
  774. self.login_as(user=self.user)
  775. url = f"{self.path}?id={group.id}"
  776. response = self.client.put(
  777. url,
  778. data={
  779. "status": "resolved",
  780. "statusDetails": {"inCommit": {"commit": commit.key, "repository": repo.name}},
  781. },
  782. format="json",
  783. )
  784. assert response.status_code == 200
  785. assert response.data["status"] == "resolved"
  786. assert response.data["statusDetails"]["inCommit"]["id"] == commit.key
  787. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  788. group = Group.objects.get(id=group.id)
  789. assert group.status == GroupStatus.RESOLVED
  790. link = GroupLink.objects.get(group_id=group.id)
  791. assert link.project_id == self.project.id
  792. assert link.linked_type == GroupLink.LinkedType.commit
  793. assert link.relationship == GroupLink.Relationship.resolves
  794. assert link.linked_id == commit.id
  795. assert GroupSubscription.objects.filter(
  796. user_id=self.user.id, group=group, is_active=True
  797. ).exists()
  798. activity = Activity.objects.get(group=group, type=ActivityType.SET_RESOLVED_IN_COMMIT.value)
  799. assert activity.data["commit"] == commit.id
  800. resolution = GroupResolution.objects.get(group=group)
  801. assert resolution.type == GroupResolution.Type.in_release
  802. assert resolution.status == GroupResolution.Status.resolved
  803. def test_set_resolved_in_explicit_commit_missing(self):
  804. repo = self.create_repo(project=self.project, name=self.project.name)
  805. group = self.create_group(status=GroupStatus.UNRESOLVED)
  806. self.login_as(user=self.user)
  807. url = f"{self.path}?id={group.id}"
  808. response = self.client.put(
  809. url,
  810. data={
  811. "status": "resolved",
  812. "statusDetails": {"inCommit": {"commit": "a" * 40, "repository": repo.name}},
  813. },
  814. format="json",
  815. )
  816. assert response.status_code == 400
  817. assert (
  818. response.data["statusDetails"]["inCommit"]["commit"][0]
  819. == "Unable to find the given commit."
  820. )
  821. def test_set_unresolved(self):
  822. release = self.create_release(project=self.project, version="abc")
  823. group = self.create_group(status=GroupStatus.RESOLVED)
  824. GroupResolution.objects.create(group=group, release=release)
  825. self.login_as(user=self.user)
  826. url = f"{self.path}?id={group.id}"
  827. response = self.client.put(url, data={"status": "unresolved"}, format="json")
  828. assert response.status_code == 200
  829. assert response.data == {"status": "unresolved", "statusDetails": {}}
  830. group = Group.objects.get(id=group.id)
  831. assert group.status == GroupStatus.UNRESOLVED
  832. self.assertNoResolution(group)
  833. assert GroupSubscription.objects.filter(
  834. user_id=self.user.id, group=group, is_active=True
  835. ).exists()
  836. def test_set_unresolved_on_snooze(self):
  837. group = self.create_group(status=GroupStatus.IGNORED)
  838. GroupSnooze.objects.create(group=group, until=timezone.now() - timedelta(days=1))
  839. self.login_as(user=self.user)
  840. url = f"{self.path}?id={group.id}"
  841. response = self.client.put(url, data={"status": "unresolved"}, format="json")
  842. assert response.status_code == 200
  843. assert response.data == {"status": "unresolved", "statusDetails": {}}
  844. group = Group.objects.get(id=group.id)
  845. assert group.status == GroupStatus.UNRESOLVED
  846. def test_basic_ignore(self):
  847. group = self.create_group(status=GroupStatus.RESOLVED)
  848. snooze = GroupSnooze.objects.create(group=group, until=timezone.now())
  849. self.login_as(user=self.user)
  850. url = f"{self.path}?id={group.id}"
  851. response = self.client.put(url, data={"status": "ignored"}, format="json")
  852. assert response.status_code == 200
  853. # existing snooze objects should be cleaned up
  854. assert not GroupSnooze.objects.filter(id=snooze.id).exists()
  855. group = Group.objects.get(id=group.id)
  856. assert group.status == GroupStatus.IGNORED
  857. assert response.data == {"status": "ignored", "statusDetails": {}, "inbox": None}
  858. def test_snooze_duration(self):
  859. group = self.create_group(status=GroupStatus.RESOLVED)
  860. self.login_as(user=self.user)
  861. url = f"{self.path}?id={group.id}"
  862. response = self.client.put(
  863. url, data={"status": "ignored", "ignoreDuration": 30}, format="json"
  864. )
  865. assert response.status_code == 200
  866. snooze = GroupSnooze.objects.get(group=group)
  867. now = timezone.now()
  868. assert snooze.count is None
  869. assert snooze.until is not None
  870. assert snooze.until > now + timedelta(minutes=29)
  871. assert snooze.until < now + timedelta(minutes=31)
  872. assert snooze.user_count is None
  873. assert snooze.user_window is None
  874. assert snooze.window is None
  875. assert response.data["status"] == "ignored"
  876. assert response.data["statusDetails"]["ignoreCount"] == snooze.count
  877. assert response.data["statusDetails"]["ignoreWindow"] == snooze.window
  878. assert response.data["statusDetails"]["ignoreUserCount"] == snooze.user_count
  879. assert response.data["statusDetails"]["ignoreUserWindow"] == snooze.user_window
  880. assert response.data["statusDetails"]["ignoreUntil"] == snooze.until
  881. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  882. def test_snooze_count(self):
  883. group = self.create_group(status=GroupStatus.RESOLVED, times_seen=1)
  884. self.login_as(user=self.user)
  885. url = f"{self.path}?id={group.id}"
  886. response = self.client.put(
  887. url, data={"status": "ignored", "ignoreCount": 100}, format="json"
  888. )
  889. assert response.status_code == 200
  890. snooze = GroupSnooze.objects.get(group=group)
  891. assert snooze.count == 100
  892. assert snooze.until is None
  893. assert snooze.user_count is None
  894. assert snooze.user_window is None
  895. assert snooze.window is None
  896. assert snooze.state is not None
  897. assert snooze.state["times_seen"] == 1
  898. assert response.data["status"] == "ignored"
  899. assert response.data["statusDetails"]["ignoreCount"] == snooze.count
  900. assert response.data["statusDetails"]["ignoreWindow"] == snooze.window
  901. assert response.data["statusDetails"]["ignoreUserCount"] == snooze.user_count
  902. assert response.data["statusDetails"]["ignoreUserWindow"] == snooze.user_window
  903. assert response.data["statusDetails"]["ignoreUntil"] == snooze.until
  904. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  905. def test_snooze_user_count(self):
  906. for i in range(10):
  907. event = self.store_event(
  908. data={
  909. "fingerprint": ["put-me-in-group-1"],
  910. "user": {"id": str(i)},
  911. "timestamp": iso_format(self.min_ago + timedelta(seconds=i)),
  912. },
  913. project_id=self.project.id,
  914. )
  915. group = Group.objects.get(id=event.group.id)
  916. group.status = GroupStatus.RESOLVED
  917. group.substatus = None
  918. group.save()
  919. self.login_as(user=self.user)
  920. url = f"{self.path}?id={group.id}"
  921. response = self.client.put(
  922. url, data={"status": "ignored", "ignoreUserCount": 10}, format="json"
  923. )
  924. assert response.status_code == 200
  925. snooze = GroupSnooze.objects.get(group=group)
  926. assert snooze.count is None
  927. assert snooze.until is None
  928. assert snooze.user_count == 10
  929. assert snooze.user_window is None
  930. assert snooze.window is None
  931. assert snooze.state is not None
  932. assert snooze.state["users_seen"] == 10
  933. assert response.data["status"] == "ignored"
  934. assert response.data["statusDetails"]["ignoreCount"] == snooze.count
  935. assert response.data["statusDetails"]["ignoreWindow"] == snooze.window
  936. assert response.data["statusDetails"]["ignoreUserCount"] == snooze.user_count
  937. assert response.data["statusDetails"]["ignoreUserWindow"] == snooze.user_window
  938. assert response.data["statusDetails"]["ignoreUntil"] == snooze.until
  939. assert response.data["statusDetails"]["actor"]["id"] == str(self.user.id)
  940. def test_set_bookmarked(self):
  941. group1 = self.create_group(status=GroupStatus.RESOLVED)
  942. group2 = self.create_group(status=GroupStatus.UNRESOLVED)
  943. group3 = self.create_group(status=GroupStatus.IGNORED)
  944. group4 = self.create_group(
  945. project=self.create_project(slug="foo"),
  946. status=GroupStatus.UNRESOLVED,
  947. )
  948. self.login_as(user=self.user)
  949. url = f"{self.path}?id={group1.id}&id={group2.id}&group4={group4.id}"
  950. response = self.client.put(url, data={"isBookmarked": "true"}, format="json")
  951. assert response.status_code == 200
  952. assert response.data == {"isBookmarked": True}
  953. bookmark1 = GroupBookmark.objects.filter(group=group1, user_id=self.user.id)
  954. assert bookmark1.exists()
  955. assert GroupSubscription.objects.filter(
  956. user_id=self.user.id, group=group1, is_active=True
  957. ).exists()
  958. bookmark2 = GroupBookmark.objects.filter(group=group2, user_id=self.user.id)
  959. assert bookmark2.exists()
  960. assert GroupSubscription.objects.filter(
  961. user_id=self.user.id, group=group2, is_active=True
  962. ).exists()
  963. bookmark3 = GroupBookmark.objects.filter(group=group3, user_id=self.user.id)
  964. assert not bookmark3.exists()
  965. bookmark4 = GroupBookmark.objects.filter(group=group4, user_id=self.user.id)
  966. assert not bookmark4.exists()
  967. def test_subscription(self):
  968. group1 = self.create_group()
  969. group2 = self.create_group()
  970. group3 = self.create_group()
  971. group4 = self.create_group(project=self.create_project(slug="foo"))
  972. self.login_as(user=self.user)
  973. url = f"{self.path}?id={group1.id}&id={group2.id}&group4={group4.id}"
  974. response = self.client.put(url, data={"isSubscribed": "true"}, format="json")
  975. assert response.status_code == 200
  976. assert response.data == {"isSubscribed": True, "subscriptionDetails": {"reason": "unknown"}}
  977. assert GroupSubscription.objects.filter(
  978. group=group1, user_id=self.user.id, is_active=True
  979. ).exists()
  980. assert GroupSubscription.objects.filter(
  981. group=group2, user_id=self.user.id, is_active=True
  982. ).exists()
  983. assert not GroupSubscription.objects.filter(group=group3, user_id=self.user.id).exists()
  984. assert not GroupSubscription.objects.filter(group=group4, user_id=self.user.id).exists()
  985. def test_set_public(self):
  986. group1 = self.create_group()
  987. group2 = self.create_group()
  988. self.login_as(user=self.user)
  989. url = f"{self.path}?id={group1.id}&id={group2.id}"
  990. response = self.client.put(url, data={"isPublic": "true"}, format="json")
  991. assert response.status_code == 200
  992. assert response.data["isPublic"] is True
  993. assert "shareId" in response.data
  994. new_group1 = Group.objects.get(id=group1.id)
  995. assert bool(new_group1.get_share_id())
  996. new_group2 = Group.objects.get(id=group2.id)
  997. assert bool(new_group2.get_share_id())
  998. def test_set_private(self):
  999. group1 = self.create_group()
  1000. group2 = self.create_group()
  1001. # Manually mark them as shared
  1002. for g in group1, group2:
  1003. GroupShare.objects.create(project_id=g.project_id, group=g)
  1004. assert bool(g.get_share_id())
  1005. self.login_as(user=self.user)
  1006. url = f"{self.path}?id={group1.id}&id={group2.id}"
  1007. response = self.client.put(url, data={"isPublic": "false"}, format="json")
  1008. assert response.status_code == 200
  1009. assert response.data == {"isPublic": False, "shareId": None}
  1010. new_group1 = Group.objects.get(id=group1.id)
  1011. assert not bool(new_group1.get_share_id())
  1012. new_group2 = Group.objects.get(id=group2.id)
  1013. assert not bool(new_group2.get_share_id())
  1014. def test_set_has_seen(self):
  1015. group1 = self.create_group(status=GroupStatus.RESOLVED)
  1016. group2 = self.create_group(status=GroupStatus.UNRESOLVED)
  1017. group3 = self.create_group(status=GroupStatus.IGNORED)
  1018. group4 = self.create_group(
  1019. project=self.create_project(slug="foo"),
  1020. status=GroupStatus.UNRESOLVED,
  1021. )
  1022. self.login_as(user=self.user)
  1023. url = f"{self.path}?id={group1.id}&id={group2.id}&group4={group4.id}"
  1024. response = self.client.put(url, data={"hasSeen": "true"}, format="json")
  1025. assert response.status_code == 200
  1026. assert response.data == {"hasSeen": True}
  1027. r1 = GroupSeen.objects.filter(group=group1, user_id=self.user.id)
  1028. assert r1.exists()
  1029. r2 = GroupSeen.objects.filter(group=group2, user_id=self.user.id)
  1030. assert r2.exists()
  1031. r3 = GroupSeen.objects.filter(group=group3, user_id=self.user.id)
  1032. assert not r3.exists()
  1033. r4 = GroupSeen.objects.filter(group=group4, user_id=self.user.id)
  1034. assert not r4.exists()
  1035. def test_inbox_fields(self):
  1036. group1 = self.create_group(status=GroupStatus.RESOLVED)
  1037. add_group_to_inbox(group1, GroupInboxReason.NEW)
  1038. self.login_as(user=self.user)
  1039. url = f"{self.path}?id={group1.id}"
  1040. response = self.client.put(url, data={"status": "resolved"}, format="json")
  1041. assert "inbox" in response.data
  1042. assert response.data["inbox"] is None
  1043. @patch("sentry.issues.merge.uuid4")
  1044. @patch("sentry.issues.merge.merge_groups")
  1045. @patch("sentry.eventstream.backend")
  1046. def test_merge(self, mock_eventstream, merge_groups, mock_uuid4):
  1047. eventstream_state = object()
  1048. mock_eventstream.start_merge = Mock(return_value=eventstream_state)
  1049. mock_uuid4.return_value = self.get_mock_uuid()
  1050. group1 = self.create_group(times_seen=1)
  1051. group2 = self.create_group(times_seen=50)
  1052. group3 = self.create_group(times_seen=2)
  1053. self.create_group()
  1054. self.login_as(user=self.user)
  1055. url = f"{self.path}?id={group1.id}&id={group2.id}&id={group3.id}"
  1056. response = self.client.put(url, data={"merge": "1"}, format="json")
  1057. assert response.status_code == 200
  1058. assert response.data["merge"]["parent"] == str(group2.id)
  1059. assert sorted(response.data["merge"]["children"]) == sorted(
  1060. [str(group1.id), str(group3.id)]
  1061. )
  1062. mock_eventstream.start_merge.assert_called_once_with(
  1063. group1.project_id, [group3.id, group1.id], group2.id
  1064. )
  1065. assert len(merge_groups.mock_calls) == 1
  1066. merge_groups.delay.assert_any_call(
  1067. from_object_ids=[group3.id, group1.id],
  1068. to_object_id=group2.id,
  1069. transaction_id="abc123",
  1070. eventstream_state=eventstream_state,
  1071. )
  1072. @patch("sentry.issues.merge.uuid4")
  1073. @patch("sentry.issues.merge.merge_groups")
  1074. @patch("sentry.eventstream.backend")
  1075. def test_merge_performance_issues(self, mock_eventstream, merge_groups, mock_uuid4):
  1076. eventstream_state = object()
  1077. mock_eventstream.start_merge = Mock(return_value=eventstream_state)
  1078. mock_uuid4.return_value = self.get_mock_uuid()
  1079. group1 = self.create_group(times_seen=1, type=PerformanceSlowDBQueryGroupType.type_id)
  1080. group2 = self.create_group(times_seen=50, type=PerformanceSlowDBQueryGroupType.type_id)
  1081. group3 = self.create_group(times_seen=2, type=PerformanceSlowDBQueryGroupType.type_id)
  1082. self.create_group()
  1083. self.login_as(user=self.user)
  1084. url = f"{self.path}?id={group1.id}&id={group2.id}&id={group3.id}"
  1085. response = self.client.put(url, data={"merge": "1"}, format="json")
  1086. assert response.status_code == 400, response.content
  1087. def test_assign(self):
  1088. group1 = self.create_group(is_public=True)
  1089. group2 = self.create_group(is_public=True)
  1090. user = self.user
  1091. self.login_as(user=user)
  1092. url = f"{self.path}?id={group1.id}"
  1093. response = self.client.put(url, data={"assignedTo": user.username})
  1094. assert response.status_code == 200
  1095. assert response.data["assignedTo"]["id"] == str(user.id)
  1096. assert response.data["assignedTo"]["type"] == "user"
  1097. assert GroupAssignee.objects.filter(group=group1, user_id=user.id).exists()
  1098. assert not GroupAssignee.objects.filter(group=group2, user_id=user.id).exists()
  1099. assert (
  1100. Activity.objects.filter(
  1101. group=group1, user_id=user.id, type=ActivityType.ASSIGNED.value
  1102. ).count()
  1103. == 1
  1104. )
  1105. assert GroupSubscription.objects.filter(
  1106. user_id=user.id, group=group1, is_active=True
  1107. ).exists()
  1108. response = self.client.put(url, data={"assignedTo": ""}, format="json")
  1109. assert response.status_code == 200, response.content
  1110. assert response.data["assignedTo"] is None
  1111. assert not GroupAssignee.objects.filter(group=group1, user_id=user.id).exists()
  1112. def test_assign_non_member(self):
  1113. group = self.create_group(is_public=True)
  1114. member = self.user
  1115. non_member = self.create_user("bar@example.com")
  1116. self.login_as(user=member)
  1117. url = f"{self.path}?id={group.id}"
  1118. response = self.client.put(url, data={"assignedTo": non_member.username}, format="json")
  1119. assert response.status_code == 400, response.content
  1120. def test_assign_team(self):
  1121. self.login_as(user=self.user)
  1122. group = self.create_group()
  1123. other_member = self.create_user("bar@example.com")
  1124. team = self.create_team(
  1125. organization=group.project.organization, members=[self.user, other_member]
  1126. )
  1127. group.project.add_team(team)
  1128. url = f"{self.path}?id={group.id}"
  1129. response = self.client.put(url, data={"assignedTo": f"team:{team.id}"})
  1130. assert response.status_code == 200
  1131. assert response.data["assignedTo"]["id"] == str(team.id)
  1132. assert response.data["assignedTo"]["type"] == "team"
  1133. assert GroupAssignee.objects.filter(group=group, team=team).exists()
  1134. assert Activity.objects.filter(group=group, type=ActivityType.ASSIGNED.value).count() == 1
  1135. assert GroupSubscription.objects.filter(group=group, is_active=True).count() == 2
  1136. response = self.client.put(url, data={"assignedTo": ""}, format="json")
  1137. assert response.status_code == 200, response.content
  1138. assert response.data["assignedTo"] is None
  1139. def test_discard(self):
  1140. group1 = self.create_group(is_public=True)
  1141. group2 = self.create_group(is_public=True)
  1142. group_hash = GroupHash.objects.create(hash="x" * 32, project=group1.project, group=group1)
  1143. user = self.user
  1144. self.login_as(user=user)
  1145. url = f"{self.path}?id={group1.id}"
  1146. with self.tasks():
  1147. with self.feature("projects:discard-groups"):
  1148. response = self.client.put(url, data={"discard": True})
  1149. assert response.status_code == 204
  1150. assert not Group.objects.filter(id=group1.id).exists()
  1151. assert Group.objects.filter(id=group2.id).exists()
  1152. assert GroupHash.objects.filter(id=group_hash.id).exists()
  1153. tombstone = GroupTombstone.objects.get(
  1154. id=GroupHash.objects.get(id=group_hash.id).group_tombstone_id
  1155. )
  1156. assert tombstone.message == group1.message
  1157. assert tombstone.culprit == group1.culprit
  1158. assert tombstone.project == group1.project
  1159. assert tombstone.data == group1.data
  1160. @patch(
  1161. "sentry.models.OrganizationMember.get_scopes",
  1162. return_value=frozenset(s for s in settings.SENTRY_SCOPES if s != "event:admin"),
  1163. )
  1164. def test_discard_requires_events_admin(self, mock_get_scopes):
  1165. group1 = self.create_group(is_public=True)
  1166. user = self.user
  1167. self.login_as(user=user)
  1168. url = f"{self.path}?id={group1.id}"
  1169. with self.tasks(), self.feature("projects:discard-groups"):
  1170. response = self.client.put(url, data={"discard": True})
  1171. assert response.status_code == 400
  1172. assert Group.objects.filter(id=group1.id).exists()
  1173. class GroupDeleteTest(APITestCase, SnubaTestCase):
  1174. @cached_property
  1175. def path(self):
  1176. return f"/api/0/projects/{self.project.organization.slug}/{self.project.slug}/issues/"
  1177. def create_groups(
  1178. self, groups_to_create: Sequence[tuple[int, Project, int | str | None]]
  1179. ) -> list[Group]:
  1180. groups = []
  1181. for status, project, type in groups_to_create:
  1182. if type is None:
  1183. groups.append(self.create_group(status=status, project=project))
  1184. else:
  1185. groups.append(self.create_group(status=status, project=project, type=type))
  1186. for g in groups:
  1187. hash = uuid4().hex
  1188. GroupHash.objects.create(project=g.project, hash=hash, group=g)
  1189. return groups
  1190. def assert_groups_being_deleted(self, groups: Sequence[Group]) -> None:
  1191. for g in groups:
  1192. assert Group.objects.get(id=g.id).status == GroupStatus.PENDING_DELETION
  1193. assert not GroupHash.objects.filter(group_id=g.id).exists()
  1194. # XXX: I do not understand why this update is necessary for the tests to function
  1195. Group.objects.filter(id__in=[g.id for g in groups]).update(status=GroupStatus.UNRESOLVED)
  1196. def assert_groups_are_gone(self, groups: Sequence[Group]) -> None:
  1197. for g in groups:
  1198. assert not Group.objects.filter(id=g.id).exists()
  1199. assert not GroupHash.objects.filter(group_id=g.id).exists()
  1200. def assert_groups_not_deleted(self, groups: Sequence[Group]) -> None:
  1201. for g in groups:
  1202. assert Group.objects.filter(id=g.id).exists()
  1203. assert Group.objects.get(id=g.id).status != GroupStatus.PENDING_DELETION
  1204. assert GroupHash.objects.filter(group_id=g.id).exists()
  1205. @patch("sentry.eventstream.backend")
  1206. def test_delete_by_id(self, mock_eventstream):
  1207. eventstream_state = {"event_stream_state": uuid4()}
  1208. mock_eventstream.start_delete_groups = Mock(return_value=eventstream_state)
  1209. groups = self.create_groups(
  1210. [
  1211. (GroupStatus.RESOLVED, self.project, None),
  1212. (GroupStatus.UNRESOLVED, self.project, None),
  1213. (GroupStatus.IGNORED, self.project, None),
  1214. (GroupStatus.UNRESOLVED, self.create_project(slug="foo"), None),
  1215. ],
  1216. )
  1217. group1, group2, group3, group4 = groups
  1218. self.login_as(user=self.user)
  1219. # Group 4 will not be deleted because it belongs to a different project
  1220. url = f"{self.path}?id={group1.id}&id={group2.id}&id={group4.id}"
  1221. response = self.client.delete(url, format="json")
  1222. mock_eventstream.start_delete_groups.assert_called_once_with(
  1223. group1.project_id, [group1.id, group2.id]
  1224. )
  1225. assert response.status_code == 204
  1226. self.assert_groups_being_deleted([group1, group2])
  1227. # Group 4 is not deleted because it belongs to a different project
  1228. self.assert_groups_not_deleted([group3, group4])
  1229. with self.tasks():
  1230. response = self.client.delete(url, format="json")
  1231. # XXX(markus): Something is sending duplicated replacements to snuba --
  1232. # once from within tasks.deletions.groups and another time from
  1233. # sentry.deletions.defaults.groups
  1234. assert mock_eventstream.end_delete_groups.call_args_list == [
  1235. call(eventstream_state),
  1236. call(eventstream_state),
  1237. ]
  1238. assert response.status_code == 204
  1239. self.assert_groups_are_gone([group1, group2])
  1240. self.assert_groups_not_deleted([group3, group4])
  1241. @patch("sentry.eventstream.backend")
  1242. def test_delete_performance_issue_by_id(self, mock_eventstream):
  1243. eventstream_state = {"event_stream_state": uuid4()}
  1244. mock_eventstream.start_delete_groups = Mock(return_value=eventstream_state)
  1245. group1, group2 = self.create_groups(
  1246. [
  1247. (GroupStatus.RESOLVED, self.project, PerformanceSlowDBQueryGroupType.type_id),
  1248. (GroupStatus.UNRESOLVED, self.project, PerformanceSlowDBQueryGroupType.type_id),
  1249. ],
  1250. )
  1251. self.login_as(user=self.user)
  1252. url = f"{self.path}?id={group1.id}&id={group2.id}"
  1253. response = self.client.delete(url, format="json")
  1254. # We do not support issue platform deletions
  1255. assert response.status_code == 400
  1256. self.assert_groups_not_deleted([group1, group2])
  1257. def test_bulk_delete(self):
  1258. groups_to_create = []
  1259. for _ in range(10, 41):
  1260. groups_to_create.append((GroupStatus.RESOLVED, self.project, None))
  1261. groups = self.create_groups(groups_to_create)
  1262. self.login_as(user=self.user)
  1263. # if query is '' it defaults to is:unresolved
  1264. url = self.path + "?query="
  1265. response = self.client.delete(url, format="json")
  1266. assert response.status_code == 204
  1267. self.assert_groups_being_deleted(groups)
  1268. with self.tasks():
  1269. response = self.client.delete(url, format="json")
  1270. assert response.status_code == 204
  1271. self.assert_groups_are_gone(groups)
  1272. def test_bulk_delete_performance_issues(self):
  1273. groups_to_create = []
  1274. for _ in range(10, 41):
  1275. groups_to_create.append(
  1276. (GroupStatus.RESOLVED, self.project, PerformanceSlowDBQueryGroupType.type_id)
  1277. )
  1278. groups = self.create_groups(groups_to_create)
  1279. self.login_as(user=self.user)
  1280. # if query is '' it defaults to is:unresolved
  1281. url = self.path + "?query="
  1282. response = self.client.delete(url, format="json")
  1283. # We do not support issue platform deletions
  1284. assert response.status_code == 400
  1285. self.assert_groups_not_deleted(groups)