Private/Tests/Invoke-ConfluenceHttpRequest.Tests.ps1

BeforeAll {
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'
    $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.confluence.psd1') -Force
    Set-ConfluenceContext -ConfluenceUrl 'https://contoso.atlassian.net' -Username 'user@contoso.com' -PersonalAccessToken 'token'
}

AfterAll {
    Remove-Module -Name tcs.confluence -Force -ErrorAction SilentlyContinue
}

Describe 'Invoke-ConfluenceHttpRequest (private)' {
    It 'Returns status, description and content of a response' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest { [pscustomobject]@{ StatusCode = 201; StatusDescription = 'Created'; Content = '{"id":"1"}' } }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/wiki/api/v2/pages' -Method POST -Body '{}' }
        $result.StatusCode | Should -Be 201
        $result.StatusDescription | Should -Be 'Created'
        $result.Content | Should -Be '{"id":"1"}'
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $UseBasicParsing -and $Headers.Accept -eq 'application/json' }
    }

    It 'Skips the HTTP error check on PowerShell 7' -Skip:($PSVersionTable.PSVersion.Major -lt 7) {
        Mock -ModuleName tcs.confluence Invoke-WebRequest { [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '' } }
        $null = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $SkipHttpErrorCheck }
    }

    It 'Turns an HTTP error thrown by Windows PowerShell 5.1 into a response' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            $exception = New-Object -TypeName System.Exception -ArgumentList 'The remote server returned an error: (404) Not Found.'
            Add-Member -InputObject $exception -MemberType NoteProperty -Name Response -Value ([pscustomobject]@{ StatusCode = 404; StatusDescription = 'Not Found' })
            $record = New-Object -TypeName System.Management.Automation.ErrorRecord -ArgumentList $exception, 'WebCmdletWebResponseException', 'InvalidOperation', $null
            $record.ErrorDetails = New-Object -TypeName System.Management.Automation.ErrorDetails -ArgumentList '{"errors":[{"title":"missing"}]}'
            throw $record
        }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        $result.StatusCode | Should -Be 404
        $result.StatusDescription | Should -Be 'Not Found'
        $result.Content | Should -Be '{"errors":[{"title":"missing"}]}'
    }

    It 'Rethrows transport failures' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest { throw 'No such host is known.' }
        { InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET } } | Should -Throw -ExpectedMessage '*No such host*'
    }

    It 'Throws when no context is set' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest { }
        InModuleScope tcs.confluence {
            $saved = $script:ConfluenceCredential
            try {
                $script:ConfluenceCredential = $null
                { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET } |
                    Should -Throw -ExpectedMessage 'No Confluence context is set. Run Set-ConfluenceContext first.'
            }
            finally {
                $script:ConfluenceCredential = $saved
            }
        }
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 0 -Exactly
    }

    It 'Builds the Authorization header per request and merges additional headers' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest { [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '' } }
        $null = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method PUT -AdditionalHeaders @{ 'X-Atlassian-Token' = 'no-check' } }
        $expected = 'Basic ' + [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('user@contoso.com:token'))
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
            $Headers.Authorization -eq $expected -and $Headers.Accept -eq 'application/json' -and $Headers['X-Atlassian-Token'] -eq 'no-check'
        }
        InModuleScope tcs.confluence { @(Get-Variable -Scope Script | Where-Object { "$($_.Value)" -like 'Basic *' }) } | Should -BeNullOrEmpty
    }
}

