Tests/GraphShell.Tests.ps1

#Requires -Modules Pester

<#
    Pester tests for the GraphShell PowerShell module.
    Run locally with:
        Invoke-Pester -Path .\powershell\GraphShell\Tests\GraphShell.Tests.ps1 -Output Detailed
#>


BeforeAll {
    $script:ModuleRoot = Split-Path -Parent $PSScriptRoot
    $script:ManifestPath = Join-Path $ModuleRoot 'GraphShell.psd1'

    Remove-Module GraphShell -ErrorAction SilentlyContinue
    Import-Module $ManifestPath -Force -ErrorAction Stop
}

AfterAll {
    Remove-Module GraphShell -ErrorAction SilentlyContinue
}

Describe 'GraphShell module packaging' {
    It 'imports without error' {
        { Import-Module $script:ManifestPath -Force } | Should -Not -Throw
    }

    It 'has a valid module manifest' {
        { Test-ModuleManifest -Path $script:ManifestPath -ErrorAction Stop } | Should -Not -Throw
    }

    It 'exports all six public GraphShell commands' {
        $exported = (Get-Command -Module GraphShell).Name | Sort-Object
        $exported | Should -Be @('Get-GraphConnectionCommand', 'Get-GraphDetail', 'Get-GraphLegacyMapping', 'Get-GraphMapping', 'Get-GraphParity', 'Get-GraphPermission')
    }

    It 'declares the GraphShell/Microsoft.Graph distinction in the manifest description' {
        $manifest = Test-ModuleManifest -Path $script:ManifestPath
        $manifest.Description | Should -Match 'does not replace the Microsoft Graph PowerShell SDK'
    }
}

Describe 'Get-GraphMapping -Cmdlet' {
    It 'resolves a known cmdlet to its REST relationship' {
        $result = Get-GraphMapping -Cmdlet 'Get-MgUser'
        $result | Should -Not -BeNullOrEmpty
        ($result | Where-Object Endpoint -eq '/users').Method | Should -Be 'GET'
        $result[0].PSObject.TypeNames | Should -Contain 'GraphShell.Mapping'
    }

    It 'resolves a renamed cmdlet to the current cmdlet' {
        $result = Get-GraphMapping -Cmdlet 'Remove-MgApplicationOwnerByRef'
        $result | Should -Not -BeNullOrEmpty
        $result[0].Cmdlet | Should -Be 'Remove-MgApplicationOwnerDirectoryObjectByRef'
        $result[0].RenamedFrom | Should -Be 'Remove-MgApplicationOwnerByRef'
    }

    It 'returns nothing for a cmdlet outside the Microsoft Graph SDK' {
        Get-GraphMapping -Cmdlet 'Get-AzRoleAssignment' | Should -BeNullOrEmpty
    }
}

Describe 'Get-GraphMapping -Endpoint' {
    It 'resolves a known REST endpoint to its cmdlet(s)' {
        $result = Get-GraphMapping -Endpoint '/users'
        $result | Should -Not -BeNullOrEmpty
        ($result.Cmdlet -contains 'Get-MgUser') | Should -Be $true
    }
}

Describe 'Get-GraphMapping -Permission' {
    It 'uses the permission reverse index to list related operations' {
        $result = Get-GraphMapping -Permission 'User.Read.All'
        $result | Should -Not -BeNullOrEmpty
        $result[0].MatchedPermission | Should -Be 'User.Read.All'
        ($result.Cmdlet -contains 'Get-MgUser') | Should -Be $true
    }

    It 'returns nothing for an unknown permission' {
        Get-GraphMapping -Permission 'Not.A.Real.Permission' | Should -BeNullOrEmpty
    }
}

Describe 'Get-GraphMapping -Module' {
    It 'returns operations for a known module name' {
        $result = Get-GraphMapping -Module 'Microsoft.Graph.Users'
        $result | Should -Not -BeNullOrEmpty
        ($result.ModuleName -contains 'Microsoft.Graph.Users') | Should -Be $true
    }

    It 'resolves commands from device, backup and identity modules' {
        $samples = @(
            @{ Module = 'Microsoft.Graph.DeviceManagement'; Cmdlet = 'Get-MgDeviceManagementManagedDevice' },
            @{ Module = 'Microsoft.Graph.BackupRestore'; Cmdlet = 'Get-MgSolutionBackupRestore' },
            @{ Module = 'Microsoft.Graph.Identity.DirectoryManagement'; Cmdlet = 'Get-MgDevice' }
        )

        foreach ($sample in $samples) {
            $moduleResults = @(Get-GraphMapping -Module $sample.Module)
            $moduleResults.ModuleName | Should -Contain $sample.Module

            $cmdletResults = @(Get-GraphMapping -Cmdlet $sample.Cmdlet)
            $cmdletResults.ModuleName | Should -Contain $sample.Module
        }
    }

    It 'resolves every module in the generated module index' {
        $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
        $moduleNamesPath = Join-Path $repoRoot 'data\graph-command-by-module-names.json'
        $moduleIndex = Get-Content -LiteralPath $moduleNamesPath -Raw | ConvertFrom-Json

        foreach ($entry in $moduleIndex.modules) {
            $result = @(Get-GraphMapping -Module $entry.moduleName)
            $result.ModuleName | Should -Contain $entry.moduleName
        }
    }
}

