test_group_details.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. from typing import Any
  2. from unittest import mock
  3. from unittest.mock import patch
  4. from rest_framework.exceptions import ErrorDetail
  5. from sentry import tsdb
  6. from sentry.issues.forecasts import generate_and_save_forecasts
  7. from sentry.issues.grouptype import PerformanceSlowDBQueryGroupType
  8. from sentry.models.activity import Activity
  9. from sentry.models.environment import Environment
  10. from sentry.models.group import Group, GroupStatus
  11. from sentry.models.groupinbox import GroupInboxReason, add_group_to_inbox, remove_group_from_inbox
  12. from sentry.models.groupowner import GROUP_OWNER_TYPE, GroupOwner, GroupOwnerType
  13. from sentry.models.release import Release
  14. from sentry.testutils.cases import APITestCase, SnubaTestCase
  15. from sentry.testutils.helpers import Feature
  16. from sentry.testutils.helpers.datetime import before_now
  17. from sentry.types.activity import ActivityType
  18. from sentry.types.group import PriorityLevel
  19. class GroupDetailsTest(APITestCase, SnubaTestCase):
  20. def test_multiple_environments(self):
  21. group = self.create_group()
  22. self.login_as(user=self.user)
  23. environment = Environment.get_or_create(group.project, "production")
  24. environment2 = Environment.get_or_create(group.project, "staging")
  25. url = f"/api/0/issues/{group.id}/"
  26. with mock.patch(
  27. "sentry.issues.endpoints.group_details.tsdb.backend.get_range",
  28. side_effect=tsdb.backend.get_range,
  29. ) as get_range:
  30. response = self.client.get(
  31. f"{url}?environment=production&environment=staging", format="json"
  32. )
  33. assert response.status_code == 200
  34. assert get_range.call_count == 2
  35. for args, kwargs in get_range.call_args_list:
  36. assert kwargs["environment_ids"] == [environment.id, environment2.id]
  37. response = self.client.get(f"{url}?environment=invalid", format="json")
  38. assert response.status_code == 404
  39. def test_with_first_last_release(self):
  40. self.login_as(user=self.user)
  41. first_release = {
  42. "firstEvent": before_now(minutes=3),
  43. "lastEvent": before_now(minutes=2, seconds=30),
  44. }
  45. last_release = {
  46. "firstEvent": before_now(minutes=1, seconds=30),
  47. "lastEvent": before_now(minutes=1),
  48. }
  49. for timestamp in first_release.values():
  50. self.store_event(
  51. data={"release": "1.0", "timestamp": timestamp.isoformat()},
  52. project_id=self.project.id,
  53. )
  54. self.store_event(
  55. data={"release": "1.1", "timestamp": before_now(minutes=2).isoformat()},
  56. project_id=self.project.id,
  57. )
  58. event = [
  59. self.store_event(
  60. data={"release": "1.0a", "timestamp": timestamp.isoformat()},
  61. project_id=self.project.id,
  62. )
  63. for timestamp in last_release.values()
  64. ][-1]
  65. group = event.group
  66. url = f"/api/0/issues/{group.id}/"
  67. response = self.client.get(url, format="json")
  68. assert response.status_code == 200, response.content
  69. assert response.data["id"] == str(group.id)
  70. release = response.data["firstRelease"]
  71. assert release["version"] == "1.0"
  72. for event, timestamp in first_release.items():
  73. assert release[event].ctime() == timestamp.ctime()
  74. release = response.data["lastRelease"]
  75. assert release["version"] == "1.0a"
  76. for event, timestamp in last_release.items():
  77. assert release[event].ctime() == timestamp.ctime()
  78. def test_first_last_only_one_tagstore(self):
  79. self.login_as(user=self.user)
  80. event = self.store_event(
  81. data={"release": "1.0", "timestamp": before_now(days=3).isoformat()},
  82. project_id=self.project.id,
  83. )
  84. self.store_event(
  85. data={"release": "1.1", "timestamp": before_now(minutes=3).isoformat()},
  86. project_id=self.project.id,
  87. )
  88. group = event.group
  89. url = f"/api/0/issues/{group.id}/"
  90. with mock.patch("sentry.tagstore.backend.get_release_tags") as get_release_tags:
  91. response = self.client.get(url, format="json")
  92. assert response.status_code == 200
  93. assert get_release_tags.call_count == 1
  94. def test_first_release_only(self):
  95. self.login_as(user=self.user)
  96. first_event = before_now(days=3)
  97. self.store_event(
  98. data={"release": "1.0", "timestamp": first_event.isoformat()},
  99. project_id=self.project.id,
  100. )
  101. event = self.store_event(
  102. data={"release": "1.1", "timestamp": before_now(days=1).isoformat()},
  103. project_id=self.project.id,
  104. )
  105. # Forcibly remove one of the releases
  106. Release.objects.get(version="1.1").delete()
  107. group = event.group
  108. url = f"/api/0/issues/{group.id}/"
  109. response = self.client.get(url, format="json")
  110. assert response.status_code == 200, response.content
  111. assert response.data["firstRelease"]["version"] == "1.0"
  112. # only one event
  113. assert (
  114. response.data["firstRelease"]["firstEvent"]
  115. == response.data["firstRelease"]["lastEvent"]
  116. )
  117. assert response.data["firstRelease"]["firstEvent"].ctime() == first_event.ctime()
  118. assert response.data["lastRelease"] is None
  119. def test_group_expand_inbox(self):
  120. self.login_as(user=self.user)
  121. event = self.store_event(
  122. data={"timestamp": before_now(minutes=3).isoformat()},
  123. project_id=self.project.id,
  124. )
  125. group = event.group
  126. add_group_to_inbox(group, GroupInboxReason.NEW)
  127. url = f"/api/0/issues/{group.id}/?expand=inbox"
  128. response = self.client.get(url, format="json")
  129. assert response.status_code == 200, response.content
  130. assert response.data["inbox"] is not None
  131. assert response.data["inbox"]["reason"] == GroupInboxReason.NEW.value
  132. assert response.data["inbox"]["reason_details"] is None
  133. remove_group_from_inbox(event.group)
  134. response = self.client.get(url, format="json")
  135. assert response.status_code == 200, response.content
  136. assert response.data["inbox"] is None
  137. def test_group_expand_owners(self):
  138. self.login_as(user=self.user)
  139. event = self.store_event(
  140. data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]},
  141. project_id=self.project.id,
  142. )
  143. group = event.group
  144. url = f"/api/0/issues/{group.id}/?expand=owners"
  145. self.login_as(user=self.user)
  146. # Test with no owner
  147. response = self.client.get(url, format="json")
  148. assert response.status_code == 200
  149. assert response.data["owners"] is None
  150. # Test with owners
  151. GroupOwner.objects.create(
  152. group=event.group,
  153. project=event.project,
  154. organization=event.project.organization,
  155. type=GroupOwnerType.SUSPECT_COMMIT.value,
  156. user_id=self.user.id,
  157. )
  158. response = self.client.get(url, format="json")
  159. assert response.status_code == 200, response.content
  160. assert response.data["owners"] is not None
  161. assert len(response.data["owners"]) == 1
  162. assert response.data["owners"][0]["owner"] == f"user:{self.user.id}"
  163. assert response.data["owners"][0]["type"] == GROUP_OWNER_TYPE[GroupOwnerType.SUSPECT_COMMIT]
  164. def test_group_expand_forecasts(self):
  165. self.login_as(user=self.user)
  166. event = self.store_event(
  167. data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]},
  168. project_id=self.project.id,
  169. )
  170. group = event.group
  171. generate_and_save_forecasts([group])
  172. url = f"/api/0/issues/{group.id}/?expand=forecast"
  173. response = self.client.get(url, format="json")
  174. assert response.status_code == 200, response.content
  175. assert response.data["forecast"] is not None
  176. assert response.data["forecast"]["data"] is not None
  177. assert response.data["forecast"]["date_added"] is not None
  178. def test_group_get_priority(self):
  179. self.login_as(user=self.user)
  180. group = self.create_group(
  181. project=self.project,
  182. status=GroupStatus.IGNORED,
  183. priority=PriorityLevel.LOW,
  184. )
  185. url = f"/api/0/issues/{group.id}/"
  186. response = self.client.get(url, format="json")
  187. assert response.status_code == 200, response.content
  188. assert response.data["priority"] == "low"
  189. assert response.data["priorityLockedAt"] is None
  190. def test_group_post_priority(self):
  191. self.login_as(user=self.user)
  192. group = self.create_group(
  193. project=self.project,
  194. status=GroupStatus.IGNORED,
  195. priority=PriorityLevel.LOW,
  196. )
  197. url = f"/api/0/issues/{group.id}/"
  198. get_response_before = self.client.get(url, format="json")
  199. assert get_response_before.status_code == 200, get_response_before.content
  200. assert get_response_before.data["priority"] == "low"
  201. response = self.client.put(url, {"priority": "high"}, format="json")
  202. assert response.status_code == 200, response.content
  203. assert response.data["priority"] == "high"
  204. act_for_group = Activity.objects.get_activities_for_group(group=group, num=100)
  205. assert len(act_for_group) == 2
  206. assert act_for_group[0].type == ActivityType.SET_PRIORITY.value
  207. assert act_for_group[-1].type == ActivityType.FIRST_SEEN.value
  208. assert act_for_group[0].user_id == self.user.id
  209. assert act_for_group[0].data["priority"] == "high"
  210. get_response_after = self.client.get(url, format="json")
  211. assert get_response_after.status_code == 200, get_response_after.content
  212. assert get_response_after.data["priority"] == "high"
  213. assert get_response_after.data["priorityLockedAt"] is not None
  214. def test_assigned_to_unknown(self):
  215. self.login_as(user=self.user)
  216. event = self.store_event(
  217. data={"timestamp": before_now(minutes=3).isoformat()},
  218. project_id=self.project.id,
  219. )
  220. group = event.group
  221. url = f"/api/0/issues/{group.id}/"
  222. response = self.client.put(
  223. url, {"assignedTo": "admin@localhost", "status": "unresolved"}, format="json"
  224. )
  225. assert response.status_code == 200
  226. response = self.client.put(
  227. url, {"assignedTo": "user@doesnotexist.com", "status": "unresolved"}, format="json"
  228. )
  229. assert response.status_code == 400
  230. assert response.data == {
  231. "assignedTo": [
  232. ErrorDetail(
  233. string="Could not parse actor. Format should be `type:id` where type is `team` or `user`.",
  234. code="invalid",
  235. )
  236. ]
  237. }
  238. def test_collapse_stats_does_not_work(self):
  239. """
  240. 'collapse' param should hide the stats data and not return anything in the response, but the impl
  241. doesn't seem to respect this param.
  242. include this test here in-case the endpoint behavior changes in the future.
  243. """
  244. self.login_as(user=self.user)
  245. event = self.store_event(
  246. data={"timestamp": before_now(minutes=3).isoformat()},
  247. project_id=self.project.id,
  248. )
  249. group = event.group
  250. url = f"/api/0/issues/{group.id}/"
  251. response = self.client.get(url, {"collapse": ["stats"]}, format="json")
  252. assert response.status_code == 200
  253. assert int(response.data["id"]) == event.group.id
  254. assert response.data["stats"] # key shouldn't be present
  255. assert response.data["count"] is not None # key shouldn't be present
  256. assert response.data["userCount"] is not None # key shouldn't be present
  257. assert response.data["firstSeen"] is not None # key shouldn't be present
  258. assert response.data["lastSeen"] is not None # key shouldn't be present
  259. def test_issue_type_category(self):
  260. """Test that the issue's type and category is returned in the results"""
  261. self.login_as(user=self.user)
  262. event = self.store_event(
  263. data={"timestamp": before_now(minutes=3).isoformat()},
  264. project_id=self.project.id,
  265. )
  266. url = f"/api/0/issues/{event.group.id}/"
  267. response = self.client.get(url, format="json")
  268. assert response.status_code == 200
  269. assert int(response.data["id"]) == event.group.id
  270. assert response.data["issueType"] == "error"
  271. assert response.data["issueCategory"] == "error"
  272. def test_delete_error_issue(self) -> Any:
  273. """Test that a user cannot delete a error issue"""
  274. self.login_as(user=self.user)
  275. group = self.create_group(status=GroupStatus.RESOLVED, project=self.project)
  276. url = f"/api/0/issues/{group.id}/"
  277. with patch(
  278. "sentry.api.helpers.group_index.delete.delete_groups_task.apply_async"
  279. ) as mock_apply_async:
  280. response = self.client.delete(url, format="json")
  281. mock_apply_async.assert_called_once()
  282. kwargs = mock_apply_async.call_args[1]
  283. assert kwargs["countdown"] == 3600
  284. assert response.status_code == 202
  285. # Since the task has not executed yet the group is pending deletion
  286. assert Group.objects.get(id=group.id).status == GroupStatus.PENDING_DELETION
  287. # Undo some of what the previous endpoint call did
  288. group.update(status=GroupStatus.RESOLVED)
  289. with self.tasks():
  290. response = self.client.delete(url, format="json")
  291. assert response.status_code == 202
  292. assert not Group.objects.filter(id=group.id).exists()
  293. def test_delete_issue_platform_issue(self) -> Any:
  294. """Test that a user cannot delete an issue if issue platform deletion is not allowed"""
  295. self.login_as(user=self.user)
  296. group = self.create_group(
  297. status=GroupStatus.RESOLVED,
  298. project=self.project,
  299. type=PerformanceSlowDBQueryGroupType.type_id,
  300. )
  301. url = f"/api/0/issues/{group.id}/"
  302. response = self.client.delete(url, format="json")
  303. assert response.status_code == 400
  304. assert response.json() == ["Only error issues can be deleted."]
  305. # We are allowed to delete the groups with the feature flag enabled
  306. with Feature({"organizations:issue-platform-deletion": True}):
  307. with patch(
  308. "sentry.api.helpers.group_index.delete.delete_groups_task.apply_async"
  309. ) as mock_apply_async:
  310. response = self.client.delete(url, format="json")
  311. assert response.status_code == 202
  312. # Since the task has not executed yet the group is pending deletion
  313. assert Group.objects.get(id=group.id).status == GroupStatus.PENDING_DELETION
  314. mock_apply_async.assert_called_once()
  315. kwargs = mock_apply_async.call_args[1]
  316. # We don't wait to schedule the deletion of non-error issues
  317. assert kwargs["countdown"] == 0
  318. # Undo some of what the previous endpoint call did
  319. group.update(status=GroupStatus.RESOLVED)
  320. with Feature({"organizations:issue-platform-deletion": True}), self.tasks():
  321. response = self.client.delete(url, format="json")
  322. assert response.status_code == 202
  323. # Now check that the group doesn't exist
  324. assert not Group.objects.filter(id=group.id).exists()