Describe 'Invoke-ConfluenceHttpRequest retries (private)' {
    BeforeEach {
        # Invoke-WithRetry sleeps in the tcs.core module scope
        Mock -ModuleName tcs.core Start-Sleep { }
    }

    It 'Retries a 429 after the Retry-After delay in seconds' {
        $script:calls = 0
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            $script:calls++
            if ($script:calls -eq 1) {
                return [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = ''; Headers = @{ 'Retry-After' = @('7') } }
            }
            [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"id":"1"}' }
        }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        $result.StatusCode | Should -Be 200
        $result.Content | Should -Be '{"id":"1"}'
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 2 -Exactly
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 7000 }
    }

    It 'Uses Retry-After even when it is shorter than the backoff' {
        $script:calls = 0
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            $script:calls++
            if ($script:calls -le 3) {
                return [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = ''; Headers = @{ 'Retry-After' = @('1') } }
            }
            [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '' }
        }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        $result.StatusCode | Should -Be 200
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 4 -Exactly
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 3 -Exactly -ParameterFilter { $Milliseconds -eq 1000 }
    }

    It 'Waits until a Retry-After HTTP date' {
        $script:calls = 0
        $script:retryAt = [datetime]::UtcNow.AddSeconds(30).ToString('r', [System.Globalization.CultureInfo]::InvariantCulture)
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            $script:calls++
            if ($script:calls -eq 1) {
                return [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = ''; Headers = @{ 'Retry-After' = @($script:retryAt) } }
            }
            [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '' }
        }
        $null = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -ge 28000 -and $Milliseconds -le 31000 }
    }

    It 'Caps the Retry-After delay at 60 seconds' {
        $script:calls = 0
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            $script:calls++
            if ($script:calls -eq 1) {
                return [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = ''; Headers = @{ 'Retry-After' = @('3600') } }
            }
            [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '' }
        }
        $null = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 60000 }
    }

    It 'Gives up after four retries with 2, 4, 8 and 16 second waits and returns the 429' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = '{"message":"slow down"}' }
        }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        $result.StatusCode | Should -Be 429
        $result.StatusDescription | Should -Be 'Too Many Requests'
        $result.Content | Should -Be '{"message":"slow down"}'
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 5 -Exactly
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 4 -Exactly
        foreach ($expectedDelay in 2000, 4000, 8000, 16000) {
            Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq $expectedDelay }
        }
    }

    It 'Retries a 429 thrown by Windows PowerShell 5.1' {
        $script:calls = 0
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            $script:calls++
            if ($script:calls -eq 1) {
                $exception = New-Object -TypeName System.Exception -ArgumentList 'The remote server returned an error: (429) Too Many Requests.'
                $webResponse = [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Headers = @{ 'Retry-After' = '5' } }
                Add-Member -InputObject $exception -MemberType NoteProperty -Name Response -Value $webResponse
                throw (New-Object -TypeName System.Management.Automation.ErrorRecord -ArgumentList $exception, 'WebCmdletWebResponseException', 'InvalidOperation', $null)
            }
            [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '' }
        }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        $result.StatusCode | Should -Be 200
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 5000 }
    }

    It 'Returns a 429 at once with -MaxRetry 0' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest { [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = '' } }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET -MaxRetry 0 }
        $result.StatusCode | Should -Be 429
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 0 -Exactly
    }

    It 'Does not retry a <StatusCode> response' -ForEach @(
        @{ StatusCode = 500; StatusDescription = 'Internal Server Error' }
        @{ StatusCode = 503; StatusDescription = 'Service Unavailable' }
        @{ StatusCode = 404; StatusDescription = 'Not Found' }
    ) {
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            [pscustomobject]@{ StatusCode = $StatusCode; StatusDescription = $StatusDescription; Content = ''; Headers = @{ 'Retry-After' = @('1') } }
        }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        $result.StatusCode | Should -Be $StatusCode
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 0 -Exactly
    }

    It 'Does not retry a 500 thrown by Windows PowerShell 5.1' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            $exception = New-Object -TypeName System.Exception -ArgumentList 'The remote server returned an error: (500) Internal Server Error.'
            Add-Member -InputObject $exception -MemberType NoteProperty -Name Response -Value ([pscustomobject]@{ StatusCode = 500; StatusDescription = 'Internal Server Error' })
            throw (New-Object -TypeName System.Management.Automation.ErrorRecord -ArgumentList $exception, 'WebCmdletWebResponseException', 'InvalidOperation', $null)
        }
        $result = InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET }
        $result.StatusCode | Should -Be 500
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 0 -Exactly
    }

    It 'Does not retry a transport failure' {
        Mock -ModuleName tcs.confluence Invoke-WebRequest { throw (New-Object -TypeName System.Net.WebException -ArgumentList 'No such host is known.', ([System.Net.WebExceptionStatus]::NameResolutionFailure)) }
        { InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET } } |
            Should -Throw -ExpectedMessage 'No such host is known.'
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 0 -Exactly
    }

    It 'Does not retry a transport failure after a 429' {
        $script:calls = 0
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            $script:calls++
            if ($script:calls -eq 1) {
                return [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = '' }
            }
            throw 'The operation has timed out.'
        }
        { InModuleScope tcs.confluence { Invoke-ConfluenceHttpRequest -Uri 'https://contoso.atlassian.net/x' -Method GET } } |
            Should -Throw -ExpectedMessage 'The operation has timed out.'
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 2 -Exactly
        Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 2000 }
    }
}

Describe 'Get-HtmlFormatTag (private)' {
    It 'Returns nested opening and reversed closing tags' {
        InModuleScope tcs.confluence { Get-HtmlFormatTag -Format Bold, Strikethrough } | Should -BeExactly '<strong><s>'
        InModuleScope tcs.confluence { Get-HtmlFormatTag -Format Bold, Strikethrough -Close } | Should -BeExactly '</s></strong>'
    }

    It 'Returns an empty string for no formats' {
        InModuleScope tcs.confluence { Get-HtmlFormatTag -Format $null } | Should -BeExactly ''
    }
}