ModuleFast.tests.ps1

using namespace System.Management.Automation
using namespace System.Collections.Generic
using namespace System.Diagnostics.CodeAnalysis
Import-Module ./ModuleFast.psm1 -Force

BeforeAll {
    if ($env:MFURI) {
        $PSDefaultParameterValues['Get-ModuleFastPlan:Source'] = $env:MFURI
    }
}

InModuleScope 'ModuleFast' {
    Describe 'ModuleFastSpec' {
        Context 'Constructors' {
            It 'Name' {
                $spec = [ModuleFastSpec]'Test'
                $spec.Name | Should -Be 'Test'
                $spec.Guid | Should -Be ([Guid]::Empty)
                $spec.Min | Should -Be ([ModuleFastSpec]::MinVersion)
                $spec.Max | Should -Be ([ModuleFastSpec]::MaxVersion)
                $spec.Required | Should -BeNull
            }

            It 'Has non-settable properties' {
                $spec = [ModuleFastSpec]'Test'
                { $spec.Min = '1' } | Should -Throw
                { $spec.Max = '1' } | Should -Throw
                { $spec.Required = '1' } | Should -Throw
                { $spec.Name = 'fake' } | Should -Throw
                { $spec.Guid = New-Guid } | Should -Throw
            }

            It 'ModuleSpecification' {
                $in = [ModuleSpecification]@{
                    ModuleName    = 'Test'
                    ModuleVersion = '2.1.5'
                }
                $spec = [ModuleFastSpec]$in
                $spec.Name | Should -Be 'Test'
                $spec.Guid | Should -Be ([Guid]::Empty)
                $spec.Min | Should -Be '2.1.5'
                $spec.Max | Should -Be ([ModuleFastSpec]::MaxVersion)
                $spec.Required | Should -BeNull
            }
        }

        Context 'ModuleSpecification Conversion' {
            It 'Name' {
                $spec = [ModuleSpecification][ModuleFastSpec]'Test'
                $spec.Name | Should -Be 'Test'
                $spec.Version | Should -Be '0.0.0'
            }
            It 'RequiredVersion' {
                $spec = [ModuleSpecification][ModuleFastSpec]::new('Test', '1.2.3')
                $spec.Name | Should -Be 'Test'
                $spec.RequiredVersion | Should -Be '1.2.3'
            }
            It 'ModuleVersion' {
                $spec = [ModuleSpecification][ModuleFastSpec]::new('Test', '1.2.3', '')
                $spec.Name | Should -Be 'Test'
                $spec.Version | Should -Be '1.2.3'
            }
        }

        Context 'ParseVersion' {
            It 'parses a normal version' {
                $version = '1.2.3'
                $result = [ModuleFastSpec]::ParseVersion($version)
                $result.Major | Should -Be 1
                $result.Minor | Should -Be 2
                $result.Patch | Should -Be 3
                $result.PreReleaseLabel | Should -BeNull
                $result.BuildLabel | Should -BeNull
                [ModuleFastSpec]::ParseSemanticVersion($result) | Should -BeExactly $version
            }
            It 'parses a system version' {
                $version = '1.2.3.4'
                $result = [ModuleFastSpec]::ParseVersion($version)
                $result.Major | Should -Be 1
                $result.Minor | Should -Be 2
                $result.Patch | Should -Be (3 + 1)
                $result.PreReleaseLabel | Should -Be (4).ToString().PadLeft(10, '0')
                $result.BuildLabel | Should -Be 'SYSTEMVERSION.HASREVISION'
                [ModuleFastSpec]::ParseSemanticVersion($result) | Should -BeExactly $version
            }
            It 'parses a major/minor only version' {
                $version = '1.4'
                $result = [ModuleFastSpec]::ParseVersion($version)
                $result.Major | Should -Be 1
                $result.Minor | Should -Be 4
                $result.Patch | Should -Be 0
                $result.PreReleaseLabel | Should -BeNull
                $result.BuildLabel | Should -Be 'SYSTEMVERSION.NOBUILD'
                [ModuleFastSpec]::ParseSemanticVersion($result) | Should -BeExactly $version
            }
            It 'parses a patch version being zero' {
                $version = '1.4.0.5'
                $result = [ModuleFastSpec]::ParseVersion($version)
                $result.Major | Should -Be 1
                $result.Minor | Should -Be 4
                $result.Patch | Should -Be (0 + 1)
                $result.PreReleaseLabel | Should -Be (5).ToString().PadLeft(10, '0')
                $result.BuildLabel | Should -Be 'SYSTEMVERSION.HASREVISION'
                [ModuleFastSpec]::ParseSemanticVersion($result) | Should -BeExactly $version
            }
        }
        Context 'ParseSemanticVersion' {
            It 'parses a normal version' {
                $version = '1.2.3'
                $result = [ModuleFastSpec]::ParseSemanticVersion($version)
                $result.Major | Should -Be 1
                $result.Minor | Should -Be 2
                $result.Build | Should -Be 3
                $result.Revision | Should -Be -1
            }
            It 'strips non-version fields' {
                $version = '1.2.3-something+4'
                $result = [ModuleFastSpec]::ParseSemanticVersion($version)
                $result.Major | Should -Be 1
                $result.Minor | Should -Be 2
                $result.Build | Should -Be 3
                $result.Revision | Should -Be -1
            }
        }
        Context 'Overlap' {
            It 'overlaps exactly' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $spec2 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $spec1.Overlaps($spec2) | Should -BeTrue
                $spec2.Overlaps($spec1) | Should -BeTrue
            }
            It 'overlaps partially' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.1', '1.2.4')
                $spec2 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.5')
                $spec1.Overlaps($spec2) | Should -BeTrue
                $spec2.Overlaps($spec1) | Should -BeTrue
            }
            It 'no overlap' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.1', '1.2.2')
                $spec2 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $spec1.Overlaps($spec2) | Should -BeFalse
                $spec2.Overlaps($spec1) | Should -BeFalse
            }
            It 'overlaps partially with no max' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.1', '1.2.4')
                $spec2 = [ModuleFastSpec]::new('Test', '1.2.3')
                $spec1.Overlaps($spec2) | Should -BeTrue
                $spec2.Overlaps($spec1) | Should -BeTrue
            }
            It 'overlaps partially with no min' {
                $spec1 = [ModuleFastSpec]::new('Test', $null, '1.2.4')
                $spec2 = [ModuleFastSpec]::new('Test', '1.2.3')
                $spec1.Overlaps($spec2) | Should -BeTrue
                $spec2.Overlaps($spec1) | Should -BeTrue
            }
            It 'overlaps partially with no min or max' {
                $spec1 = [ModuleFastSpec]'Test'
                $spec2 = [ModuleFastSpec]'Test'
                $spec1.Overlaps($spec2) | Should -BeTrue
                $spec2.Overlaps($spec1) | Should -BeTrue
            }
            It 'errors on different Names' {
                $spec1 = [ModuleFastSpec]'Test'
                $spec2 = [ModuleFastSpec]'Test2'
                { $spec1.Overlaps($spec2) } | Should -Throw
                { $spec2.Overlaps($spec1) } | Should -Throw
            }
            It 'errors on different GUIDs' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.0.0', [Guid]::NewGuid())
                $spec2 = [ModuleFastSpec]::new('Test', '1.0.0', [Guid]::NewGuid())
                { $spec1.Overlaps($spec2) } | Should -Throw
                { $spec2.Overlaps($spec1) } | Should -Throw
            }
        }
        Context 'Equals' {
            It 'ModuleFastSpec' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $spec2 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $spec1 -eq $spec2 | Should -BeTrue
            }
            It 'ModuleFastSpec not equal on name' {
                $spec1 = [ModuleFastSpec]'Test'
                $spec2 = [ModuleFastSpec]'Test2'
                $spec1 -eq $spec2 | Should -BeFalse
            }
            It 'ModuleFastSpec not equal on min' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.3')
                $spec2 = [ModuleFastSpec]::new('Test', '1.2.4')
                $spec1 -eq $spec2 | Should -BeFalse
            }
            It 'ModuleFastSpec not equal on max' {
                $spec1 = [ModuleFastSpec]::new('Test', $null, '1.2.3')
                $spec2 = [ModuleFastSpec]::new('Test', $null, '1.2.4')
                $spec1 -eq $spec2 | Should -BeFalse
            }
            It 'Version In Range' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $version = [Version]::new('1.2.3')
                $spec1 -eq $version | Should -BeTrue
            }
            It 'Version NotIn Range' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $version = [Version]::new('1.2.2')
                $spec1 -eq $version | Should -BeFalse
            }
            It 'SemanticVersion In Range' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $version = [SemanticVersion]::new('1.2.3')
                $spec1 -eq $version | Should -BeTrue
            }
            It 'SemanticVersion NotIn Range' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
                $version = [SemanticVersion]::new('1.2.3')
                $spec1 -eq $version | Should -BeTrue
            }

            It 'String Comparisons' {
                $spec = [ModuleFastSpec]::new('Test', '1.1.1', '2.2.2')
                $spec -eq '1' | Should -BeFalse
                $spec -eq '2' | Should -BeTrue
                $spec -eq '3' | Should -BeFalse
                $spec -eq '1.0' | Should -BeFalse
                $spec -eq '1.1' | Should -BeFalse
                $spec -eq '1.2' | Should -BeTrue
                $spec -eq '2.0' | Should -BeTrue
                $spec -eq '2.2' | Should -BeTrue
                $spec -eq '2.3' | Should -BeFalse
                $spec -eq '3.0' | Should -BeFalse
                $spec -eq '1.1.0' | Should -BeFalse
                $spec -eq '1.1.1' | Should -BeTrue
                $spec -eq '1.1.2' | Should -BeTrue
                $spec -eq '2.2.2' | Should -BeTrue
                $spec -eq '2.2.3' | Should -BeFalse
                $spec -eq '3.0.0' | Should -BeFalse
            }
        }

        Context 'Compare' {
            It 'Sorts' {
                $spec1 = [ModuleFastSpec]::new('Test', '1.2.3')
                $spec2 = [ModuleFastSpec]::new('Test', '1.2.4')
                $spec3 = [ModuleFastSpec]::new('Test', '1.2.5')
                $spec3, $spec1, $spec2
            | Sort-Object
            | Should -Be @( $spec1, $spec2, $spec3 )

                $spec3, $spec1, $spec2
            | Sort-Object -Descending
            | Should -Be @( $spec3, $spec2, $spec1 )
            }
        }
    }

    Describe 'HashSet Dedupe (GetHashCode)' {
        It 'Name' {
            $spec1 = [ModuleFastSpec]'Test'
            $spec2 = [ModuleFastSpec]'Test'
            $spec1.GetHashCode() | Should -Be $spec2.GetHashCode()
        }
        It 'RequiredVersion' {
            $spec1 = [ModuleFastSpec]::new('Test', '1.2.3')
            $spec2 = [ModuleFastSpec]::new('Test', '1.2.3')
            $spec1.GetHashCode() | Should -Be $spec2.GetHashCode()
        }
        It 'Min and Max Version' {
            $spec1 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
            $spec2 = [ModuleFastSpec]::new('Test', '1.2.3', '1.2.4')
            $spec1.GetHashCode() | Should -Be $spec2.GetHashCode()
        }
        It 'Max Version Only' {
            $spec1 = [ModuleFastSpec]::new('Test', $null, '1.2.4')
            $spec2 = [ModuleFastSpec]::new('Test', $null, '1.2.4')
            $spec1.GetHashCode() | Should -Be $spec2.GetHashCode()
        }
        It 'Min Version Only' {
            $spec1 = [ModuleFastSpec]::new('Test', '1.2.3')
            $spec2 = [ModuleFastSpec]::new('Test', '1.2.3')
            $spec1.GetHashCode() | Should -Be $spec2.GetHashCode()
        }
        It 'Guid' {
            [HashSet[ModuleFastSpec]]$hs = @{}
            $guid = [Guid]::NewGuid()
            $spec1 = [ModuleFastSpec]::new('Test', '1.2.4', $guid)
            $hs.Add($spec1) | Should -BeTrue
            $spec2 = [ModuleFastSpec]::new('Test', '1.2.4', $guid)
            $hs.Add($spec2) | Should -BeFalse
        }
    }

    Describe 'NugetRange' {
        Context 'Decrement' {
            It '<In> should be <Out>' {
                $actual = [NugetRange]::Decrement($In)
                $actual | Should -Be $Out
                $actual | Should -BeLessThan $In
            } -TestCases @(
                @{In = '1.0.1-build+5'; Out = '1.0.0' }
                @{In = '1.0.1'; Out = '1.0.0' }
                @{In = '1.0.2'; Out = '1.0.1' }
                @{In = '1.1.2'; Out = '1.1.1' }
                @{In = '0.1.2'; Out = '0.1.1' }
            )
        }
    }
}

