Internal/Invoke-JiraMethod.Tests.ps1

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

InModuleScope PSJira {

    $ShowMockData = $false
    $ShowDebugText = $false

    $validMethods = @('Get','Post','Put','Delete')

    Describe "Invoke-JiraMethod" {

        ## Helper functions

        if ($ShowDebugText)
        {
            Mock "Write-Debug" {
                Write-Host " [DEBUG] $Message" -ForegroundColor Yellow
            }
        }

        function defParam($command, $name)
        {
            It "Has a -$name parameter" {
                $command.Parameters.Item($name) | Should Not BeNullOrEmpty
            }
        }

        function ShowMockInfo($functionName, [String[]] $params) {
            if ($ShowMockData)
            {
                Write-Host " Mocked $functionName" -ForegroundColor Cyan
                foreach ($p in $params) {
                    Write-Host " [$p] $(Get-Variable -Name $p -ValueOnly)" -ForegroundColor Cyan
                }
            }
        }

        Context "Sanity checking" {
            $command = Get-Command -Name Invoke-JiraMethod

            defParam $command 'Method'
            defParam $command 'URI'
            defParam $command 'Body'
            defParam $command 'Credential'

            It "Has a ValidateSet for the -Method parameter that accepts methods [$($validMethods -join ', ')]" {
                $validateSet = $command.Parameters.Method.Attributes | ? {$_.TypeID -eq [System.Management.Automation.ValidateSetAttribute]}
                $validateSet.ValidValues | Should Be $validMethods
            }
        }

        Context "Behavior testing" {

            $testUri = 'http://example.com'
            $testUsername = 'testUsername'
            $testPassword = 'password123'
            $testCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $testUsername,(ConvertTo-SecureString -AsPlainText -Force $testPassword)

            Mock Invoke-WebRequest {
                ShowMockInfo 'Invoke-WebRequest' -Params 'Uri','Method'
                # if ($ShowMockData)
                # {
                # Write-Host " Mocked Invoke-WebRequest" -ForegroundColor Cyan
                # Write-Host " [Uri] $Uri" -ForegroundColor Cyan
                # Write-Host " [Method] $Method" -ForegroundColor Cyan
                # }
            }

            It "Correctly performs all necessary HTTP method requests [$($validMethods -join ',')] to a provided URI" {
                foreach ($method in $validMethods)
                {
                    { Invoke-JiraMethod -Method $method -URI $testUri } | Should Not Throw
                    Assert-MockCalled -CommandName Invoke-WebRequest -ParameterFilter {$Method -eq $method -and $Uri -eq $testUri} -Scope It
                }
            }

            It "Sends the Content-Type header of application/json and UTF-8" {
                { Invoke-JiraMethod -Method Get -URI $testUri } | Should Not Throw
                Assert-MockCalled -CommandName Invoke-WebRequest -ParameterFilter {$Headers.Item('Content-Type') -eq 'application/json; charset=utf-8'} -Scope It
            }

            It "Uses the -UseBasicParsing switch for Invoke-WebRequest" {
                { Invoke-JiraMethod -Method Get -URI $testUri } | Should Not Throw
                Assert-MockCalled -CommandName Invoke-WebRequest -ParameterFilter {$UseBasicParsing -eq $true} -Scope It
            }

            It "Provides Base64 credentials in the Authorization header only when the -Credential parameter is supplied" {
                # This is the authorizion token that should be provided when using HTTP Basic authentication. It takes the form of
                # "username:password" encoded into a base 64 String.

                # This is why you shouldn't use PSJira on a plain HTTP connection.
                # See how easy it would be to decrypt your credentials?
                $token = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("${testUsername}:$testPassword"))

                { Invoke-JiraMethod -Method Get -URI $testUri -Credential $testCred } | Should Not Throw
                Assert-MockCalled -CommandName Invoke-WebRequest -ParameterFilter {$Headers.Item('Authorization') -eq "Basic $token"} -Exactly -Times 1 -Scope It

                # This one should call without the Authorization header, so check that the Authorization header mock has only been called once,
                # and that the Authorization-less header mock has also been called once.
                { Invoke-JiraMethod -Method Get -URI $testUri} | Should Not Throw
                Assert-MockCalled -CommandName Invoke-WebRequest -ParameterFilter {$Headers.Item('Authorization') -eq "Basic $token"} -Exactly -Times 1 -Scope It
                Assert-MockCalled -CommandName Invoke-WebRequest -ParameterFilter {-not $Headers.Item('Authorization')} -Exactly -Times 1 -Scope It
            }
        }

        $validTestUri = 'https://jira.atlassian.com/rest/api/latest/issue/303853'

        # This is a real REST result from Atlassian's public-facing JIRA instance, trimmed and cleaned
        # up just a bit for fields we don't care about.

        # You can obtain this data with a single PowerShell line:
        # Invoke-WebRequest -Method Get -Uri https://jira.atlassian.com/rest/api/latest/issue/303853
        $validRestResult = @'
{
  "expand": "renderedFields,names,schema,transitions,operations,editmeta,changelog,versionedRepresentations",
  "id": "303853",
  "self": "https://jira.atlassian.com/rest/api/latest/issue/303853",
  "key": "DEMO-2719",
  "fields": {
    "issuetype": {
      "self": "https://jira.atlassian.com/rest/api/2/issuetype/2",
      "id": "2",
      "description": "A new feature of the product, which has yet to be developed.",
      "iconUrl": "https://jira.atlassian.com/images/icons/issuetypes/newfeature.png",
      "name": "New Feature",
      "subtask": false
    },
    "timespent": null,
    "project": {
      "self": "https://jira.atlassian.com/rest/api/2/project/10820",
      "id": "10820",
      "key": "DEMO",
      "name": "Demo",
      "avatarUrls": {
        "48x48": "https://jira.atlassian.com/secure/projectavatar?avatarId=10011",
        "24x24": "https://jira.atlassian.com/secure/projectavatar?size=small&avatarId=10011",
        "16x16": "https://jira.atlassian.com/secure/projectavatar?size=xsmall&avatarId=10011",
        "32x32": "https://jira.atlassian.com/secure/projectavatar?size=medium&avatarId=10011"
      }
    },
    "fixVersions": [],
    "aggregatetimespent": null,
    "resolution": null,
    "resolutiondate": null,
    "workratio": -1,
    "lastViewed": null,
    "watches": {
      "self": "https://jira.atlassian.com/rest/api/2/issue/DEMO-2719/watchers",
      "watchCount": 1,
      "isWatching": false
    },
    "created": "2013-10-26T20:06:23.853+0000",
    "priority": {
      "self": "https://jira.atlassian.com/rest/api/2/priority/4",
      "iconUrl": "https://jira.atlassian.com/images/icons/priorities/minor.png",
      "name": "Minor",
      "id": "4"
    },
    "labels": [],
    "aggregatetimeoriginalestimate": null,
    "timeestimate": null,
    "versions": [],
    "issuelinks": [
      {
        "id": "115932",
        "self": "https://jira.atlassian.com/rest/api/2/issueLink/115932",
        "type": {
          "id": "10080",
          "name": "Detail",
          "inward": "is detailed by",
          "outward": "details",
          "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10080"
        },
        "outwardIssue": {
          "id": "303848",
          "key": "DEMO-2717",
          "self": "https://jira.atlassian.com/rest/api/2/issue/303848",
          "fields": {
            "summary": "New Feature Test Task",
            "status": {
              "self": "https://jira.atlassian.com/rest/api/2/status/1",
              "description": "Issue is open and has not yet been accepted by Atlassian.",
              "iconUrl": "https://jira.atlassian.com/images/icons/statuses/open.png",
              "name": "Open",
              "id": "1",
              "statusCategory": {
                "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2",
                "id": 2,
                "key": "new",
                "colorName": "blue-gray",
                "name": "To Do"
              }
            },
            "priority": {
              "self": "https://jira.atlassian.com/rest/api/2/priority/4",
              "iconUrl": "https://jira.atlassian.com/images/icons/priorities/minor.png",
              "name": "Minor",
              "id": "4"
            },
            "issuetype": {
              "self": "https://jira.atlassian.com/rest/api/2/issuetype/2",
              "id": "2",
              "description": "A new feature of the product, which has yet to be developed.",
              "iconUrl": "https://jira.atlassian.com/images/icons/issuetypes/newfeature.png",
              "name": "New Feature",
              "subtask": false
            }
          }
        }
      },
      {
        "id": "119483",
        "self": "https://jira.atlassian.com/rest/api/2/issueLink/119483",
        "type": {
          "id": "10000",
          "name": "Reference",
          "inward": "is related to",
          "outward": "relates to",
          "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000"
        },
        "outwardIssue": {
          "id": "304302",
          "key": "DEMO-2722",
          "self": "https://jira.atlassian.com/rest/api/2/issue/304302",
          "fields": {
            "summary": "My summary",
            "status": {
              "self": "https://jira.atlassian.com/rest/api/2/status/1",
              "description": "Issue is open and has not yet been accepted by Atlassian.",
              "iconUrl": "https://jira.atlassian.com/images/icons/statuses/open.png",
              "name": "Open",
              "id": "1",
              "statusCategory": {
                "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2",
                "id": 2,
                "key": "new",
                "colorName": "blue-gray",
                "name": "To Do"
              }
            },
            "priority": {
              "self": "https://jira.atlassian.com/rest/api/2/priority/4",
              "iconUrl": "https://jira.atlassian.com/images/icons/priorities/minor.png",
              "name": "Minor",
              "id": "4"
            },
            "issuetype": {
              "self": "https://jira.atlassian.com/rest/api/2/issuetype/1",
              "id": "1",
              "description": "A problem which impairs or prevents the functions of the product.",
              "iconUrl": "https://jira.atlassian.com/images/icons/issuetypes/bug.png",
              "name": "Bug",
              "subtask": false
            }
          }
        }
      },
      {
        "id": "115931",
        "self": "https://jira.atlassian.com/rest/api/2/issueLink/115931",
        "type": {
          "id": "10000",
          "name": "Reference",
          "inward": "is related to",
          "outward": "relates to",
          "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000"
        },
        "inwardIssue": {
          "id": "303852",
          "key": "DEMO-2718",
          "self": "https://jira.atlassian.com/rest/api/2/issue/303852",
          "fields": {
            "summary": "REST ye merry gentlemen.",
            "status": {
              "self": "https://jira.atlassian.com/rest/api/2/status/1",
              "description": "Issue is open and has not yet been accepted by Atlassian.",
              "iconUrl": "https://jira.atlassian.com/images/icons/statuses/open.png",
              "name": "Open",
              "id": "1",
              "statusCategory": {
                "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2",
                "id": 2,
                "key": "new",
                "colorName": "blue-gray",
                "name": "To Do"
              }
            },
            "priority": {
              "self": "https://jira.atlassian.com/rest/api/2/priority/4",
              "iconUrl": "https://jira.atlassian.com/images/icons/priorities/minor.png",
              "name": "Minor",
              "id": "4"
            },
            "issuetype": {
              "self": "https://jira.atlassian.com/rest/api/2/issuetype/2",
              "id": "2",
              "description": "A new feature of the product, which has yet to be developed.",
              "iconUrl": "https://jira.atlassian.com/images/icons/issuetypes/newfeature.png",
              "name": "New Feature",
              "subtask": false
            }
          }
        }
      }
    ],
    "assignee": {
      "self": "https://jira.atlassian.com/rest/api/2/user?username=ben%40atlassian.com",
      "name": "ben@atlassian.com",
      "key": "ben@atlassian.com",
      "emailAddress": "ben at atlassian dot com",
      "avatarUrls": {
        "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=ben%40atlassian.com&avatarId=72204",
        "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=ben%40atlassian.com&avatarId=72204",
        "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=ben%40atlassian.com&avatarId=72204",
        "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=ben%40atlassian.com&avatarId=72204"
      },
      "displayName": "Benjamin Naftzger [Atlassian]",
      "active": true,
      "timeZone": "Europe/Berlin"
    },
    "updated": "2013-12-08T11:00:43.133+0000",
    "status": {
      "self": "https://jira.atlassian.com/rest/api/2/status/1",
      "description": "Issue is open and has not yet been accepted by Atlassian.",
      "iconUrl": "https://jira.atlassian.com/images/icons/statuses/open.png",
      "name": "Open",
      "id": "1",
      "statusCategory": {
        "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2",
        "id": 2,
        "key": "new",
        "colorName": "blue-gray",
        "name": "To Do"
      }
    },
    "components": [],
    "timeoriginalestimate": null,
    "description": "Creating of an issue using project keys and issue type names using the REST API",
    "timetracking": {},
    "attachment": [],
    "aggregatetimeestimate": null,
    "summary": "REST ye merry gentlemen.",
    "creator": {
      "self": "https://jira.atlassian.com/rest/api/2/user?username=gokhant",
      "name": "gokhant",
      "key": "gokhant",
      "emailAddress": "gokhant at gmail dot com",
      "avatarUrls": {
        "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=gokhant&avatarId=73000",
        "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=gokhant&avatarId=73000",
        "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=gokhant&avatarId=73000",
        "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=gokhant&avatarId=73000"
      },
      "displayName": "Gokhan Tuna",
      "active": true,
      "timeZone": "Etc/UTC"
    },
    "subtasks": [],
    "reporter": {
      "self": "https://jira.atlassian.com/rest/api/2/user?username=gokhant",
      "name": "gokhant",
      "key": "gokhant",
      "emailAddress": "gokhant at gmail dot com",
      "avatarUrls": {
        "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=gokhant&avatarId=73000",
        "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=gokhant&avatarId=73000",
        "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=gokhant&avatarId=73000",
        "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=gokhant&avatarId=73000"
      },
      "displayName": "Gokhan Tuna",
      "active": true,
      "timeZone": "Etc/UTC"
    },
    "aggregateprogress": {
      "progress": 0,
      "total": 0
    },
    "environment": null,
    "duedate": null,
    "progress": {
      "progress": 0,
      "total": 0
    },
    "comment": {
      "startAt": 0,
      "maxResults": 1,
      "total": 1,
      "comments": [
        {
          "self": "https://jira.atlassian.com/rest/api/2/issue/303853/comment/534625",
          "id": "534625",
          "author": {
            "self": "https://jira.atlassian.com/rest/api/2/user?username=gokhant",
            "name": "gokhant",
            "key": "gokhant",
            "emailAddress": "gokhant at gmail dot com",
            "avatarUrls": {
              "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=gokhant&avatarId=73000",
              "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=gokhant&avatarId=73000",
              "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=gokhant&avatarId=73000",
              "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=gokhant&avatarId=73000"
            },
            "displayName": "Gokhan Tuna",
            "active": true,
            "timeZone": "Etc/UTC"
          },
          "body": "test comment",
          "updateAuthor": {
            "self": "https://jira.atlassian.com/rest/api/2/user?username=gokhant",
            "name": "gokhant",
            "key": "gokhant",
            "emailAddress": "gokhant at gmail dot com",
            "avatarUrls": {
              "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=gokhant&avatarId=73000",
              "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=gokhant&avatarId=73000",
              "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=gokhant&avatarId=73000",
              "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=gokhant&avatarId=73000"
            },
            "displayName": "Gokhan Tuna",
            "active": true,
            "timeZone": "Etc/UTC"
          },
          "created": "2013-11-05T02:50:09.991+0000",
          "updated": "2013-11-05T02:50:09.991+0000"
        }
      ]
    },
    "votes": {
      "self": "https://jira.atlassian.com/rest/api/2/issue/DEMO-2719/votes",
      "votes": 0,
      "hasVoted": false
    },
    "worklog": {
      "startAt": 0,
      "maxResults": 20,
      "total": 0,
      "worklogs": []
    }
  }
}
'@


        $validObjResult = ConvertFrom-Json2 -InputObject $validRestResult

        Context "Output handling - valid object returned (HTTP 200)" {

            It "Outputs an object representation of JSON returned from JIRA" {

                Mock Invoke-WebRequest -ParameterFilter {$Method -eq 'Get' -and $Uri -eq $validTestUri} {
                    ShowMockInfo 'Invoke-WebRequest' -Params 'Uri','Method'
                    Write-Output [PSCustomObject] @{
                        'Content' = $validRestResult
                    }
                }

                $result = Invoke-JiraMethod -Method Get -URI $validTestUri
                $result | Should Not BeNullOrEmpty

                # Compare each property in the result returned to the expected result
                foreach ($property in (Get-Member -InputObject $result | ? {$_.MemberType -eq 'NoteProperty'})) {
                    $result.$property | Should Be $validObjResult.$property
                }
            }
        }

        Context "Output handling - no content returned (HTTP 204)" {
            Mock Invoke-WebRequest {
                ShowMockInfo 'Invoke-WebRequest' -Params 'Uri','Method'

                Write-Output [PSCustomObject] @{
                    'StatusCode' = 204
                    'Content' = $null
                }
            }
            Mock ConvertFrom-Json2 {
                ShowMockInfo 'ConvertFrom-Json2'
            }

            It "Correctly handles HTTP response codes that do not provide a return body" {
                { Invoke-JiraMethod -Method Get -URI $validTestUri } | Should Not Throw
                Assert-MockCalled -CommandName ConvertFrom-Json2 -Exactly -Times 0 -Scope It
            }
        }

        Context "Output handling - JIRA error returned (HTTP 400 and up)" {
            $invalidTestUri = 'https://jira.atlassian.com/rest/api/latest/issue/1'
            $invalidRestResult = '{"errorMessages":["Issue Does Not Exist"],"errors":{}}';

            Mock Invoke-WebRequest {
                ShowMockInfo 'Invoke-WebRequest' -Params 'Uri','Method'
                Write-Output [PSCustomObject] @{
                    'StatusCode' = 400
                    'Content'    = $invalidRestResult
                }
            }

            Mock Resolve-JiraError {
                ShowMockInfo 'Resolve-JiraError' -params 'InputObject'
            }

            It "Uses Resolve-JiraError to parse any JIRA error messages returned" {
                { Invoke-JiraMethod -Method Get -URI $invalidTestUri } | Should Not Throw
                Assert-MockCalled -CommandName Resolve-JiraError -Exactly -Times 1 -Scope It
            }
        }
    }
}