1+ from typing import Any
12from unittest .mock import MagicMock , patch
23
3- from sentry .integrations .github .client import CachedRepo
44from sentry .testutils .cases import APITestCase
55
66
7- def _make_cached_repo (
7+ def _make_github_repo (
88 id : int ,
99 name : str ,
1010 full_name : str ,
1111 default_branch : str | None = "main" ,
1212 archived : bool = False ,
13- ) -> CachedRepo :
13+ ) -> dict [ str , Any ] :
1414 return {
1515 "id" : id ,
1616 "name" : name ,
@@ -289,11 +289,13 @@ def test_no_repository_method(self) -> None:
289289 assert response .status_code == 400
290290
291291
292- CACHED_REPOS = [_make_cached_repo (i , f"repo-{ i } " , f"Example/repo-{ i } " ) for i in range (1 , 6 )]
292+ ALL_REPOS = [_make_github_repo (i , f"repo-{ i } " , f"Example/repo-{ i } " ) for i in range (1 , 6 )]
293+ TOTAL_COUNT = len (ALL_REPOS )
294+ MOCK_TARGET = "sentry.integrations.github.client.GitHubBaseClient.get_repos_page"
293295
294296
295297class OrganizationIntegrationReposPaginatedTest (APITestCase ):
296- """Tests for cursor-based pagination triggered by sending per_page ."""
298+ """Tests for cursor-based pagination via GitHub API page params ."""
297299
298300 def setUp (self ) -> None :
299301 super ().setUp ()
@@ -307,14 +309,13 @@ def setUp(self) -> None:
307309 f"/api/0/organizations/{ self .org .slug } /integrations/{ self .integration .id } /repos/"
308310 )
309311
310- @patch (
311- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
312- )
313- def test_first_page (self , mock_cache : MagicMock ) -> None :
314- mock_cache .return_value = CACHED_REPOS
312+ @patch (MOCK_TARGET )
313+ def test_first_page (self , mock_page : MagicMock ) -> None :
314+ mock_page .return_value = (ALL_REPOS [:2 ], TOTAL_COUNT )
315315 response = self .client .get (self .path , data = {"per_page" : "2" }, format = "json" )
316316
317317 assert response .status_code == 200 , response .content
318+ mock_page .assert_called_once_with (page = 1 , per_page = 2 )
318319 repos = response .data ["repos" ]
319320 assert len (repos ) == 2
320321 assert repos [0 ]["identifier" ] == "Example/repo-1"
@@ -323,61 +324,61 @@ def test_first_page(self, mock_cache: MagicMock) -> None:
323324 assert 'rel="next"' in response ["Link" ]
324325 assert 'results="true"' in response ["Link" ].split ("next" )[1 ]
325326
326- @patch (
327- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
328- )
329- def test_second_page (self , mock_cache : MagicMock ) -> None :
330- mock_cache .return_value = CACHED_REPOS
327+ @patch (MOCK_TARGET )
328+ def test_second_page (self , mock_page : MagicMock ) -> None :
329+ mock_page .return_value = (ALL_REPOS [2 :4 ], TOTAL_COUNT )
331330 response = self .client .get (
332331 self .path , data = {"per_page" : "2" , "cursor" : "0:2:0" }, format = "json"
333332 )
334333
335334 assert response .status_code == 200 , response .content
335+ mock_page .assert_called_once_with (page = 2 , per_page = 2 )
336336 repos = response .data ["repos" ]
337337 assert len (repos ) == 2
338338 assert repos [0 ]["identifier" ] == "Example/repo-3"
339339 assert repos [1 ]["identifier" ] == "Example/repo-4"
340340
341- @patch (
342- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
343- )
344- def test_last_page (self , mock_cache : MagicMock ) -> None :
345- mock_cache .return_value = CACHED_REPOS
341+ @patch (MOCK_TARGET )
342+ def test_last_page (self , mock_page : MagicMock ) -> None :
343+ mock_page .return_value = (ALL_REPOS [4 :], TOTAL_COUNT )
346344 response = self .client .get (
347345 self .path , data = {"per_page" : "2" , "cursor" : "0:4:0" }, format = "json"
348346 )
349347
350348 assert response .status_code == 200 , response .content
349+ mock_page .assert_called_once_with (page = 3 , per_page = 2 )
351350 repos = response .data ["repos" ]
352351 assert len (repos ) == 1
353352 assert repos [0 ]["identifier" ] == "Example/repo-5"
354353 link = response ["Link" ]
355354 next_part = link .split ("next" )[1 ]
356355 assert 'results="false"' in next_part
357356
358- @patch (
359- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
360- )
361- def test_excludes_archived (self , mock_cache : MagicMock ) -> None :
362- mock_cache .return_value = [
363- _make_cached_repo (1 , "active" , "Example/active" ),
364- _make_cached_repo (2 , "archived" , "Example/archived" , archived = True ),
365- ]
357+ @patch (MOCK_TARGET )
358+ def test_excludes_archived (self , mock_page : MagicMock ) -> None :
359+ mock_page .return_value = (
360+ [
361+ _make_github_repo (1 , "active" , "Example/active" ),
362+ _make_github_repo (2 , "archived" , "Example/archived" , archived = True ),
363+ ],
364+ 2 ,
365+ )
366366 response = self .client .get (self .path , data = {"per_page" : "100" }, format = "json" )
367367
368368 assert response .status_code == 200 , response .content
369369 repos = response .data ["repos" ]
370370 assert len (repos ) == 1
371371 assert repos [0 ]["identifier" ] == "Example/active"
372372
373- @patch (
374- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
375- )
376- def test_installable_only (self , mock_cache : MagicMock ) -> None :
377- mock_cache .return_value = [
378- _make_cached_repo (1 , "installed-repo" , "Example/installed-repo" ),
379- _make_cached_repo (2 , "available-repo" , "Example/available-repo" ),
380- ]
373+ @patch (MOCK_TARGET )
374+ def test_installable_only (self , mock_page : MagicMock ) -> None :
375+ mock_page .return_value = (
376+ [
377+ _make_github_repo (1 , "installed-repo" , "Example/installed-repo" ),
378+ _make_github_repo (2 , "available-repo" , "Example/available-repo" ),
379+ ],
380+ 2 ,
381+ )
381382 self .create_repo (
382383 project = self .project ,
383384 integration_id = self .integration .id ,
@@ -393,12 +394,10 @@ def test_installable_only(self, mock_cache: MagicMock) -> None:
393394 assert repos [0 ]["identifier" ] == "Example/available-repo"
394395 assert repos [0 ]["isInstalled" ] is False
395396
396- @patch (
397- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
398- )
399- def test_no_cursor_on_single_page (self , mock_cache : MagicMock ) -> None :
397+ @patch (MOCK_TARGET )
398+ def test_no_cursor_on_single_page (self , mock_page : MagicMock ) -> None :
400399 """When all repos fit in one page, no Link header is added."""
401- mock_cache .return_value = [ _make_cached_repo (1 , "repo-1" , "Example/repo-1" )]
400+ mock_page .return_value = ([ _make_github_repo (1 , "repo-1" , "Example/repo-1" )], 1 )
402401 response = self .client .get (self .path , data = {"per_page" : "100" }, format = "json" )
403402
404403 assert response .status_code == 200 , response .content
@@ -434,67 +433,30 @@ def test_search_with_per_page_uses_full_list(self, get_repositories: MagicMock)
434433 get_repositories .assert_called_once_with ("repo" , accessible_only = False , use_cache = False )
435434 assert "Link" not in response
436435
437- @patch (
438- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
439- )
440- def test_per_page_zero_clamped_to_one (self , mock_cache : MagicMock ) -> None :
441- mock_cache .return_value = CACHED_REPOS
442- response = self .client .get (self .path , data = {"per_page" : "0" }, format = "json" )
436+ def test_per_page_invalid_returns_400 (self ) -> None :
437+ """Invalid per_page values (0, negative, non-numeric, >100) return 400."""
438+ for value in ("0" , "-1" , "abc" , "200" ):
439+ response = self .client .get (self .path , data = {"per_page" : value }, format = "json" )
440+ assert response .status_code == 400 , f"per_page={ value } should return 400"
443441
444- assert response .status_code == 200 , response .content
445- assert len (response .data ["repos" ]) == 1
446-
447- @patch (
448- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
449- )
450- def test_per_page_negative_clamped_to_one (self , mock_cache : MagicMock ) -> None :
451- mock_cache .return_value = CACHED_REPOS
452- response = self .client .get (self .path , data = {"per_page" : "-1" }, format = "json" )
453-
454- assert response .status_code == 200 , response .content
455- assert len (response .data ["repos" ]) == 1
456-
457- @patch (
458- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
459- )
460- def test_per_page_non_numeric_defaults_to_100 (self , mock_cache : MagicMock ) -> None :
461- mock_cache .return_value = CACHED_REPOS
462- response = self .client .get (self .path , data = {"per_page" : "abc" }, format = "json" )
463-
464- assert response .status_code == 200 , response .content
465- assert len (response .data ["repos" ]) == 5
466-
467- @patch (
468- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
469- )
470- def test_per_page_over_max_clamped_to_100 (self , mock_cache : MagicMock ) -> None :
471- mock_cache .return_value = CACHED_REPOS
472- response = self .client .get (self .path , data = {"per_page" : "200" }, format = "json" )
473-
474- assert response .status_code == 200 , response .content
475- assert len (response .data ["repos" ]) == 5
476-
477- @patch (
478- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
479- )
480- def test_negative_cursor_offset_clamped_to_zero (self , mock_cache : MagicMock ) -> None :
481- mock_cache .return_value = CACHED_REPOS
442+ @patch (MOCK_TARGET )
443+ def test_negative_cursor_offset_clamped_to_zero (self , mock_page : MagicMock ) -> None :
444+ mock_page .return_value = (ALL_REPOS [:2 ], TOTAL_COUNT )
482445 response = self .client .get (
483446 self .path , data = {"per_page" : "2" , "cursor" : "0:-5:0" }, format = "json"
484447 )
485448
486449 assert response .status_code == 200 , response .content
450+ mock_page .assert_called_once_with (page = 1 , per_page = 2 )
487451 repos = response .data ["repos" ]
488452 assert len (repos ) == 2
489453 assert repos [0 ]["identifier" ] == "Example/repo-1"
490454
491- @patch (
492- "sentry.integrations.github.client.GitHubBaseClient.get_repos_cached" ,
493- )
494- def test_integration_error_returns_400 (self , mock_cache : MagicMock ) -> None :
455+ @patch (MOCK_TARGET )
456+ def test_integration_error_returns_400 (self , mock_page : MagicMock ) -> None :
495457 from sentry .shared_integrations .exceptions import IntegrationError
496458
497- mock_cache .side_effect = IntegrationError ("token revoked" )
459+ mock_page .side_effect = IntegrationError ("token revoked" )
498460 response = self .client .get (self .path , data = {"per_page" : "2" }, format = "json" )
499461
500462 assert response .status_code == 400
0 commit comments