Tests/RuleEngine.Tests.ps1

BeforeAll {
    $moduleRoot = Split-Path -Parent $PSScriptRoot
    # Import the .psm1 directly (not the manifest) so the tests run fully
    # offline, without Microsoft.Graph.Authentication installed.
    Import-Module (Join-Path $moduleRoot 'EntraGroupInsights.psm1') -Force
}

Describe 'Get-EGIDynamicGroupRuleTree (parser)' {

    It 'parses a single leaf expression' {
        $tree = (Get-EGIDynamicGroupRuleTree -Rule 'user.department -eq "Sales"').Tree
        $tree.Type | Should -Be 'Leaf'
        $tree.Expression | Should -Be 'user.department -eq "Sales"'
    }

    It 'parses an -or expression' {
        $tree = (Get-EGIDynamicGroupRuleTree -Rule '(user.department -eq "Sales") -or (user.department -eq "Marketing")').Tree
        $tree.Type | Should -Be 'Or'
        $tree.Children.Count | Should -Be 2
    }

    It 'parses -and -not with correct precedence' {
        $tree = (Get-EGIDynamicGroupRuleTree -Rule '(user.department -eq "Sales") -and -not (user.jobTitle -startsWith "SDE")').Tree
        $tree.Type | Should -Be 'And'
        $tree.Children[1].Type | Should -Be 'Not'
    }

    It 'keeps an -any(...) lambda body as a single leaf' {
        $tree = (Get-EGIDynamicGroupRuleTree -Rule 'user.otherMails -any (_ -contains "contoso")').Tree
        $tree.Type | Should -Be 'Leaf'
        $tree.Expression | Should -Match '-any'
    }

    It 'keeps parens inside quoted lambda values out of the depth count' {
        $tree = (Get-EGIDynamicGroupRuleTree -Rule 'user.otherMails -any (_ -contains "a(b")').Tree
        $tree.Type | Should -Be 'Leaf'
        $tree.Expression | Should -Match '\-any'
    }

    It 'warns when -and and -or are mixed without parentheses' {
        $null = Get-EGIDynamicGroupRuleTree -Rule 'user.a -eq "1" -or user.b -eq "2" -and user.c -eq "3"' `
            -WarningVariable warnings -WarningAction SilentlyContinue
        @($warnings).Count | Should -BeGreaterThan 0
        "$warnings" | Should -Match 'parenthes'
    }

    It 'does not warn when the mix is fully parenthesized' {
        $null = Get-EGIDynamicGroupRuleTree -Rule '(user.a -eq "1" -or user.b -eq "2") -and user.c -eq "3"' `
            -WarningVariable warnings -WarningAction SilentlyContinue
        @($warnings).Count | Should -Be 0
    }

    It 'throws on unbalanced parentheses' {
        { Get-EGIDynamicGroupRuleTree -Rule '(user.a -eq "1"' } | Should -Throw
    }
}