Describe 'Get-GraphMapping -Query' {
    It 'finds PIM-eligible role operations from a free-text concept' {
        $result = Get-GraphMapping -Query 'PIM eligible'
        $result | Should -Not -BeNullOrEmpty
        ($result | Where-Object Cmdlet -like '*RoleEligibilitySchedule*') | Should -Not -BeNullOrEmpty
    }

    It 'prioritizes records covering all concepts over unrelated compound-word matches' {
        $topResults = @(Get-GraphMapping -Query 'group membership' | Select-Object -First 10)

        $topResults.Cmdlet | Should -Contain 'Get-MgGroupMember'
        $topResults.ModuleName | Should -Not -Match 'DeviceManagement'
    }
}

Describe 'Get-GraphMapping output shape' {
    It 'returns real PowerShell objects usable with Select-Object/Where-Object' {
        $result = Get-GraphMapping -Permission 'User.Read.All' |
            Where-Object ApiVersion -eq 'v1.0' |
            Select-Object -First 1 Cmdlet, Module, Method, Endpoint
        $result | Should -Not -BeNullOrEmpty
        $result.PSObject.Properties.Name | Should -Contain 'Cmdlet'
    }
}

Describe 'Get-GraphDetail' {
    It 'loads only the necessary detail shard for a cmdlet' {
        $result = Get-GraphDetail -Name 'Get-MgUser'
        $result | Should -Not -BeNullOrEmpty
        ($result | Where-Object Endpoint -eq '/users').Permissions | Should -Not -BeNullOrEmpty
        $result[0].DetailShard | Should -Be 'g'
    }

    It 'skips generating a REST equivalent when the endpoint needs a path parameter' {
        $result = Get-GraphDetail -Name 'Get-MgUser' | Where-Object Endpoint -eq '/users/{user-id}'
        $result.RestEquivalent | Should -BeNullOrEmpty
        $result.RestNote | Should -Match 'path parameter'
    }

    It 'generates an Invoke-MgGraphRequest equivalent when the endpoint has no path parameters' {
        $result = Get-GraphDetail -Name 'Get-MgUser' | Where-Object Endpoint -eq '/users'
        $result.RestEquivalent | Should -Match 'Invoke-MgGraphRequest -Method GET'
    }

    It 'accepts pipeline input from Get-GraphMapping' {
        $result = Get-GraphMapping -Cmdlet 'Get-MgUser' | Select-Object -First 1 | Get-GraphDetail
        $result | Should -Not -BeNullOrEmpty
    }

    It 'warns and returns nothing for an unknown cmdlet' {
        $warnings = @()
        Get-GraphDetail -Name 'Get-MgDoesNotExist' -WarningVariable warnings -WarningAction SilentlyContinue |
            Should -BeNullOrEmpty
        $warnings.Count | Should -BeGreaterThan 0
    }
}

Describe 'GraphShell catalog cache' {
    It 'reuses the cached index across calls instead of reloading the file' {
        InModuleScope GraphShell {
            $script:GraphCatalogCache = $null
            $first = Get-GraphCatalogIndex
            $second = Get-GraphCatalogIndex
            [object]::ReferenceEquals($first, $second) | Should -Be $true
        }
    }
}

