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 sentry.constants import DataCategory
  5. from sentry.testutils.cases import APITestCase, OutcomesSnubaTest
  6. from sentry.testutils.helpers.datetime import freeze_time
  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