Describe 'Test-EGIDynamicGroupRule' {

    BeforeAll {
        $script:users = @(
            [pscustomobject]@{ Id = 'u1'; DisplayName = 'Alice'; Department = 'Sales'; Country = 'DE' }
            [pscustomobject]@{ Id = 'u2'; DisplayName = 'Bob'; Department = 'Marketing'; Country = 'DE' }
            [pscustomobject]@{ Id = 'u3'; DisplayName = 'Carol'; Department = 'Sales'; Country = 'US' }
        )
    }

    It 'matches only users satisfying an AND rule' {
        $result = Test-EGIDynamicGroupRule -Rule '(user.department -eq "Sales") -and (user.country -eq "DE")' -Users $script:users -PassThru
        $result.DisplayName | Should -Be 'Alice'
    }

    It '-PassThru returns the original user objects, not report rows' {
        $result = Test-EGIDynamicGroupRule -Rule '(user.department -eq "Sales") -and (user.country -eq "DE")' -Users $script:users -PassThru
        $result.Id | Should -Be 'u1'
        $result.Country | Should -Be 'DE'
    }

    It 'matches users satisfying an OR rule' {
        $result = Test-EGIDynamicGroupRule -Rule '(user.department -eq "Sales") -or (user.department -eq "Marketing")' -Users $script:users -PassThru
        $result.Count | Should -Be 3
    }

    It 'includes the user Id in the report rows' {
        $result = Test-EGIDynamicGroupRule -Rule 'user.department -eq "Sales"' -Users $script:users
        ($result | Where-Object Matches).Id | Should -Be @('u1', 'u3')
    }

    It 'reports an error for unsupported constructs instead of a wrong result' {
        $result = Test-EGIDynamicGroupRule -Rule 'user.employeehiredate -ge system.now -plus p1d' -Users $script:users -WarningAction SilentlyContinue
        $result | Where-Object { $_.Error } | Should -Not -BeNullOrEmpty
    }

    It 'supports hashtable user objects' {
        $hashUsers = @(
            @{ Id = 'h1'; DisplayName = 'Hank'; Department = 'Sales'; Country = 'DE' }
        )
        $result = Test-EGIDynamicGroupRule -Rule 'user.department -eq "Sales"' -Users $hashUsers
        $result.DisplayName | Should -Be 'Hank'
        $result.Matches | Should -BeTrue
    }

    It 'evaluates -in and -notIn against list literals' {
        $result = Test-EGIDynamicGroupRule -Rule 'user.department -in ["Sales", "Marketing"]' -Users $script:users -PassThru
        $result.Count | Should -Be 3

        $result = Test-EGIDynamicGroupRule -Rule 'user.department -notIn ["Sales"]' -Users $script:users -PassThru
        $result.DisplayName | Should -Be 'Bob'
    }

    It 'keeps quoted commas inside -in list values intact' {
        $users = @([pscustomobject]@{ DisplayName = 'Rita'; Department = 'R, D' })
        $result = Test-EGIDynamicGroupRule -Rule 'user.department -in ["R, D", "Sales"]' -Users $users -PassThru
        $result.DisplayName | Should -Be 'Rita'
    }

    It 'evaluates -any over a collection property' {
        $users = @(
            [pscustomobject]@{ DisplayName = 'Mia'; OtherMails = @('mia@contoso.com', 'mia@fabrikam.com') }
            [pscustomobject]@{ DisplayName = 'Ned'; OtherMails = @('ned@fabrikam.com') }
        )
        $result = Test-EGIDynamicGroupRule -Rule 'user.otherMails -any (_ -contains "contoso")' -Users $users -PassThru
        $result.DisplayName | Should -Be 'Mia'
    }

    It 'evaluates extension attribute properties' {
        $users = @(
            [pscustomobject]@{ DisplayName = 'Eve'; extension_abc123_costCenter = '42' }
            [pscustomobject]@{ DisplayName = 'Sam'; extension_abc123_costCenter = '7' }
        )
        $result = Test-EGIDynamicGroupRule -Rule 'user.extension_abc123_costCenter -eq "42"' -Users $users -PassThru
        $result.DisplayName | Should -Be 'Eve'
    }

    It 'treats wildcard characters in values as literals' {
        $users = @(
            [pscustomobject]@{ DisplayName = '[Ext] Dave'; Department = 'Ops' }
            [pscustomobject]@{ DisplayName = 'Erin'; Department = 'Ops' }
        )
        $result = Test-EGIDynamicGroupRule -Rule 'user.displayName -startsWith "[Ext]"' -Users $users -PassThru
        $result.DisplayName | Should -Be '[Ext] Dave'

        $result = Test-EGIDynamicGroupRule -Rule 'user.displayName -contains "[Ext]"' -Users $users -PassThru
        $result.DisplayName | Should -Be '[Ext] Dave'
    }

    It 'treats null properties as non-matching for positive string operators' {
        $users = @([pscustomobject]@{ DisplayName = 'NoDept'; Department = $null })
        $result = Test-EGIDynamicGroupRule -Rule 'user.department -startsWith "Sal"' -Users $users
        $result.Matches | Should -BeFalse
    }
}