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 } } } |