Tests/M365-LicenseOptimizer.Tests.ps1

BeforeAll {
    $ModuleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    $ModulePath = Join-Path $PSScriptRoot '..'
    $ManifestPath = Join-Path $ModulePath 'M365-LicenseOptimizer.psd1'
    $ModulePsm1   = Join-Path $ModulePath 'M365-LicenseOptimizer.psm1'

    # Remove if already loaded, then import
    Get-Module M365-LicenseOptimizer -ErrorAction SilentlyContinue | Remove-Module -Force
    Import-Module $ManifestPath -Force
}

AfterAll {
    Get-Module M365-LicenseOptimizer -ErrorAction SilentlyContinue | Remove-Module -Force
}

Describe 'Module: M365-LicenseOptimizer' {

    Context 'Module Loading' {

        It 'Should import without errors' {
            $module = Get-Module M365-LicenseOptimizer
            $module | Should -Not -BeNullOrEmpty
        }

        It 'Should export exactly 5 public functions' {
            $module = Get-Module M365-LicenseOptimizer
            $module.ExportedFunctions.Count | Should -Be 5
        }

        It 'Should export Invoke-LicenseOptimization' {
            $module = Get-Module M365-LicenseOptimizer
            $module.ExportedFunctions.Keys | Should -Contain 'Invoke-LicenseOptimization'
        }

        It 'Should export Get-LicenseInventory' {
            $module = Get-Module M365-LicenseOptimizer
            $module.ExportedFunctions.Keys | Should -Contain 'Get-LicenseInventory'
        }

        It 'Should export Get-UnderutilizedLicenses' {
            $module = Get-Module M365-LicenseOptimizer
            $module.ExportedFunctions.Keys | Should -Contain 'Get-UnderutilizedLicenses'
        }

        It 'Should export Get-InactiveLicensedUsers' {
            $module = Get-Module M365-LicenseOptimizer
            $module.ExportedFunctions.Keys | Should -Contain 'Get-InactiveLicensedUsers'
        }

        It 'Should export Get-LicenseSavingsReport' {
            $module = Get-Module M365-LicenseOptimizer
            $module.ExportedFunctions.Keys | Should -Contain 'Get-LicenseSavingsReport'
        }

        It 'Should NOT export private functions' {
            $module = Get-Module M365-LicenseOptimizer
            $module.ExportedFunctions.Keys | Should -Not -Contain 'Test-GraphConnection'
            $module.ExportedFunctions.Keys | Should -Not -Contain 'New-HtmlDashboard'
            $module.ExportedFunctions.Keys | Should -Not -Contain 'Get-LicenseFriendlyName'
        }
    }

    Context 'Manifest Validation' {

        It 'Should have a valid module manifest' {
            $manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..' 'M365-LicenseOptimizer.psd1')
            $manifest | Should -Not -BeNullOrEmpty
        }

        It 'Should have the correct GUID' {
            $manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..' 'M365-LicenseOptimizer.psd1')
            $manifest.GUID | Should -Be 'c3d4e5f6-0a91-4c7d-be8f-4a5b6c7d8e9f'
        }

        It 'Should require PowerShell 5.1' {
            $manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..' 'M365-LicenseOptimizer.psd1')
            $manifest.PowerShellVersion | Should -Be '5.1'
        }

        It 'Should have the correct author' {
            $manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..' 'M365-LicenseOptimizer.psd1')
            $manifest.Author | Should -BeLike '*Larry Roberts*'
        }

        It 'Should have a description mentioning license optimization' {
            $manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..' 'M365-LicenseOptimizer.psd1')
            $manifest.Description | Should -BeLike '*license*'
        }

        It 'Should have proper tags' {
            $manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..' 'M365-LicenseOptimizer.psd1')
            $manifest.Tags | Should -Contain 'Microsoft365'
            $manifest.Tags | Should -Contain 'License'
            $manifest.Tags | Should -Contain 'Optimization'
        }

        It 'Should have ProjectUri set' {
            $manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..' 'M365-LicenseOptimizer.psd1')
            $manifest.ProjectUri | Should -Not -BeNullOrEmpty
        }

        It 'Should have LicenseUri set' {
            $manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..' 'M365-LicenseOptimizer.psd1')
            $manifest.LicenseUri | Should -Not -BeNullOrEmpty
        }
    }

    Context 'Parameter Validation' {

        It 'Invoke-LicenseOptimization should have OutputPath parameter' {
            $cmd = Get-Command Invoke-LicenseOptimization
            $cmd.Parameters.Keys | Should -Contain 'OutputPath'
        }

        It 'Invoke-LicenseOptimization should have DaysInactive parameter' {
            $cmd = Get-Command Invoke-LicenseOptimization
            $cmd.Parameters.Keys | Should -Contain 'DaysInactive'
        }

        It 'Invoke-LicenseOptimization should have IncludeGuests switch' {
            $cmd = Get-Command Invoke-LicenseOptimization
            $cmd.Parameters['IncludeGuests'].SwitchParameter | Should -Be $true
        }

        It 'Get-UnderutilizedLicenses should have DaysInactive with default 90' {
            $cmd = Get-Command Get-UnderutilizedLicenses
            $cmd.Parameters.Keys | Should -Contain 'DaysInactive'
            $cmd.Parameters['DaysInactive'].Attributes.Where({ $_ -is [System.Management.Automation.ParameterAttribute] })[0].Mandatory | Should -Be $false
        }

        It 'Get-InactiveLicensedUsers should have IncludeGuests switch' {
            $cmd = Get-Command Get-InactiveLicensedUsers
            $cmd.Parameters['IncludeGuests'].SwitchParameter | Should -Be $true
        }

        It 'Get-InactiveLicensedUsers should have IncludeDisabled switch' {
            $cmd = Get-Command Get-InactiveLicensedUsers
            $cmd.Parameters['IncludeDisabled'].SwitchParameter | Should -Be $true
        }

        It 'Get-LicenseSavingsReport should have DaysInactive parameter' {
            $cmd = Get-Command Get-LicenseSavingsReport
            $cmd.Parameters.Keys | Should -Contain 'DaysInactive'
        }
    }

    Context 'Get-LicenseInventory - Mock-Based' {

        BeforeAll {
            # Mock Graph connection check
            Mock Test-GraphConnection { return $true } -ModuleName M365-LicenseOptimizer

            # Mock Get-MgSubscribedSku with sample data
            Mock Get-MgSubscribedSku {
                @(
                    [PSCustomObject]@{
                        SkuPartNumber = 'SPE_E5'
                        SkuId         = '06ebc4ee-1bb5-47dd-8120-11324bc54e06'
                        PrepaidUnits  = [PSCustomObject]@{ Enabled = 100 }
                        ConsumedUnits = 85
                    },
                    [PSCustomObject]@{
                        SkuPartNumber = 'ENTERPRISEPACK'
                        SkuId         = '6fd2c87f-b296-42f0-b197-1e91e994b900'
                        PrepaidUnits  = [PSCustomObject]@{ Enabled = 200 }
                        ConsumedUnits = 150
                    },
                    [PSCustomObject]@{
                        SkuPartNumber = 'EXCHANGESTANDARD'
                        SkuId         = '4b9405b0-7788-4568-add1-99614e613b69'
                        PrepaidUnits  = [PSCustomObject]@{ Enabled = 50 }
                        ConsumedUnits = 49
                    }
                )
            } -ModuleName M365-LicenseOptimizer
        }

        It 'Should return results for all SKUs' {
            $results = Get-LicenseInventory
            $results.Count | Should -Be 3
        }

        It 'Should calculate utilization percentage correctly' {
            $results = Get-LicenseInventory
            $e5 = $results | Where-Object { $_.SkuPartNumber -eq 'SPE_E5' }
            $e5.UtilizationPercent | Should -Be 85.0
        }

        It 'Should calculate available licenses correctly' {
            $results = Get-LicenseInventory
            $e5 = $results | Where-Object { $_.SkuPartNumber -eq 'SPE_E5' }
            $e5.AvailableLicenses | Should -Be 15
        }

        It 'Should flag OVER-PROVISIONED when >20% unassigned' {
            $results = Get-LicenseInventory
            $e3 = $results | Where-Object { $_.SkuPartNumber -eq 'ENTERPRISEPACK' }
            $e3.Finding | Should -Be 'OVER-PROVISIONED'
        }

        It 'Should flag FULLY UTILIZED when >=95% assigned' {
            $results = Get-LicenseInventory
            $exo = $results | Where-Object { $_.SkuPartNumber -eq 'EXCHANGESTANDARD' }
            $exo.Finding | Should -Be 'FULLY UTILIZED'
        }

        It 'Should map friendly names correctly' {
            $results = Get-LicenseInventory
            $e5 = $results | Where-Object { $_.SkuPartNumber -eq 'SPE_E5' }
            $e5.FriendlyName | Should -Be 'Microsoft 365 E5'
        }

        It 'Should calculate monthly cost as cost-per-license times assigned' {
            $results = Get-LicenseInventory
            $e5 = $results | Where-Object { $_.SkuPartNumber -eq 'SPE_E5' }
            # 85 assigned * $57/mo = $4845
            $e5.MonthlyCost | Should -Be 4845.00
        }
    }

    Context 'Get-InactiveLicensedUsers - Mock-Based' {

        BeforeAll {
            Mock Test-GraphConnection { return $true } -ModuleName M365-LicenseOptimizer

            $testSkuId = '06ebc4ee-1bb5-47dd-8120-11324bc54e06'

            Mock Get-MgSubscribedSku {
                @(
                    [PSCustomObject]@{
                        SkuPartNumber = 'SPE_E5'
                        SkuId         = $testSkuId
                        PrepaidUnits  = [PSCustomObject]@{ Enabled = 100 }
                        ConsumedUnits = 85
                    }
                )
            } -ModuleName M365-LicenseOptimizer

            Mock Get-MgUser {
                @(
                    # Active user - should NOT appear
                    [PSCustomObject]@{
                        Id                 = '1'
                        UserPrincipalName  = 'active@contoso.com'
                        DisplayName        = 'Active User'
                        AccountEnabled     = $true
                        UserType           = 'Member'
                        AssignedLicenses   = @([PSCustomObject]@{ SkuId = $testSkuId; DisabledPlans = @() })
                        SignInActivity     = [PSCustomObject]@{
                            LastSignInDateTime = (Get-Date).AddDays(-5).ToString('o')
                        }
                    },
                    # Inactive user - 120 days since sign-in
                    [PSCustomObject]@{
                        Id                 = '2'
                        UserPrincipalName  = 'inactive@contoso.com'
                        DisplayName        = 'Inactive User'
                        AccountEnabled     = $true
                        UserType           = 'Member'
                        AssignedLicenses   = @([PSCustomObject]@{ SkuId = $testSkuId; DisabledPlans = @() })
                        SignInActivity     = [PSCustomObject]@{
                            LastSignInDateTime = (Get-Date).AddDays(-120).ToString('o')
                        }
                    },
                    # Disabled user with license
                    [PSCustomObject]@{
                        Id                 = '3'
                        UserPrincipalName  = 'disabled@contoso.com'
                        DisplayName        = 'Disabled User'
                        AccountEnabled     = $false
                        UserType           = 'Member'
                        AssignedLicenses   = @([PSCustomObject]@{ SkuId = $testSkuId; DisabledPlans = @() })
                        SignInActivity     = [PSCustomObject]@{
                            LastSignInDateTime = (Get-Date).AddDays(-200).ToString('o')
                        }
                    },
                    # Never signed in
                    [PSCustomObject]@{
                        Id                 = '4'
                        UserPrincipalName  = 'neversigned@contoso.com'
                        DisplayName        = 'Never Signed In'
                        AccountEnabled     = $true
                        UserType           = 'Member'
                        AssignedLicenses   = @([PSCustomObject]@{ SkuId = $testSkuId; DisabledPlans = @() })
                        SignInActivity     = [PSCustomObject]@{
                            LastSignInDateTime = $null
                        }
                    }
                )
            } -ModuleName M365-LicenseOptimizer
        }

        It 'Should detect inactive licensed users' {
            $results = Get-InactiveLicensedUsers -DaysInactive 90
            $inactive = $results | Where-Object { $_.Finding -eq 'INACTIVE LICENSED USER' }
            $inactive | Should -Not -BeNullOrEmpty
            $inactive.UserPrincipalName | Should -Contain 'inactive@contoso.com'
        }

        It 'Should detect disabled accounts with licenses' {
            $results = Get-InactiveLicensedUsers -DaysInactive 90
            $disabled = $results | Where-Object { $_.Finding -eq 'DISABLED WITH LICENSE' }
            $disabled | Should -Not -BeNullOrEmpty
            $disabled.UserPrincipalName | Should -Contain 'disabled@contoso.com'
        }

        It 'Should detect users who never signed in' {
            $results = Get-InactiveLicensedUsers -DaysInactive 90
            $never = $results | Where-Object { $_.Finding -eq 'NEVER SIGNED IN' }
            $never | Should -Not -BeNullOrEmpty
            $never.UserPrincipalName | Should -Contain 'neversigned@contoso.com'
        }

        It 'Should NOT flag active users' {
            $results = Get-InactiveLicensedUsers -DaysInactive 90
            $results.UserPrincipalName | Should -Not -Contain 'active@contoso.com'
        }

        It 'Should calculate license cost for each user' {
            $results = Get-InactiveLicensedUsers -DaysInactive 90
            $results | ForEach-Object {
                $_.LicenseCost | Should -BeGreaterThan 0
            }
        }

        It 'Should return correct LastSignIn display for never-signed-in users' {
            $results = Get-InactiveLicensedUsers -DaysInactive 90
            $never = $results | Where-Object { $_.Finding -eq 'NEVER SIGNED IN' }
            $never.LastSignIn | Should -Be 'Never'
        }
    }

    Context 'Get-LicenseSavingsReport - Mock-Based' {

        BeforeAll {
            Mock Test-GraphConnection { return $true } -ModuleName M365-LicenseOptimizer

            # Mock Get-LicenseInventory
            Mock Get-LicenseInventory {
                @(
                    [PSCustomObject]@{
                        SkuPartNumber      = 'SPE_E5'
                        FriendlyName       = 'Microsoft 365 E5'
                        TotalLicenses      = 100
                        AssignedLicenses   = 85
                        AvailableLicenses  = 15
                        UtilizationPercent = 85.0
                        MonthlyCost        = 4845.00
                        Finding            = 'OVER-PROVISIONED'
                    },
                    [PSCustomObject]@{
                        SkuPartNumber      = 'ENTERPRISEPACK'
                        FriendlyName       = 'Office 365 E3'
                        TotalLicenses      = 200
                        AssignedLicenses   = 190
                        AvailableLicenses  = 10
                        UtilizationPercent = 95.0
                        MonthlyCost        = 4370.00
                        Finding            = 'FULLY UTILIZED'
                    }
                )
            } -ModuleName M365-LicenseOptimizer

            # Mock Get-UnderutilizedLicenses
            Mock Get-UnderutilizedLicenses {
                @(
                    [PSCustomObject]@{
                        UserPrincipalName  = 'user1@contoso.com'
                        DisplayName        = 'User One'
                        AssignedLicense    = 'Microsoft 365 E5'
                        LastSignIn         = '2025-11-01'
                        DaysSinceSignIn    = 45
                        ServicesUsed       = 'Exchange, Teams'
                        RecommendedLicense = 'Microsoft 365 E3'
                        MonthlySavings     = 21.00
                        Finding            = 'DOWNGRADE CANDIDATE'
                    },
                    [PSCustomObject]@{
                        UserPrincipalName  = 'user2@contoso.com'
                        DisplayName        = 'User Two'
                        AssignedLicense    = 'Microsoft 365 E5'
                        LastSignIn         = '2025-11-10'
                        DaysSinceSignIn    = 36
                        ServicesUsed       = 'Exchange'
                        RecommendedLicense = 'Microsoft 365 E3'
                        MonthlySavings     = 21.00
                        Finding            = 'DOWNGRADE CANDIDATE'
                    }
                )
            } -ModuleName M365-LicenseOptimizer

            # Mock Get-InactiveLicensedUsers
            Mock Get-InactiveLicensedUsers {
                @(
                    [PSCustomObject]@{
                        UserPrincipalName = 'inactive1@contoso.com'
                        DisplayName       = 'Inactive One'
                        AccountEnabled    = $true
                        UserType          = 'Member'
                        AssignedLicenses  = 'Microsoft 365 E5'
                        LicenseCost       = 57.00
                        LastSignIn        = '2025-08-01'
                        DaysSinceSignIn   = 150
                        Finding           = 'INACTIVE LICENSED USER'
                    },
                    [PSCustomObject]@{
                        UserPrincipalName = 'disabled1@contoso.com'
                        DisplayName       = 'Disabled One'
                        AccountEnabled    = $false
                        UserType          = 'Member'
                        AssignedLicenses  = 'Office 365 E3'
                        LicenseCost       = 23.00
                        LastSignIn        = '2025-06-01'
                        DaysSinceSignIn   = 210
                        Finding           = 'DISABLED WITH LICENSE'
                    },
                    [PSCustomObject]@{
                        UserPrincipalName = 'never1@contoso.com'
                        DisplayName       = 'Never One'
                        AccountEnabled    = $true
                        UserType          = 'Member'
                        AssignedLicenses  = 'Microsoft 365 E5'
                        LicenseCost       = 57.00
                        LastSignIn        = 'Never'
                        DaysSinceSignIn   = 'Never'
                        Finding           = 'NEVER SIGNED IN'
                    }
                )
            } -ModuleName M365-LicenseOptimizer
        }

        It 'Should return a savings report with multiple categories' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $report.Count | Should -BeGreaterOrEqual 5
        }

        It 'Should include a Current Spend row' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $spend = $report | Where-Object { $_.Category -eq 'Current Spend' }
            $spend | Should -Not -BeNullOrEmpty
            $spend.MonthlySavings | Should -BeGreaterThan 0
        }

        It 'Should include an Inactive Users row with correct count' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $row = $report | Where-Object { $_.Category -eq 'Inactive Users' }
            $row | Should -Not -BeNullOrEmpty
            $row.Count | Should -Be 1
        }

        It 'Should include a Disabled Accounts row' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $row = $report | Where-Object { $_.Category -eq 'Disabled Accounts' }
            $row | Should -Not -BeNullOrEmpty
            $row.Count | Should -Be 1
        }

        It 'Should include a License Downgrades row with correct count' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $row = $report | Where-Object { $_.Category -eq 'License Downgrades' }
            $row | Should -Not -BeNullOrEmpty
            $row.Count | Should -Be 2
        }

        It 'Should calculate annual savings as monthly times 12' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $total = $report | Where-Object { $_.Category -eq 'TOTAL POTENTIAL SAVINGS' }
            $total | Should -Not -BeNullOrEmpty
            $total.AnnualSavings | Should -Be ([math]::Round($total.MonthlySavings * 12, 2))
        }

        It 'Should have total savings greater than zero' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $total = $report | Where-Object { $_.Category -eq 'TOTAL POTENTIAL SAVINGS' }
            $total.MonthlySavings | Should -BeGreaterThan 0
            $total.AnnualSavings | Should -BeGreaterThan 0
        }

        It 'Should correctly sum inactive user savings' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $inactive = $report | Where-Object { $_.Category -eq 'Inactive Users' }
            # One inactive user at $57/mo
            $inactive.MonthlySavings | Should -Be 57.00
        }

        It 'Should correctly sum downgrade savings' {
            $report = Get-LicenseSavingsReport -DaysInactive 90
            $downgrades = $report | Where-Object { $_.Category -eq 'License Downgrades' }
            # Two downgrade candidates at $21/mo each = $42
            $downgrades.MonthlySavings | Should -Be 42.00
        }
    }
}