Describe 'Get-GraphParity' {
    It 'reports SdkAndOpenApi for an endpoint that has both a cmdlet and an OpenAPI operation' {
        $result = Get-GraphParity -Endpoint '/users' -Method GET
        $result | Should -Not -BeNullOrEmpty
        $result.State | Should -Be 'SdkAndOpenApi'
        $result.HasCmdlet | Should -Be $true
        $result.Cmdlets | Should -Contain 'Get-MgUser'
        $result.PSObject.TypeNames | Should -Contain 'GraphShell.Parity'
    }

    It 'reports OpenApiOnly for an endpoint the Graph API exposes without a matching cmdlet' {
        $result = Get-GraphParity -Endpoint '/applications/{id}/appmanagementpolicies' -Method DELETE
        $result | Should -Not -BeNullOrEmpty
        $result.State | Should -Be 'OpenApiOnly'
        $result.HasCmdlet | Should -Be $false
        $result.EstimatedModule | Should -Be 'Applications'
    }

    It 'reports SdkOnly for a cmdlet-backed endpoint that has no matching OpenAPI operation' {
        $result = Get-GraphParity -Endpoint '/directory/impactedResources/addTag' -Method POST
        $result | Should -Not -BeNullOrEmpty
        $result.State | Should -Be 'SdkOnly'
        $result.HasCmdlet | Should -Be $true
    }

    It 'warns and returns nothing for an endpoint unknown to both the SDK and the Graph API' {
        $warnings = @()
        Get-GraphParity -Endpoint '/this/does/not/exist/anywhere' -WarningVariable warnings -WarningAction SilentlyContinue |
            Should -BeNullOrEmpty
        $warnings.Count | Should -BeGreaterThan 0
    }

    It 'accepts pipeline input from Get-GraphMapping' {
        $result = Get-GraphMapping -Cmdlet 'Get-MgUser' | Get-GraphParity
        $result | Should -Not -BeNullOrEmpty
        $result | ForEach-Object { $_.State | Should -Be 'SdkAndOpenApi' }
    }

    It 'reports parity for every endpoint a cmdlet maps to when using -Cmdlet' {
        $result = Get-GraphParity -Cmdlet 'Get-MgUser'
        $result | Should -Not -BeNullOrEmpty
        $result.Count | Should -BeGreaterThan 1
        $result | ForEach-Object { $_.HasCmdlet | Should -Be $true }
        $result | ForEach-Object { $_.PSObject.TypeNames | Should -Contain 'GraphShell.Parity' }
    }

    It 'binds -Cmdlet from a pipeline object exposing a Cmdlet property' {
        $result = [pscustomobject]@{ Cmdlet = 'Get-MgUser' } | Get-GraphParity
        $result | Should -Not -BeNullOrEmpty
        ($result.Cmdlets | Select-Object -Unique) | Should -Contain 'Get-MgUser'
    }

    It 'warns and returns nothing for a cmdlet outside the catalog when using -Cmdlet' {
        $warnings = @()
        Get-GraphParity -Cmdlet 'Get-ThisCmdletDoesNotExist' -WarningVariable warnings -WarningAction SilentlyContinue |
            Should -BeNullOrEmpty
        $warnings.Count | Should -BeGreaterThan 0
    }
}

Describe 'Get-GraphLegacyMapping' {
    It 'resolves a legacy AzureAD command to its current Mg command' {
        $result = Get-GraphLegacyMapping -Command 'Get-AzureADUser'
        $result | Should -Not -BeNullOrEmpty
        $result | ForEach-Object { $_.GraphCommand | Should -Be 'Get-MgUser' }
        $result | ForEach-Object { $_.Status | Should -Be 'Mapped' }
        $result[0].PSObject.TypeNames | Should -Contain 'GraphShell.LegacyMapping'
    }

    It 'resolves a legacy MSOnline command to its current Mg command' {
        $result = Get-GraphLegacyMapping -Command 'Get-MsolUser'
        $result | Should -Not -BeNullOrEmpty
        $result | ForEach-Object { $_.GraphCommand | Should -Be 'Get-MgUser' }
    }

    It 'reports AmbiguousMapped when the official source lists more than one current command' {
        $result = Get-GraphLegacyMapping -Command 'Get-MsolDirSyncProvisioningError'
        $result | Should -Not -BeNullOrEmpty
        ($result | Select-Object -ExpandProperty GraphCommand -Unique).Count | Should -BeGreaterThan 1
        $result | ForEach-Object { $_.Status | Should -Be 'AmbiguousMapped' }
    }

    It 'includes permissions only when -IncludeDetails is used' {
        $withoutDetails = Get-GraphLegacyMapping -Command 'Get-AzureADUser' | Select-Object -First 1
        $withoutDetails.Permissions | Should -BeNullOrEmpty

        $withDetails = Get-GraphLegacyMapping -Command 'Get-AzureADUser' -IncludeDetails | Select-Object -First 1
        $withDetails.Permissions | Should -Not -BeNullOrEmpty
    }

    It 'warns and returns nothing for a command outside the official legacy mapping' {
        $warnings = @()
        Get-GraphLegacyMapping -Command 'Get-DoesNotExist' -WarningVariable warnings -WarningAction SilentlyContinue |
            Should -BeNullOrEmpty
        $warnings.Count | Should -BeGreaterThan 0
    }
}