Describe 'Get-ModuleFastPlan' -Tag 'E2E' {
    BeforeAll {
        $SCRIPT:__existingPSModulePath = $env:PSModulePath
        $env:PSModulePath = $testDrive

        $SCRIPT:__existingProgressPreference = $ProgressPreference
        $ProgressPreference = 'SilentlyContinue'

    }
    AfterAll {
        $env:PSModulePath = $SCRIPT:__existingPSModulePath
        $ProgressPreference = $SCRIPT:__existingProgressPreference
    }

    #This is used for testcases
    $SCRIPT:moduleName = 'Az.Accounts'

    It 'Gets Module by <Test>' {
        $actual = Get-ModuleFastPlan $spec
        $actual | Should -HaveCount 1
        $actual.Name | Should -Be $moduleName
        $actual.Required -as [Version] | Should -Not -BeNullOrEmpty
    } -TestCases (
        @{Test = 'Name'; Spec = $moduleName },
        @{Test = 'MinimumVersion'; Spec = @{ ModuleName = $moduleName; ModuleVersion = '0.0.0' } },
        @{Test = 'RequiredVersionNotLatest'; Spec = @{ ModuleName = $moduleName; RequiredVersion = '2.7.3' } }
    )
    It 'Gets Module with 1 dependency' {
        Get-ModuleFastPlan 'Az.Compute' | Should -HaveCount 2
    }
    It 'Gets Module with lots of dependencies (Az)' {
        #TODO: Mocks
        Get-ModuleFastPlan 'Az' | Should -HaveCount 78
    }
    It 'Gets Module with 4 section version number and a 4 section version number dependency (VMware.VimAutomation.Common)' {
        Get-ModuleFastPlan 'VMware.VimAutomation.Common' | Should -HaveCount 2

    }
    It 'Gets multiple modules' {
        Get-ModuleFastPlan 'Az', 'VMware.PowerCLI' | Should -HaveCount 153
    }
}

