test_organization_stats_v2.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  1. import functools
  2. from datetime import datetime, timedelta, timezone
  3. from django.urls import reverse
  4. from freezegun import freeze_time
  5. from sentry.constants import DataCategory
  6. from sentry.testutils import APITestCase
  7. from sentry.testutils.cases import OutcomesSnubaTest
  8. from sentry.testutils.silo import region_silo_test
  9. from sentry.utils.outcomes import Outcome
  10. @region_silo_test
  11. class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
  12. def setUp(self):
  13. super().setUp()
  14. self.now = datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc)
  15. self.login_as(user=self.user)
  16. self.org = self.organization
  17. self.org.flags.allow_joinleave = False
  18. self.org.save()
  19. self.org2 = self.create_organization()
  20. self.org3 = self.create_organization()
  21. self.project = self.create_project(
  22. name="bar", teams=[self.create_team(organization=self.org, members=[self.user])]
  23. )
  24. self.project2 = self.create_project(
  25. name="foo", teams=[self.create_team(organization=self.org, members=[self.user])]
  26. )
  27. self.project3 = self.create_project(organization=self.org2)
  28. self.user2 = self.create_user(is_superuser=False)
  29. self.create_member(user=self.user2, organization=self.organization, role="member", teams=[])
  30. self.create_member(user=self.user2, organization=self.org3, role="member", teams=[])
  31. self.project4 = self.create_project(
  32. name="users2sproj",
  33. teams=[self.create_team(organization=self.org, members=[self.user2])],
  34. )
  35. self.store_outcomes(
  36. {
  37. "org_id": self.org.id,
  38. "timestamp": self.now - timedelta(hours=1),
  39. "project_id": self.project.id,
  40. "outcome": Outcome.ACCEPTED,
  41. "reason": "none",
  42. "category": DataCategory.ERROR,
  43. "quantity": 1,
  44. },
  45. 5,
  46. )
  47. self.store_outcomes(
  48. {
  49. "org_id": self.org.id,
  50. "timestamp": self.now - timedelta(hours=1),
  51. "project_id": self.project.id,
  52. "outcome": Outcome.ACCEPTED,
  53. "reason": "none",
  54. "category": DataCategory.DEFAULT, # test that this shows up under error
  55. "quantity": 1,
  56. }
  57. )
  58. self.store_outcomes(
  59. {
  60. "org_id": self.org.id,
  61. "timestamp": self.now - timedelta(hours=1),
  62. "project_id": self.project.id,
  63. "outcome": Outcome.RATE_LIMITED,
  64. "reason": "smart_rate_limit",
  65. "category": DataCategory.ATTACHMENT,
  66. "quantity": 1024,
  67. }
  68. )
  69. self.store_outcomes(
  70. {
  71. "org_id": self.org.id,
  72. "timestamp": self.now - timedelta(hours=1),
  73. "project_id": self.project2.id,
  74. "outcome": Outcome.RATE_LIMITED,
  75. "reason": "smart_rate_limit",
  76. "category": DataCategory.TRANSACTION,
  77. "quantity": 1,
  78. }
  79. )
  80. def do_request(self, query, user=None, org=None):
  81. self.login_as(user=user or self.user)
  82. url = reverse(
  83. "sentry-api-0-organization-stats-v2",
  84. kwargs={"organization_slug": (org or self.organization).slug},
  85. )
  86. return self.client.get(url, query, format="json")
  87. def test_empty_request(self):
  88. response = self.do_request({})
  89. assert response.status_code == 400, response.content
  90. assert result_sorted(response.data) == {"detail": 'At least one "field" is required.'}
  91. def test_inaccessible_project(self):
  92. response = self.do_request({"project": [self.project3.id]})
  93. assert response.status_code == 403, response.content
  94. assert result_sorted(response.data) == {
  95. "detail": "You do not have permission to perform this action."
  96. }
  97. def test_no_projects_available(self):
  98. response = self.do_request(
  99. {
  100. "groupBy": ["project"],
  101. "statsPeriod": "1d",
  102. "interval": "1d",
  103. "field": ["sum(quantity)"],
  104. "category": ["error", "transaction"],
  105. },
  106. user=self.user2,
  107. org=self.org3,
  108. )
  109. assert response.status_code == 400, response.content
  110. assert result_sorted(response.data) == {
  111. "detail": "No projects available",
  112. }
  113. def test_unknown_field(self):
  114. response = self.do_request(
  115. {
  116. "field": ["summ(qarntenty)"],
  117. "statsPeriod": "1d",
  118. "interval": "1d",
  119. }
  120. )
  121. assert response.status_code == 400, response.content
  122. assert result_sorted(response.data) == {
  123. "detail": 'Invalid field: "summ(qarntenty)"',
  124. }
  125. def test_no_end_param(self):
  126. response = self.do_request(
  127. {"field": ["sum(quantity)"], "interval": "1d", "start": "2021-03-14T00:00:00Z"}
  128. )
  129. assert response.status_code == 400, response.content
  130. assert result_sorted(response.data) == {"detail": "start and end are both required"}
  131. @freeze_time(datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc))
  132. def test_future_request(self):
  133. response = self.do_request(
  134. {
  135. "field": ["sum(quantity)"],
  136. "interval": "1h",
  137. "category": ["error"],
  138. "start": "2021-03-14T15:30:00",
  139. "end": "2021-03-14T16:30:00",
  140. }
  141. )
  142. assert response.status_code == 200, response.content
  143. assert result_sorted(response.data) == {
  144. "intervals": ["2021-03-14T12:00:00Z"],
  145. "groups": [
  146. {"by": {}, "series": {"sum(quantity)": [0]}, "totals": {"sum(quantity)": 0}}
  147. ],
  148. "start": "2021-03-14T12:00:00Z",
  149. "end": "2021-03-14T12:28:00Z",
  150. }
  151. def test_unknown_category(self):
  152. response = self.do_request(
  153. {
  154. "field": ["sum(quantity)"],
  155. "statsPeriod": "1d",
  156. "interval": "1d",
  157. "category": "scoobydoo",
  158. }
  159. )
  160. assert response.status_code == 400, response.content
  161. assert result_sorted(response.data) == {
  162. "detail": 'Invalid category: "scoobydoo"',
  163. }
  164. def test_unknown_outcome(self):
  165. response = self.do_request(
  166. {
  167. "field": ["sum(quantity)"],
  168. "statsPeriod": "1d",
  169. "interval": "1d",
  170. "category": "error",
  171. "outcome": "scoobydoo",
  172. }
  173. )
  174. assert response.status_code == 400, response.content
  175. assert result_sorted(response.data) == {
  176. "detail": 'Invalid outcome: "scoobydoo"',
  177. }
  178. def test_unknown_groupby(self):
  179. response = self.do_request(
  180. {
  181. "field": ["sum(quantity)"],
  182. "groupBy": ["category_"],
  183. "statsPeriod": "1d",
  184. "interval": "1d",
  185. }
  186. )
  187. assert response.status_code == 400, response.content
  188. assert result_sorted(response.data) == {"detail": 'Invalid groupBy: "category_"'}
  189. def test_resolution_invalid(self):
  190. self.login_as(user=self.user)
  191. make_request = functools.partial(
  192. self.client.get,
  193. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  194. )
  195. response = make_request(
  196. {
  197. "statsPeriod": "1d",
  198. "interval": "bad_interval",
  199. }
  200. )
  201. assert response.status_code == 400, response.content
  202. @freeze_time("2021-03-14T12:27:28.303Z")
  203. def test_attachment_filter_only(self):
  204. response = self.do_request(
  205. {
  206. "project": [-1],
  207. "statsPeriod": "1d",
  208. "interval": "1d",
  209. "field": ["sum(quantity)"],
  210. "category": ["error", "attachment"],
  211. }
  212. )
  213. assert response.status_code == 400, response.content
  214. assert result_sorted(response.data) == {
  215. "detail": "if filtering by attachment no other category may be present"
  216. }
  217. @freeze_time("2021-03-14T12:27:28.303Z")
  218. def test_timeseries_interval(self):
  219. response = self.do_request(
  220. {
  221. "project": [-1],
  222. "category": ["error"],
  223. "statsPeriod": "1d",
  224. "interval": "1d",
  225. "field": ["sum(quantity)"],
  226. }
  227. )
  228. assert response.status_code == 200, response.content
  229. assert result_sorted(response.data) == {
  230. "intervals": ["2021-03-14T00:00:00Z"],
  231. "groups": [
  232. {"by": {}, "series": {"sum(quantity)": [6]}, "totals": {"sum(quantity)": 6}}
  233. ],
  234. "start": "2021-03-14T00:00:00Z",
  235. "end": "2021-03-14T12:28:00Z",
  236. }
  237. response = self.do_request(
  238. {
  239. "project": [-1],
  240. "statsPeriod": "1d",
  241. "interval": "6h",
  242. "field": ["sum(quantity)"],
  243. "category": ["error"],
  244. }
  245. )
  246. assert response.status_code == 200, response.content
  247. assert result_sorted(response.data) == {
  248. "intervals": [
  249. "2021-03-13T18:00:00Z",
  250. "2021-03-14T00:00:00Z",
  251. "2021-03-14T06:00:00Z",
  252. "2021-03-14T12:00:00Z",
  253. ],
  254. "groups": [
  255. {
  256. "by": {},
  257. "series": {"sum(quantity)": [0, 0, 6, 0]},
  258. "totals": {"sum(quantity)": 6},
  259. }
  260. ],
  261. "start": "2021-03-13T18:00:00Z",
  262. "end": "2021-03-14T12:28:00Z",
  263. }
  264. @freeze_time("2021-03-14T12:27:28.303Z")
  265. def test_user_org_total_all_accessible(self):
  266. response = self.do_request(
  267. {
  268. "project": [-1],
  269. "statsPeriod": "1d",
  270. "interval": "1d",
  271. "field": ["sum(quantity)"],
  272. "category": ["error", "transaction"],
  273. },
  274. user=self.user2,
  275. )
  276. assert response.status_code == 200, response.content
  277. assert result_sorted(response.data) == {
  278. "start": "2021-03-14T00:00:00Z",
  279. "end": "2021-03-14T12:28:00Z",
  280. "intervals": ["2021-03-14T00:00:00Z"],
  281. "groups": [
  282. {"by": {}, "series": {"sum(quantity)": [7]}, "totals": {"sum(quantity)": 7}}
  283. ],
  284. }
  285. @freeze_time("2021-03-14T12:27:28.303Z")
  286. def test_user_no_proj_specific_access(self):
  287. response = self.do_request(
  288. {
  289. "project": self.project.id,
  290. "statsPeriod": "1d",
  291. "interval": "1d",
  292. "field": ["sum(quantity)"],
  293. "category": ["error", "transaction"],
  294. },
  295. user=self.user2,
  296. )
  297. assert response.status_code == 403
  298. response = self.do_request(
  299. {
  300. "project": [-1],
  301. "statsPeriod": "1d",
  302. "interval": "1d",
  303. "field": ["sum(quantity)"],
  304. "category": ["error", "transaction"],
  305. "groupBy": ["project"],
  306. },
  307. user=self.user2,
  308. )
  309. assert response.status_code == 200
  310. assert result_sorted(response.data) == {
  311. "start": "2021-03-14T00:00:00Z",
  312. "end": "2021-03-14T12:28:00Z",
  313. "groups": [],
  314. }
  315. @freeze_time("2021-03-14T12:27:28.303Z")
  316. def test_no_project_access(self):
  317. user = self.create_user(is_superuser=False)
  318. self.create_member(user=user, organization=self.organization, role="member", teams=[])
  319. response = self.do_request(
  320. {
  321. "project": [self.project.id],
  322. "statsPeriod": "1d",
  323. "interval": "1d",
  324. "category": ["error", "transaction"],
  325. "field": ["sum(quantity)"],
  326. },
  327. org=self.organization,
  328. user=user,
  329. )
  330. assert response.status_code == 403, response.content
  331. assert result_sorted(response.data) == {
  332. "detail": "You do not have permission to perform this action."
  333. }
  334. response = self.do_request(
  335. {
  336. "project": [self.project.id],
  337. "groupBy": ["project"],
  338. "statsPeriod": "1d",
  339. "interval": "1d",
  340. "category": ["error", "transaction"],
  341. "field": ["sum(quantity)"],
  342. },
  343. org=self.organization,
  344. user=user,
  345. )
  346. assert response.status_code == 403, response.content
  347. assert result_sorted(response.data) == {
  348. "detail": "You do not have permission to perform this action."
  349. }
  350. @freeze_time("2021-03-14T12:27:28.303Z")
  351. def test_open_membership_semantics(self):
  352. self.org.flags.allow_joinleave = True
  353. self.org.save()
  354. response = self.do_request(
  355. {
  356. "project": [-1],
  357. "statsPeriod": "1d",
  358. "interval": "1d",
  359. "field": ["sum(quantity)"],
  360. "category": ["error", "transaction"],
  361. "groupBy": ["project"],
  362. },
  363. user=self.user2,
  364. )
  365. assert response.status_code == 200
  366. assert result_sorted(response.data) == {
  367. "start": "2021-03-14T00:00:00Z",
  368. "end": "2021-03-14T12:28:00Z",
  369. "groups": [
  370. {
  371. "by": {"project": self.project.id},
  372. "totals": {"sum(quantity)": 6},
  373. },
  374. {
  375. "by": {"project": self.project2.id},
  376. "totals": {"sum(quantity)": 1},
  377. },
  378. ],
  379. }
  380. @freeze_time("2021-03-14T12:27:28.303Z")
  381. def test_org_simple(self):
  382. make_request = functools.partial(
  383. self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
  384. )
  385. response = make_request(
  386. {
  387. "statsPeriod": "2d",
  388. "interval": "1d",
  389. "field": ["sum(quantity)"],
  390. "groupBy": ["category", "outcome", "reason"],
  391. }
  392. )
  393. assert response.status_code == 200, response.content
  394. assert result_sorted(response.data) == {
  395. "start": "2021-03-13T00:00:00Z",
  396. "end": "2021-03-14T12:28:00Z",
  397. "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
  398. "groups": [
  399. {
  400. "by": {
  401. "outcome": "rate_limited",
  402. "reason": "spike_protection",
  403. "category": "attachment",
  404. },
  405. "totals": {"sum(quantity)": 1024},
  406. "series": {"sum(quantity)": [0, 1024]},
  407. },
  408. {
  409. "by": {"outcome": "accepted", "reason": "none", "category": "error"},
  410. "totals": {"sum(quantity)": 6},
  411. "series": {"sum(quantity)": [0, 6]},
  412. },
  413. {
  414. "by": {
  415. "category": "transaction",
  416. "reason": "spike_protection",
  417. "outcome": "rate_limited",
  418. },
  419. "totals": {"sum(quantity)": 1},
  420. "series": {"sum(quantity)": [0, 1]},
  421. },
  422. ],
  423. }
  424. @freeze_time("2021-03-14T12:27:28.303Z")
  425. def test_org_multiple_fields(self):
  426. make_request = functools.partial(
  427. self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
  428. )
  429. response = make_request(
  430. {
  431. "statsPeriod": "2d",
  432. "interval": "1d",
  433. "field": ["sum(quantity)", "sum(times_seen)"],
  434. "groupBy": ["category", "outcome", "reason"],
  435. }
  436. )
  437. assert response.status_code == 200, response.content
  438. assert result_sorted(response.data) == {
  439. "start": "2021-03-13T00:00:00Z",
  440. "end": "2021-03-14T12:28:00Z",
  441. "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
  442. "groups": [
  443. {
  444. "by": {
  445. "outcome": "rate_limited",
  446. "category": "attachment",
  447. "reason": "spike_protection",
  448. },
  449. "totals": {"sum(quantity)": 1024, "sum(times_seen)": 1},
  450. "series": {"sum(quantity)": [0, 1024], "sum(times_seen)": [0, 1]},
  451. },
  452. {
  453. "by": {"outcome": "accepted", "reason": "none", "category": "error"},
  454. "totals": {"sum(quantity)": 6, "sum(times_seen)": 6},
  455. "series": {"sum(quantity)": [0, 6], "sum(times_seen)": [0, 6]},
  456. },
  457. {
  458. "by": {
  459. "category": "transaction",
  460. "reason": "spike_protection",
  461. "outcome": "rate_limited",
  462. },
  463. "totals": {"sum(quantity)": 1, "sum(times_seen)": 1},
  464. "series": {"sum(quantity)": [0, 1], "sum(times_seen)": [0, 1]},
  465. },
  466. ],
  467. }
  468. @freeze_time("2021-03-14T12:27:28.303Z")
  469. def test_org_group_by_project(self):
  470. make_request = functools.partial(
  471. self.client.get,
  472. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  473. )
  474. response = make_request(
  475. {
  476. "statsPeriod": "1d",
  477. "interval": "1d",
  478. "field": ["sum(times_seen)"],
  479. "groupBy": ["project"],
  480. "category": ["error", "transaction"],
  481. }
  482. )
  483. assert response.status_code == 200, response.content
  484. assert result_sorted(response.data) == {
  485. "start": "2021-03-14T00:00:00Z",
  486. "end": "2021-03-14T12:28:00Z",
  487. "groups": [
  488. {
  489. "by": {"project": self.project.id},
  490. "totals": {"sum(times_seen)": 6},
  491. },
  492. {
  493. "by": {"project": self.project2.id},
  494. "totals": {"sum(times_seen)": 1},
  495. },
  496. ],
  497. }
  498. @freeze_time("2021-03-14T12:27:28.303Z")
  499. def test_org_project_totals_per_project(self):
  500. make_request = functools.partial(
  501. self.client.get,
  502. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  503. )
  504. response_per_group = make_request(
  505. {
  506. "statsPeriod": "1d",
  507. "interval": "1h",
  508. "field": ["sum(times_seen)"],
  509. "groupBy": ["project"],
  510. "category": ["error", "transaction"],
  511. }
  512. )
  513. response_total = make_request(
  514. {
  515. "statsPeriod": "1d",
  516. "interval": "1h",
  517. "field": ["sum(times_seen)"],
  518. "category": ["error", "transaction"],
  519. }
  520. )
  521. per_group_total = 0
  522. for total in response_per_group.data["groups"]:
  523. per_group_total += total["totals"]["sum(times_seen)"]
  524. assert response_per_group.status_code == 200, response_per_group.content
  525. assert response_total.status_code == 200, response_total.content
  526. assert response_total.data["groups"][0]["totals"]["sum(times_seen)"] == per_group_total
  527. @freeze_time("2021-03-14T12:27:28.303Z")
  528. def test_project_filter(self):
  529. make_request = functools.partial(
  530. self.client.get,
  531. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  532. )
  533. response = make_request(
  534. {
  535. "project": self.project.id,
  536. "statsPeriod": "1d",
  537. "interval": "1d",
  538. "field": ["sum(quantity)"],
  539. "category": ["error", "transaction"],
  540. }
  541. )
  542. assert response.status_code == 200, response.content
  543. assert result_sorted(response.data) == {
  544. "start": "2021-03-14T00:00:00Z",
  545. "end": "2021-03-14T12:28:00Z",
  546. "intervals": ["2021-03-14T00:00:00Z"],
  547. "groups": [
  548. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  549. ],
  550. }
  551. @freeze_time("2021-03-14T12:27:28.303Z")
  552. def test_reason_filter(self):
  553. make_request = functools.partial(
  554. self.client.get,
  555. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  556. )
  557. response = make_request(
  558. {
  559. "statsPeriod": "1d",
  560. "interval": "1d",
  561. "field": ["sum(times_seen)"],
  562. "reason": ["spike_protection"],
  563. "groupBy": ["category"],
  564. }
  565. )
  566. assert response.status_code == 200, response.content
  567. assert result_sorted(response.data) == {
  568. "start": "2021-03-14T00:00:00Z",
  569. "end": "2021-03-14T12:28:00Z",
  570. "intervals": ["2021-03-14T00:00:00Z"],
  571. "groups": [
  572. {
  573. "by": {"category": "attachment"},
  574. "totals": {"sum(times_seen)": 1},
  575. "series": {"sum(times_seen)": [1]},
  576. },
  577. {
  578. "by": {"category": "transaction"},
  579. "totals": {"sum(times_seen)": 1},
  580. "series": {"sum(times_seen)": [1]},
  581. },
  582. ],
  583. }
  584. @freeze_time("2021-03-14T12:27:28.303Z")
  585. def test_outcome_filter(self):
  586. make_request = functools.partial(
  587. self.client.get,
  588. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  589. )
  590. response = make_request(
  591. {
  592. "statsPeriod": "1d",
  593. "interval": "1d",
  594. "field": ["sum(quantity)"],
  595. "outcome": "accepted",
  596. "category": ["error", "transaction"],
  597. }
  598. )
  599. assert response.status_code == 200, response.content
  600. assert result_sorted(response.data) == {
  601. "start": "2021-03-14T00:00:00Z",
  602. "end": "2021-03-14T12:28:00Z",
  603. "intervals": ["2021-03-14T00:00:00Z"],
  604. "groups": [
  605. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  606. ],
  607. }
  608. @freeze_time("2021-03-14T12:27:28.303Z")
  609. def test_category_filter(self):
  610. make_request = functools.partial(
  611. self.client.get,
  612. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  613. )
  614. response = make_request(
  615. {
  616. "statsPeriod": "1d",
  617. "interval": "1d",
  618. "field": ["sum(quantity)"],
  619. "category": "error",
  620. }
  621. )
  622. assert response.status_code == 200, response.content
  623. assert result_sorted(response.data) == {
  624. "start": "2021-03-14T00:00:00Z",
  625. "end": "2021-03-14T12:28:00Z",
  626. "intervals": ["2021-03-14T00:00:00Z"],
  627. "groups": [
  628. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  629. ],
  630. }
  631. @freeze_time("2021-03-14T12:27:28.303Z")
  632. def test_minute_interval(self):
  633. make_request = functools.partial(
  634. self.client.get,
  635. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  636. )
  637. response = make_request(
  638. {
  639. "statsPeriod": "1h",
  640. "interval": "15m",
  641. "field": ["sum(quantity)"],
  642. "category": "error",
  643. }
  644. )
  645. assert response.status_code == 200, response.content
  646. assert result_sorted(response.data) == {
  647. "start": "2021-03-14T11:00:00Z",
  648. "end": "2021-03-14T12:28:00Z",
  649. "intervals": [
  650. "2021-03-14T11:00:00Z",
  651. "2021-03-14T11:15:00Z",
  652. "2021-03-14T11:30:00Z",
  653. "2021-03-14T11:45:00Z",
  654. "2021-03-14T12:00:00Z",
  655. "2021-03-14T12:15:00Z",
  656. ],
  657. "groups": [
  658. {
  659. "by": {},
  660. "totals": {"sum(quantity)": 6},
  661. "series": {"sum(quantity)": [0, 6, 0, 0, 0, 0]},
  662. }
  663. ],
  664. }
  665. def result_sorted(result):
  666. """sort the groups of the results array by the `by` object, ensuring a stable order"""
  667. def stable_dict(d):
  668. return tuple(sorted(d.items(), key=lambda t: t[0]))
  669. if "groups" in result:
  670. result["groups"].sort(key=lambda group: stable_dict(group["by"]))
  671. return result
  672. # TEST invalid parameter