functions/github/Invoke-GitHubRestMethod.Tests.ps1

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.'
. "$here\$sut"

Describe "Invoke-GitHubRestMethod" {

    $mockSplat = @{
        uri = "https://api.github.com/repos/does/notexist"
        verb = "GET"
        body = @{}
        token = "MOCK_TOKEN"
    }

    Context "When invoking with valid parameters" {
        Mock Invoke-RestMethod {
            return @{ StatusCode = 200 }
        }

        It "Should return a successful response" {
            $result = Invoke-GitHubRestMethod @mockSplat

            Assert-MockCalled Invoke-RestMethod -Exactly 1
            $result | Should -Not -BeNullOrEmpty
            $result.StatusCode | Should -Be 200
        }
    }

    Context "When requesting all pages of a paginated response" {
        Mock Invoke-RestMethod {
            # Simulate '-ResponseHeadersVariable' by setting the variable in the caller's scope
            $link = switch -Wildcard ($Uri) {
                "*page=2" { '<https://api.github.com/organizations/1/repos?page=3>; rel="next", <https://api.github.com/organizations/1/repos?page=3>; rel="last"' }
                "*page=3" { '<https://api.github.com/organizations/1/repos?page=1>; rel="first", <https://api.github.com/organizations/1/repos?page=2>; rel="prev"' }
                default   { '<https://api.github.com/organizations/1/repos?page=2>; rel="next", <https://api.github.com/organizations/1/repos?page=3>; rel="last"' }
            }
            Set-Variable -Name $ResponseHeadersVariable -Value @{ Link = @($link) } -Scope 3

            $page = $Uri -match 'page=(\d+)' ? $Matches[1] : 1
            return @(@{ name = "repo$page-a" }, @{ name = "repo$page-b" })
        }

        It "Should return the results from all pages" {
            $result = Invoke-GitHubRestMethod @mockSplat -AllPages

            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 3
            $result.Count | Should -Be 6
            $result[-1].name | Should -Be "repo3-b"
        }

        It "Should send the Authorization header when requesting every page" {
            Invoke-GitHubRestMethod @mockSplat -AllPages | Out-Null

            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 3 -ParameterFilter { $Headers.Authorization -eq "Token MOCK_TOKEN" }
            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1 -ParameterFilter { $Uri -like "*page=2" }
            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1 -ParameterFilter { $Uri -like "*page=3" }
        }

        It "Should not rely on Invoke-RestMethod to follow the pagination links" {
            Invoke-GitHubRestMethod @mockSplat -AllPages | Out-Null

            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 0 -ParameterFilter { $FollowRelLink }
        }

        It "Should only return the first page when not requesting all pages" {
            $result = Invoke-GitHubRestMethod @mockSplat

            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1
            $result.Count | Should -Be 2
        }
    }

    Context "When a pagination link points to a different origin" {
        Mock Invoke-RestMethod {
            if ($Uri -notlike "*page=2") {
                Set-Variable -Name $ResponseHeadersVariable -Value @{ Link = @("<$($script:nextPageLink)>; rel=`"next`"") } -Scope 3
            }
            return @(@{ name = "repo" })
        }

        $crossOriginTestCases = @(
            @{ change = "host"; link = "https://evil.example.com/organizations/1/repos?page=2" }
            # keep the port the same, so that only the scheme differs
            @{ change = "scheme"; link = "http://api.github.com:443/organizations/1/repos?page=2" }
            @{ change = "port"; link = "https://api.github.com:8443/organizations/1/repos?page=2" }
        )

        It "Should throw an exception when the <change> differs" -TestCases $crossOriginTestCases {
            param ($change, $link)
            $script:nextPageLink = $link

            { Invoke-GitHubRestMethod @mockSplat -AllPages } | Should -Throw "different origin"
        }

        It "Should not send a request to the other origin when the <change> differs" -TestCases $crossOriginTestCases {
            param ($change, $link)
            $script:nextPageLink = $link

            { Invoke-GitHubRestMethod @mockSplat -AllPages } | Should -Throw

            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1
            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 0 -ParameterFilter { $Uri -eq $link }
        }

        It "Should follow a link that explicitly specifies the default port" {
            $script:nextPageLink = "https://api.github.com:443/organizations/1/repos?page=2"

            $result = Invoke-GitHubRestMethod @mockSplat -AllPages

            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1 -ParameterFilter { $Uri -eq "https://api.github.com:443/organizations/1/repos?page=2" }
            $result.Count | Should -Be 2
        }
    }

    Context "When a later page remains rate limited after all retries" {
        Mock Invoke-RestMethod {
            if ($Uri -like "*page=2") {
                $statusCode = 429
                $response = New-Object System.Net.Http.HttpResponseMessage $statusCode
                $response.Headers.Add('Retry-After', '0')
                $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response
                $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation
                $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand'
                throw (New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $null)
            }
            Set-Variable -Name $ResponseHeadersVariable -Value @{ Link = @('<https://api.github.com/organizations/1/repos?page=2>; rel="next"') } -Scope 3
            return @(@{ name = "repo1-a" }, @{ name = "repo1-b" })
        }

        It "Should throw an exception" {
            { Invoke-GitHubRestMethod @mockSplat -AllPages -MaxRetries 2 } | Should -Throw "Rate limit still exceeded"

            Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 3 -ParameterFilter { $Uri -like "*page=2" }
        }

        It "Should not return the results from the earlier pages" {
            $script:received = @()
            try {
                Invoke-GitHubRestMethod @mockSplat -AllPages -MaxRetries 2 | ForEach-Object { $script:received += $_ }
            }
            catch {}

            $script:received.Count | Should -Be 0
        }
    }

    Context "When encountering an error status code" {
        Mock Invoke-RestMethod {
            $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 400}'
            $statusCode = 400
            $response = New-Object System.Net.Http.HttpResponseMessage $statusCode
            $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response
            $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation
            $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand'
            $targetObject = $null
            $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject
            $errorRecord.ErrorDetails = $errorDetails
            throw $errorRecord
        }

        It "Should throw an exception" {
            { Invoke-GitHubRestMethod @mockSplat } | Should -Throw
        }
    }

    Context "When encountering an ignored error status code" {
        Mock Invoke-RestMethod {
            $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 400}'
            $statusCode = 400
            $response = New-Object System.Net.Http.HttpResponseMessage $statusCode
            $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response
            $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation
            $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand'
            $targetObject = $null
            $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject
            $errorRecord.ErrorDetails = $errorDetails
            throw $errorRecord
        }

        It "Should not throw an exception" {
            { Invoke-GitHubRestMethod @mockSplat -HttpErrorStatusCodesToIgnore @(400) } | Should -Not -Throw
        }

        It "Should return null" {
            $result = Invoke-GitHubRestMethod @mockSplat -HttpErrorStatusCodesToIgnore @(400)
            $result | Should -BeNullOrEmpty
        }
    }

    Context "When rate limit is exceeded and given a 'Retry-After' response header" {
        $script:pesterHasRetried = $false
        Mock Invoke-RestMethod {
            try {
                if (!$pesterHasRetried) {
                    $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 429}'
                    $statusCode = 429
                    $response = New-Object System.Net.Http.HttpResponseMessage $statusCode
                    $response.Headers.Add('Retry-After', '1')
                    $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response
                    $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation
                    $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand'
                    $targetObject = $null
                    $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject
                    $errorRecord.ErrorDetails = $errorDetails
                    throw $errorRecord
                }
                else {
                    return @{ StatusCode = 200 }
                }
            }
            finally {
                $script:pesterHasRetried = $true
            } 
        }

        It "Should wait for the period specified in the Retry-After header" {

            # Act
            $result = Invoke-GitHubRestMethod @mockSplat

            # Assert
            $result | Should -Not -BeNullOrEmpty
            $result.StatusCode | Should -Be 200
            Assert-MockCalled Invoke-RestMethod -Exactly 2
        }
    }

    Context "When a rate limit quota is exhausted" {
        $script:pesterHasRetried = $false
        Mock Invoke-RestMethod {
            try {
                if (!$pesterHasRetried) {
                    $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 429}'
                    $statusCode = 429
                    $response = New-Object System.Net.Http.HttpResponseMessage $statusCode
                    $response.Headers.Add('X-RateLimit-Reset', [datetime]::UtcNow.AddSeconds(1).ToFileTimeUtc())
                    $response.Headers.Add('X-RateLimit-Remaining', "0")
                    $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response
                    $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation
                    $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand'
                    $targetObject = $null
                    $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject
                    $errorRecord.ErrorDetails = $errorDetails
                    throw $errorRecord
                }
                else {
                    return @{ StatusCode = 200 }
                }
            }
            finally {
                $script:pesterHasRetried = $true
            } 
        }

        It "Should wait until the time speciied in the 'X-RateLimit-Reset' response header" {

            # Act
            $result = Invoke-GitHubRestMethod @mockSplat

            # Assert
            $result | Should -Not -BeNullOrEmpty
            $result.StatusCode | Should -Be 200
            Assert-MockCalled Invoke-RestMethod -Exactly 2
        }
    }

    Context "When rate limit is exceeded with no retry context" {
        $script:pesterRetryCount = 0
        Mock Invoke-RestMethod {
            try {
                if ($pesterRetryCount -lt 3) {
                    $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 429}'
                    $statusCode = 429
                    $response = New-Object System.Net.Http.HttpResponseMessage $statusCode
                    $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response
                    $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation
                    $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand'
                    $targetObject = $null
                    $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject
                    $errorRecord.ErrorDetails = $errorDetails
                    throw $errorRecord
                }
                else {
                    return @{ StatusCode = 200 }
                }
            }
            finally {
                $script:pesterRetryCount++
            } 
        }

        It "Should implement an exponential back-off strategy" {

            # Act
            $startTime = Get-Date
            $result = Invoke-GitHubRestMethod @mockSplat -InitialBackOffSeconds 1

            # Assert
            $result | Should -Not -BeNullOrEmpty
            $result.StatusCode | Should -Be 200
            $stopTime = Get-Date
            $elapsedTime = $stopTime - $startTime
            $elapsedTime.TotalSeconds | Should -BeGreaterThan 4
        }
    }
}