Describe 'Get-GraphPermission' {
    It 'returns both the Application and Delegated definitions for a dual-type permission' {
        $result = Get-GraphPermission -Name 'User.Read.All'
        $result | Should -Not -BeNullOrEmpty
        ($result | Select-Object -ExpandProperty Type | Sort-Object -Unique) | Should -Be @('Application', 'Delegated')
        $result | ForEach-Object { $_.Id | Should -Not -BeNullOrEmpty }
        $result[0].PSObject.TypeNames | Should -Contain 'GraphShell.Permission'
    }

    It 'filters to a single type with -Type' {
        $result = Get-GraphPermission -Name 'User.Read.All' -Type Application
        $result | Should -Not -BeNullOrEmpty
        $result | ForEach-Object { $_.Type | Should -Be 'Application' }
    }

    It 'reports the related-operation count without duplicating the permission reverse index' {
        $result = Get-GraphPermission -Name 'User.Read.All' -Type Application
        $result.Operations | Should -BeGreaterThan 0
        $result.Cmdlets | Should -BeNullOrEmpty
    }

    It 'includes cmdlets and endpoints only with -IncludeOperations' {
        $result = Get-GraphPermission -Name 'User.Read.All' -Type Application -IncludeOperations
        $result.Cmdlets | Should -Not -BeNullOrEmpty
        $result.Endpoints | Should -Not -BeNullOrEmpty
    }

    It 'warns and returns nothing for a permission outside the official catalog' {
        $warnings = @()
        Get-GraphPermission -Name 'Not.A.Real.Permission' -WarningVariable warnings -WarningAction SilentlyContinue |
            Should -BeNullOrEmpty
        $warnings.Count | Should -BeGreaterThan 0
    }

    It 'accepts a permission name over the pipeline from Get-GraphMapping -Permission' {
        $permissionName = Get-GraphMapping -Permission 'User.Read.All' | Select-Object -First 1 -ExpandProperty MatchedPermission
        $result = $permissionName | Get-GraphPermission
        $result | Should -Not -BeNullOrEmpty
    }
}

Describe 'Endpoint shard cache boundaries' {
    It 'does not retain shards loaded only for a partial endpoint scan' {
        InModuleScope GraphShell {
            $script:GraphEndpointShardCache = $null
            Mock Get-GraphAllEndpointSegments { @('users','groups') }
            Get-GraphMapping -Endpoint '/__unmapped_audit_route__' -WarningAction SilentlyContinue | Out-Null
            $script:GraphEndpointShardCache.Count | Should -Be 0
            Get-GraphEndpointShard -Segment users | Out-Null
            $script:GraphEndpointShardCache.Count | Should -Be 1
        }
    }
}

Describe 'Exact and approximate parity' {
    It 'keeps count operations distinct in exact mode and preserves the approximate default' {
        InModuleScope GraphShell {
            Mock Get-GraphParityIndex {
                $methods = [System.Collections.Generic.Dictionary[string,int]]::new()
                $methods['GET'] = 1
                $approx = [System.Collections.Generic.Dictionary[string,object]]::new()
                $approx['users'] = $methods
                $exact = [System.Collections.Generic.Dictionary[string,object]]::new()
                $exact['users'] = $methods
                $exact['users/$count'] = $methods
                [pscustomobject]@{ Presence=$approx; ExactPresence=$exact; ModuleHints=[System.Collections.Generic.Dictionary[string,string]]::new() }
            }
            Mock Get-GraphEndpointShard {
                $uris = [System.Collections.Generic.Dictionary[string,object]]::new()
                $uris['users'] = @([pscustomobject]@{ command='Get-MgUser'; method='GET'; apiVersion='v1.0' })
                $uris['users/$count'] = @([pscustomobject]@{ command='Get-MgUserCount'; method='GET'; apiVersion='v1.0' })
                [pscustomobject]@{ ByUri=$uris }
            }
            $exact = Get-GraphParity -Endpoint '/users/$count' -Method GET -Exact
            $exact.Comparison | Should -Be 'Exact'
            $exact.Cmdlets | Should -Contain 'Get-MgUserCount'
            $exact.Cmdlets | Should -Not -Contain 'Get-MgUser'
            $approx = Get-GraphParity -Endpoint '/users' -Method GET
            $approx.Comparison | Should -Be 'Approximate'
            $approx.Cmdlets | Should -Contain 'Get-MgUserCount'
            $approx.Cmdlets | Should -Contain 'Get-MgUser'
        }
    }
    It 'never presents legacy approximate OpenAPI data as exact evidence' {
        InModuleScope GraphShell {
            Mock Get-GraphParityIndex {
                [pscustomobject]@{ Presence=[System.Collections.Generic.Dictionary[string,object]]::new(); ExactPresence=$null }
            }
            $result = Get-GraphParity -Endpoint '/users/$count' -Method GET -Exact
            $result.State | Should -Be 'SdkCatalogOnly'
            $result.OpenApiEvidence | Should -Be 'Unavailable'
            $result.Cmdlets | Should -Contain 'Get-MgUserCount'
            $result.Cmdlets | Should -Not -Contain 'Get-MgUser'
        }
    }
}