test_organization_stats_v2.py 25 KB

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