Describe 'Install-ModuleFast' -Tag 'E2E' {
    BeforeAll {
        $SCRIPT:__existingPSModulePath = $env:PSModulePath
        filter Limit-ModulePath {
            param(
                [string]$path,

                [Parameter(ValueFromPipeline)]
                [Management.Automation.PSModuleInfo]$InputObject
            )
            if ($PSItem.Path.StartsWith($path)) {
                return $PSItem
            }
        }
    }
    BeforeEach {
        #Remove all PSModulePath to not affect existing environment
        $installTempPath = Join-Path $testdrive $(New-Guid)
        New-Item -ItemType Directory -Path $installTempPath -ErrorAction stop
        $env:PSModulePath = $installTempPath

        [SuppressMessageAttribute(
            <#Category#>'PSUseDeclaredVarsMoreThanAssignments',
            <#CheckId#>$null,
            Justification = 'PSScriptAnalyzer doesnt see the connection between beforeeach and Describe/It'
        )]
        $imfParams = @{
            Destination          = $installTempPath
            NoProfileUpdate      = $true
            NoPSModulePathUpdate = $true
            Confirm              = $false
        }
    }
    AfterAll {
        $env:PSModulePath = $SCRIPT:__existingPSModulePath
    }
    It 'Installs Module' {
        #HACK: The testdrive mount is not available in the threadjob runspaces so we need to translate it
        Install-ModuleFast @imfParams 'Az.Accounts'
        Get-Item $installTempPath\Az.Accounts\*\Az.Accounts.psd1 | Should -Not -BeNullOrEmpty
    }
    It '4 section version numbers (VMware.PowerCLI)' {
        Install-ModuleFast @imfParams 'VMware.VimAutomation.Common'
        Get-Item $installTempPath\VMware*\*\*.psd1 | ForEach-Object {
            $moduleFolderVersion = $_ | Split-Path | Split-Path -Leaf
            Import-PowerShellDataFile -Path $_.FullName | ForEach-Object ModuleVersion | Should -Be $moduleFolderVersion
        }
        Get-Module VMWare* -ListAvailable
        | Limit-ModulePath $installTempPath
        | Should -HaveCount 2
    }
    It 'lots of dependencies (Az)' {
        Install-ModuleFast @imfParams 'Az'
        (Get-Module Az* -ListAvailable).count | Should -BeGreaterThan 10
    }
    It 'specific requiredVersion' {
        Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; RequiredVersion = '2.7.4' }
        Get-Module Az.Accounts -ListAvailable
        | Limit-ModulePath $installTempPath
        | Select-Object -ExpandProperty Version
        | Should -Be '2.7.4'
    }
    It 'specific requiredVersion when newer version is present' {
        Install-ModuleFast @imfParams 'Az.Accounts'
        Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; RequiredVersion = '2.7.4' }
        $installedVersions = Get-Module Az.Accounts -ListAvailable
        | Limit-ModulePath $installTempPath
        | Select-Object -ExpandProperty Version

        $installedVersions | Should -HaveCount 2
        $installedVersions | Should -Contain '2.7.4'
    }
    It 'Installs when Maximumversion is lower than currently installed' {
        Install-ModuleFast @imfParams 'Az.Accounts'
        Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; MaximumVersion = '2.7.3' }
        Get-Module Az.Accounts -ListAvailable
        | Limit-ModulePath $installTempPath
        | Select-Object -ExpandProperty Version
        | Should -Contain '2.7.3'
    }
    It 'Only installs once when Update is specified and latest has not changed' {
        Install-ModuleFast @imfParams 'Az.Accounts' -Update
        #This will error if the file already exists
        Install-ModuleFast @imfParams 'Az.Accounts' -Update
    }

    It 'Updates only dependent module that requires update' {
        Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; RequiredVersion = '2.10.2' }
        Install-ModuleFast @imfParams    @{ ModuleName = 'Az.Compute'; RequiredVersion = '5.0.0' }
        Get-Module Az.Accounts -ListAvailable
        | Limit-ModulePath $installTempPath
        | Select-Object -ExpandProperty Version
        | Sort-Object Version -Descending
        | Select-Object -First 1
        | Should -Be '2.10.2'

        Install-ModuleFast @imfParams 'Az.Compute', 'Az.Accounts' #Should not update
        Get-Module Az.Accounts -ListAvailable
        | Limit-ModulePath $installTempPath
        | Select-Object -ExpandProperty Version
        | Sort-Object Version -Descending
        | Select-Object -First 1
        | Should -Be '2.10.2'

        Install-ModuleFast @imfParams 'Az.Compute' -Update #Should disregard local install and update latest Az.Accounts
        Get-Module Az.Accounts -ListAvailable
        | Limit-ModulePath $installTempPath
        | Select-Object -ExpandProperty Version
        | Sort-Object Version -Descending
        | Select-Object -First 1
        | Should -BeGreaterThan ([version]'2.10.2')

        Get-Module Az.Compute -ListAvailable
        | Limit-ModulePath $installTempPath
        | Select-Object -ExpandProperty Version
        | Sort-Object Version -Descending
        | Select-Object -First 1
        | Should -BeGreaterThan ([version]'5.0.0')
    }
}