test_organization_stats_v2.py 25 KB

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