pwsh_modules/powershell-yaml/Tests/powershell-yaml.Tests.ps1

# Copyright 2016-2024 Cloudbase Solutions Srl
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#

# pinning this module to an exact version,
# because the options api will be merged with Assert-Equivalent
# before release of 1.0.0
Import-Module Assert -Version 0.9.6

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$moduleHome = Split-Path -Parent $here

$moduleName = "powershell-yaml"
$modulePath = Join-Path $moduleHome "powershell-yaml.psd1"
Import-Module $modulePath

InModuleScope $moduleName {
    $compareStrictly = Get-EquivalencyOption -Comparator Equality

    Describe "Test encode-decode symmetry." {

        Context "Simple-Items" {
            It "Should represent identity to encode and decode." -TestCases @(
                @{ Expected = 1 } 
                @{ Expected = "yes" } 
                @{ Expected = 56 } 
                @{ Expected = $null } 
            ) {
                param ($Expected)
                $actual = ConvertFrom-Yaml (ConvertTo-Yaml $Expected)

                Assert-Equivalent -Options $compareStrictly -Expected $Expected -Actual $actual 
            }
        }

        Context "Nulls and strings" {
            BeforeAll {
                $global:nullAndString = [ordered]@{"iAmNull"= $null; "iAmEmptyString"=""}
                $global:yaml = @"
iAmNull:
iAmEmptyString: ""

"@

            }

            It "should not serialize null value when -Options OmitNullValues is set" {
                $toYaml = ConvertTo-Yaml $nullAndString -Options OmitNullValues
                $toYaml | Should -Be "iAmEmptyString: """"$([Environment]::NewLine)"
            }

            It "should preserve nulls and empty strings from PowerShell" {
                $toYaml = ConvertTo-Yaml $nullAndString
                $backFromYaml = ConvertFrom-Yaml $toYaml

                ($null -eq $backFromYaml.iAmNull) | Should -Be $true
                $backFromYaml.iAmEmptyString | Should -Be ""
                $toYaml | Should -Be $yaml
            }

            It "should preserve nulls and empty strings from Yaml" {
                $fromYaml = ConvertFrom-Yaml -Ordered $yaml
                $backToYaml = ConvertTo-Yaml $fromYaml

                $backToYaml | Should -Be $yaml
                ($null -eq $fromYaml.iAmNull) | Should -Be $true
                $fromYaml.iAmEmptyString | Should -Be ""
            }
        }

        Context "Test array handling under various circumstances." {
            $arr = 1, 2, "yes", @{ key = "value" }, 5, (1, "no", 3)

            It "Should represent identity to encode/decode arrays as arguments." {
                $yaml = ConvertTo-Yaml $arr
                $a = ConvertFrom-Yaml $yaml

                Assert-Equivalent -Options $compareStrictly -Actual $a -Expected $arr
            }

            It "Should represent identity to encode/decode arrays by piping them in." {
                $yaml = $arr | ConvertTo-Yaml
                $a = ConvertFrom-Yaml $yaml
                
                Assert-Equivalent -Options $compareStrictly -Actual $a -Expected $arr
            }

            It "Should be irrelevant whether we convert an array by piping it, or referencing them as an argument." {
                $arged = ConvertTo-Yaml $arr
                $piped = $arr | ConvertTo-Yaml

                Assert-Equivalent -Options $compareStrictly -Actual $piped -Expected $arged
            }
        }

        Context "Test merging parser" {
            BeforeAll {
                $global:mergingYaml = @"
---
default: &default
  value1: 1
  value2: 2

hoge:
  <<: *default
  value3: 3
"@


                $global:mergingYamlOverwriteCase = @"
---
default: &default
  value1: 1
  value2: 2

hoge:
  <<: *default
  value1: 33
  value3: 3
"@

            }

            It "Should expand merging key with appropriate referenced keys" {
                $result = ConvertFrom-Yaml -Yaml $mergingYaml -UseMergingParser
                [array]$values = $result.hoge.keys
                [array]::sort($values)
                Assert-Equivalent -Options $compareStrictly -Actual $values -Expected @("value1", "value2", "value3")
            }

            It "Should retain literal key name in the absence or -UseMergingParser" {
                $result = ConvertFrom-Yaml -Yaml $mergingYaml
                [array]$values = $result.hoge.keys
                [array]::sort($values)
                Assert-Equivalent -Options $compareStrictly -Actual $values -Expected @("<<", "value3")
            }

            It "Shoud Throw duplicate key exception when merging keys" {
                # This case does not seem to be treated by YamlDotNet and currently throws
                # a duplicate key exception
                { ConvertFrom-Yaml -Yaml $mergingYamlOverwriteCase -UseMergingParser } | Should -Throw -PassThru | Select-Object -ExpandProperty Exception | 
Should -BeLike "*Duplicate key*"
            }

        }

        Context "Test hash handling under various circumstances." {
            $hash = @{
                # NOTE: intentionally not considered as YAML requires dict keys
                # be strings. As such; decoding the encoding of this would result
                # in a hash with the string key of "1", as below:
                # 1 = 42;
                "1" = 42;
                today = @{
                    month = "January";
                    year = "2016";
                    timestamp = Get-Date
                };
                arr = 1, 2, 3, "yes", @{ yes = "yes" };
                yes = "no"
            }

            It "Should be symmetrical to encode and then decode the hash as an argument." {
                $yaml = ConvertTo-Yaml $hash
                $h = ConvertFrom-Yaml $yaml

                Assert-Equivalent -Options $compareStrictly -Actual $h -Expected $hash
            }

            It "Should be symmetrical to endocode and then decode a hash by piping it." {
                $yaml = $hash | ConvertTo-Yaml
                $h = ConvertFrom-Yaml $yaml

                Assert-Equivalent -Options $compareStrictly -Actual $h -Expected $hash
            }

            It "Shouldn't matter whether we reference or pipe our hashes in to the YAML functions." {
                $arged = ConvertTo-Yaml $hash
                $piped = $hash | ConvertTo-Yaml

                Assert-Equivalent -Options $compareStrictly -Actual $piped -Expected $arged
            }
        }

    }

    Describe "Being able to decode an externally provided string." {

        Context "Decoding an arbitrary YAML string correctly." {
            BeforeAll {
                # testYaml is just a string containing some yaml to be tested below:
                $testYaml = @"
wishlist:
    - [coats, hats, and, scarves]
    - product : A Cool Book.
      quantity : 1
      description : I love that Cool Book.
      price : 55.34
total: 4443.52
int64: $([int64]::MaxValue)
note: >
    I can't wait.
    To get that Cool Book.

dates:
    - !!timestamp 2001-12-15T02:59:43.1Z
    - !!timestamp 2001-12-14t21:59:43.10-05:00
    - !!timestamp 2001-12-14 21:59:43.10 -5
    - !!timestamp 2001-12-15 2:59:43.10
    - !!timestamp 2002-12-14
datesAsStrings:
    - 2001-12-15T02:59:43.1Z
    - 2001-12-14t21:59:43.10-05:00
    - 2001-12-14 21:59:43.10 -5
    - 2001-12-15 2:59:43.10
    - 2002-12-14

version:
    - 1.2.3
noniso8601dates:
    - 5/4/2017
    - 1.2.3
bools:
    - true
    - false
    - TRUE
    - FALSE
    - True
    - False
"@

            
                $global:expected = @{
                    wishlist = @(
                        @("coats", "hats", "and", "scarves"),
                        @{
                            product = "A Cool Book.";
                            quantity = 1;
                            description = "I love that Cool Book.";
                            price = 55.34
                        }
                    );
                    total = 4443.52;
                    int64 = ([int64]::MaxValue);
                    note = ("I can't wait. To get that Cool Book.`n");
                    dates = @(
                        [DateTime]::Parse('2001-12-15T02:59:43.1Z'),
                        [DateTime]::Parse('2001-12-14t21:59:43.10-05:00'),
                        [DateTime]::Parse('2001-12-14 21:59:43.10 -5'),
                        [DateTime]::Parse('2001-12-15 2:59:43.10'),
                        [DateTime]::Parse('2002-12-14')
                    );
                    datesAsStrings = @(
                        "2001-12-15T02:59:43.1Z",
                        "2001-12-14t21:59:43.10-05:00",
                        "2001-12-14 21:59:43.10 -5",
                        "2001-12-15 2:59:43.10",
                        "2002-12-14"
                    );
                    version = "1.2.3";
                    noniso8601dates = @( '5/4/2017', '1.2.3' );            
                    bools = @( $true, $false, $true, $false, $true, $false );
                }

                $global:res = ConvertFrom-Yaml $testYaml
            }

            It "Should decode the YAML string as expected." {
                $wishlist = $res['wishlist']
                $wishlist | Should -Not -BeNullOrEmpty
                $wishlist.Count | Should -Be 2
                $wishlist[0] | Should -Not -BeNullOrEmpty
                $wishlist[0].Count | Should -Be 4
                $wishlist[0][0] | Should -Be $expected['wishlist'][0][0]
                $wishlist[0][1] | Should -Be $expected['wishlist'][0][1]
                $wishlist[0][2] | Should -Be $expected['wishlist'][0][2]
                $wishlist[0][3] | Should -Be $expected['wishlist'][0][3]
                $product = $res['wishlist'][1]
                $product | Should -Not -BeNullOrEmpty
                $expectedProduct = $expected['wishlist'][1]
                $product['product'] | Should -Be $expectedProduct['product']
                $product['quantity'] | Should -Be $expectedProduct['quantity']
                $product['description'] | Should -Be $expectedProduct['description']
                $product['price'] | Should -Be $expectedProduct['price']
                $res['total'] | Should -Be $expected['total']
                $res['note'] | Should -Be $expected['note']

                $res['dates'] | Should -Not -BeNullOrEmpty
                $res['dates'].Count | Should -Be $expected['dates'].Count
                for( $idx = 0; $idx -lt $expected['dates'].Count; ++$idx )
                {
                    $res['dates'][$idx] | Should -BeOfType ([datetime])
                    $res['dates'][$idx] | Should -Be $expected['dates'][$idx]
                }

                $res['datesAsStrings'] | Should -Not -BeNullOrEmpty
                $res['datesAsStrings'].Count | Should -Be $expected['datesAsStrings'].Count
                for( $idx = 0; $idx -lt $expected['datesAsStrings'].Count; ++$idx )
                {
                    $res['datesAsStrings'][$idx] | Should -BeOfType ([string])
                    $res['datesAsStrings'][$idx] | Should -Be $expected['dates'][$idx]
                }

                $res['version'] | Should -BeOfType ([string])
                $res['version'] | Should -Be $expected['version']

                $res['noniso8601dates'] | Should -Not -BeNullOrEmpty
                $res['noniso8601dates'].Count | Should -Be $expected['noniso8601dates'].Count
                for( $idx = 0; $idx -lt $expected['noniso8601dates'].Count; ++$idx )
                {
                    $res['noniso8601dates'][$idx] | Should -BeOfType ([string])
                    $res['noniso8601dates'][$idx] | Should -Be $expected['noniso8601dates'][$idx]
                }
                
                Assert-Equivalent -Options $compareStrictly -Actual $res -Expected $expected
            }
        }

    }

    Describe "Test ConvertTo-Yaml -OutFile parameter behavior" {

        Context "Providing -OutFile with invalid prefix." {
            BeforeAll {
                $testPath = "/some/bogus/path"
                $global:testObject = 42
                # mock Test-Path to fail so the test for the directory of the -OutFile fails:
                Mock Test-Path { return $false } -Verifiable -ParameterFilter { $OutFile -eq $testPath }
            }

            It "Should refuse to work with an -OutFile with an invalid prefix." {
                { ConvertTo-Yaml $testObject -OutFile $testPath } | Should -Throw "Parent folder for specified path does not exist"
            }

            It "Should verify that all the required mocks were called." {
                Assert-VerifiableMock
            }
        }

        Context "Providing existing -OutFile without -Force." {
            BeforeAll {
                $testPath = "/some/bogus/path"
                $global:testObject = "A random string this time."
                # mock Test-Path to succeed so the -OutFile seems to exist:
                Mock Test-Path { return $true } -Verifiable -ParameterFilter { $OutFile -eq $testPath }
            }

            It "Should refuse to work for an existing -OutFile but no -Force flag." {
                { ConvertTo-Yaml $testObject -OutFile $testPath } | Should -Throw "Target file already exists. Use -Force to overwrite."
            }

            It "Should verify that all the required mocks were called." {
                Assert-VerifiableMock
            }
        }

        Context "Providing a valid -OutFile." {
            BeforeAll {
                $global:testObject = @{ yes = "No"; "arr" = @(1, 2, 3) }
                $testPath = [System.IO.Path]::GetTempFileName()
                Remove-Item -Force $testPath # must be deleted for the test
            }

            It "Should succesfully write the expected content to the specified -OutFile." {
                $yaml = ConvertTo-Yaml $testObject
                ConvertTo-Yaml $testObject -OutFile $testPath

                Compare-Object $yaml (Get-Content -Raw $testPath) | Should -Be $null

            }

            # NOTE: the below assertion relies on the above writing its file.
            It "Should succesfully write the expected content to the specified -OutFile with -Force even if it exists." {
                $newTestObject = @(1, "two", @("arr", "ay"), @{ yes = "no"; answer = 42 })

                $yaml = ConvertTo-Yaml  $newTestObject
                ConvertTo-Yaml $newTestObject -OutFile $testPath -Force

                Compare-Object $yaml (Get-Content -Raw $testPath) | Should -Be $null
            }
        }

    }
    
    Describe "Generic Casting Behaviour" {
        Context "Node Style is 'Plain'" {
            BeforeAll {
                $global:value = @'
 T1: 001
'@

            }

            It 'Should be an int' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -BeOfType System.Int32
            }
            
            It 'Should be value of 1' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -Be 1
            }
            
            It 'Should not be value of 001' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -Not -Be '001'
            }
        }
        
        Context "Node Style is 'SingleQuoted'" {
            BeforeAll {
                $global:value = @'
 T1: '001'
'@

            }

            It 'Should be a string' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -BeOfType System.String
            }
            
            It 'Should be value of 001' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -Be '001'
            }
            
            It 'Should not be value of 1' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -Not -Be '1'
            }
        }
        
        Context "Node Style is 'DoubleQuoted'" {
            BeforeAll {
                $global:value = @'
 T1: "001"
'@

            }
            
            It 'Should be a string' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -BeOfType System.String
            }
            
            It 'Should be value of 001' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -Be '001'
            }
            
            It 'Should not be value of 1' {
                $result = ConvertFrom-Yaml -Yaml $value
                $result.T1 | Should -Not -Be '1'
            }
        }
    }

    Describe 'Strings containing other primitives' {
        Context 'String contains an int' {
            BeforeAll {
                $global:value = @{key="1"}
            }
            It 'Should serialise with double quotes' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key: ""1""$([Environment]::NewLine)"
            }
        }
        Context 'String contains a float' {
            BeforeAll {
                $global:value = @{key="0.25"}
            }
            It 'Should serialise with double quotes' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key: ""0.25""$([Environment]::NewLine)"
            }
        }
        Context 'String is "true"' {
            BeforeAll {
                $global:value = @{key="true"}
            }
            It 'Should serialise with double quotes' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key: ""true""$([Environment]::NewLine)"
            }
        }
        Context 'String is "false"' {
            BeforeAll {
                $global:value = @{key="false"}
            }
            It 'Should serialise with double quotes' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key: ""false""$([Environment]::NewLine)"
            }
        }
        Context 'String is "null"' {
            BeforeAll {
                $global:value = @{key="null"}
            }
            It 'Should serialise with double quotes' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key: ""null""$([Environment]::NewLine)"
            }
        }
        Context 'String is "~" (alternative syntax for null)' {
            BeforeAll {
                $global:value = @{key="~"}
            }
            It 'Should serialise with double quotes' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key: ""~""$([Environment]::NewLine)"
            }
        }
        Context 'String is empty' {
            BeforeAll {
                $global:value = @{key=""}
            }
            It 'Should serialise with double quotes' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key: """"$([Environment]::NewLine)"
            }
        }
    }

    Describe 'Numbers are parsed as the smallest type possible' {
        BeforeAll {
            $global:value = @'
bigInt: 99999999999999999999999999999999999
int32: 2147483647
int64: 9223372036854775807
'@

        }

        It 'Should be a BigInt' {
            $result = ConvertFrom-Yaml -Yaml $value
            $result.bigInt | Should -BeOfType System.Numerics.BigInteger
        }

        It 'Should be of proper type and value' {
            $result = ConvertFrom-Yaml -Yaml $value
            $result.bigInt | Should -Be ([System.Numerics.BigInteger]::Parse("99999999999999999999999999999999999"))
            $result.int32 | Should -Be ([int32]2147483647)
            $result.int64 | Should -Be ([int64]9223372036854775807)
        }
    }

    Describe 'PSCustomObjects' {
        Context 'Classes with PSCustomObjects' {
            It 'Should serialise as a hash' {
                $nestedPsO = [PSCustomObject]@{
                    Nested = 'NestedValue'
                }
                $PsO = [PSCustomObject]@{
                    Name = 'Value'
                    Nested = $nestedPsO
                    NullValue = $null
                }

                class TestClass {
                    [PSCustomObject]$PsO
                    [string]$Ok
                }
                $Class = [TestClass]@{
                    PsO = $PsO
                    Ok  = 'aye'
                }
                $asYaml = ConvertTo-Yaml $Class
                $result = ConvertFrom-Yaml -Yaml $asYaml -Ordered
                [System.Collections.Specialized.OrderedDictionary]$ret = [System.Collections.Specialized.OrderedDictionary]::new()
                $ret["PsO"] = [System.Collections.Specialized.OrderedDictionary]::new()
                $ret["PsO"]["Name"] = "Value"
                $ret["PsO"]["Nested"] = [System.Collections.Specialized.OrderedDictionary]::new()
                $ret["PsO"]["Nested"]["Nested"] = "NestedValue"
                $ret["PsO"]["NullValue"] = $null
                $ret["Ok"] = "aye"
                Assert-Equivalent -Options $compareStrictly -Expected $ret -Actual $result
            }
        }

        Context 'PSObject with null value is skipped when -Options OmitNullValues' {
            BeforeAll {
                $global:value = [PSCustomObject]@{
                    key1 = "value1"
                    key2 = $null
                }
            }
            It 'Should serialise as a hash with only the non-null value' {
                $result = ConvertTo-Yaml $value -Options OmitNullValues
                $result | Should -Be "key1: value1$([Environment]::NewLine)"
            }
        }

        Context 'PSObject with null value is included when -Options OmitNullValues is not set' {
            BeforeAll {
                $global:value = [PSCustomObject]@{
                    key1 = "value1"
                    key2 = $null
                }
            }
            It 'Should serialise as a hash with the null value' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key1: value1$([Environment]::NewLine)key2: $null$([Environment]::NewLine)"
            }
        }

        Context 'PSCustomObject with a single property' {
            BeforeAll {
                $global:value = [PSCustomObject]@{key="value"}
            }
            It 'Should serialise as a hash' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key: value$([Environment]::NewLine)"
            }
        }
        Context 'PSCustomObject with multiple properties' {
            BeforeAll {
                $global:value = [PSCustomObject]@{key1="value1"; key2="value2"}
            }
            It 'Should serialise as a hash' {
                $result = ConvertTo-Yaml $value
                $result | Should -Be "key1: value1$([Environment]::NewLine)key2: value2$([Environment]::NewLine)"
            }
            It 'Should deserialise as a hash' {
                $asYaml = ConvertTo-Yaml $value
                $result = ConvertFrom-Yaml -Yaml $asYaml -Ordered
                Assert-Equivalent -Options $compareStrictly -Expected @{key1="value1"; key2="value2"} -Actual ([hashtable]$result)
            }
        }
    }

    Describe 'StringQuotingEmitter' {
        BeforeAll {
            $oldYamlPkgUrl = 'https://www.nuget.org/api/v2/package/YamlDotNet/11.2.1'
            $pkgPath = Join-Path -Path $TestDrive -ChildPath 'YamlDotNet-11.2.1.nupkg'
            $oldYamlPkgDirPath = Join-Path -Path $TestDrive -ChildPath 'YamlDotNet-11.2.1'
            $ProgressPreference = 'SilentlyContinue'
            Invoke-WebRequest -Uri $oldYamlPkgUrl -UseBasicParsing -OutFile $pkgPath
            New-Item -Path $oldYamlPkgDirPath -ItemType Directory
            Add-Type -AssemblyName 'System.IO.Compression.FileSystem'
            [IO.Compression.ZipFile]::ExtractToDirectory($pkgPath, $oldYamlPkgDirPath)
        }

        $targetFrameworks = @('net45', 'netstandard1.3')
        if ($PSVersionTable['PSEdition'] -eq 'Core')
        {
            $targetFrameworks = @('netstandard1.3', 'netstandard2.1')
        }

        It 'can be compiled on import with <_>/YamlDotNet.dll loaded' -ForEach $targetFrameworks {
            $targetFramework = $_
            $yamlDotnetAssemblyPath =
                Join-Path -Path $TestDrive -ChildPath "YamlDotNet-11.2.1\lib\${targetFramework}\YamlDotNet.dll" -Resolve
            $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\powershell-yaml.psd1' -Resolve

            {
                # Do this in the background because YamlDotNet.dll is already loaded in this session and the way we
                # found to reproduce this issue is by loading YamlDotNet 11.2.1 then importing powershell-yaml.
                Start-Job {
                    $yamlDotnetAssemblyPath = $using:yamlDotnetAssemblyPath
                    $modulePath = $using:modulePath

                    Add-Type -Path $yamlDotnetAssemblyPath
                    Import-Module $modulePath
                } | Receive-Job -Wait -AutoRemoveJob -ErrorAction Stop
            } | Should -Not -Throw
        }
    }
}