FindObject.Tests.ps1

#Requires -Modules Pester

$modulePath = Join-Path $PSScriptRoot "FindObject.psm1"
Import-Module $modulePath -Force

Describe "Find-ObjectByName" {

    # ─── Basic matching ───────────────────────────────────────────────────────────

    Context "Basic Contains matching (default mode)" {
        It "matches a single keyword as substring" {
            $obj = [PSCustomObject]@{ Name = "testObject" }
            $obj | Find-ObjectByName "test" | Should -Be $obj
        }

        It "is case-insensitive by default" {
            $obj = [PSCustomObject]@{ Name = "TestObject" }
            $obj | Find-ObjectByName "testobject" | Should -Be $obj
        }

        It "does not match when keyword is absent" {
            $obj = [PSCustomObject]@{ Name = "hello" }
            $obj | Find-ObjectByName "xyz" | Should -BeNullOrEmpty
        }

        It "handles empty-name objects gracefully" {
            $obj = [PSCustomObject]@{ Name = "" }
            $obj | Find-ObjectByName "test" | Should -BeNullOrEmpty
        }
    }

    # ─── Logical operators ────────────────────────────────────────────────────────

    Context "Logical operators" {
        BeforeAll {
            $script:items = @(
                [PSCustomObject]@{ Name = "apple pie" }
                [PSCustomObject]@{ Name = "apple tart" }
                [PSCustomObject]@{ Name = "cherry pie" }
                [PSCustomObject]@{ Name = "banana bread" }
            )
        }

        It "implicit OR: bare keywords match any" {
            $res = $script:items | Find-ObjectByName "apple", "cherry"
            $res.Count | Should -Be 3
        }

        It "explicit OR via array syntax" {
            $res = $script:items | Find-ObjectByName "apple", "or", "banana"
            $res.Count | Should -Be 3
        }

        It "AND requires all keywords present" {
            $res = $script:items | Find-ObjectByName "apple", "and", "pie"
            $res.Count | Should -Be 1
            $res.Name | Should -Be "apple pie"
        }

        It "NOT excludes matching objects" {
            $res = $script:items | Find-ObjectByName "pie", "not", "cherry"
            $res.Count | Should -Be 1
            $res.Name | Should -Be "apple pie"
        }

        It "natural-language string: 'apple AND pie NOT tart'" {
            $res = $script:items | Find-ObjectByName "apple AND pie NOT tart"
            $res.Count | Should -Be 1
            $res.Name | Should -Be "apple pie"
        }

        It "natural-language string: 'apple OR cherry NOT tart'" {
            $res = $script:items | Find-ObjectByName "apple OR cherry NOT tart"
            $res.Count | Should -Be 2
        }

        It "throws on operator-only input" {
            { [PSCustomObject]@{ Name = "x" } | Find-ObjectByName "or" } | Should -Throw "*No valid search keywords*"
        }
    }

    # ─── Milestone 1: Core Lexer, Parser & Boolean AST Engine ─────────────────────

    Context "Milestone 1: Quoted Phrases (F1)" {
        BeforeAll {
            $script:appItems = @(
                [PSCustomObject]@{ Name = "Google Chrome Setup" }
                [PSCustomObject]@{ Name = "Google Setup" }
                [PSCustomObject]@{ Name = "Chrome Standalone Setup" }
                [PSCustomObject]@{ Name = "Tom and Jerry" }
                [PSCustomObject]@{ Name = 'He said "Hello" to me' }
                [PSCustomObject]@{ Name = "Microsoft Edge" }
            )
        }

        It "preserves double-quoted multi-word phrase with AND operator" {
            $res = $script:appItems | Find-ObjectByName '"Google Chrome" AND Setup'
            $res.Count | Should -Be 1
            $res.Name | Should -Be "Google Chrome Setup"
        }

        It "preserves single-quoted multi-word phrase with AND operator" {
            $res = $script:appItems | Find-ObjectByName "'Google Chrome' AND Setup"
            $res.Count | Should -Be 1
            $res.Name | Should -Be "Google Chrome Setup"
        }

        It "does not match when only part of a quoted phrase is present" {
            $res = $script:appItems | Find-ObjectByName '"Google Chrome"'
            $res.Count | Should -Be 1
            $res.Name | Should -Be "Google Chrome Setup"
            ($res | Where-Object { $_.Name -eq "Google Setup" }) | Should -BeNullOrEmpty
        }

        It "treats boolean keyword 'and' inside quotes as literal string text" {
            $res = $script:appItems | Find-ObjectByName '"Tom and Jerry"'
            $res.Count | Should -Be 1
            $res.Name | Should -Be "Tom and Jerry"
        }

        It "matches quoted phrase exactly in Exact mode without quote delimiters" {
            $target = [PSCustomObject]@{ Name = "Google Chrome" }
            $other = [PSCustomObject]@{ Name = "Google Chrome Setup" }
            $res = @($target, $other) | Find-ObjectByName '"Google Chrome"' -Mode Exact
            $res.Count | Should -Be 1
            $res.Name | Should -Be "Google Chrome"
        }

        It "handles escaped quotes within quoted strings" {
            $res = $script:appItems | Find-ObjectByName '"He said ""Hello"" to me"'
            $res.Count | Should -Be 1
            $res.Name | Should -Be 'He said "Hello" to me'
        }
    }

    Context "Milestone 1: Boolean Operators and Aliases (F2)" {
        BeforeAll {
            $script:fruits = @(
                [PSCustomObject]@{ Name = "apple pie" }
                [PSCustomObject]@{ Name = "apple tart" }
                [PSCustomObject]@{ Name = "cherry pie" }
                [PSCustomObject]@{ Name = "banana bread" }
            )
        }

        It "supports case-insensitive operator keywords: AND, and, aNd" {
            $res1 = $script:fruits | Find-ObjectByName "apple AND pie"
            $res2 = $script:fruits | Find-ObjectByName "apple and pie"
            $res3 = $script:fruits | Find-ObjectByName "apple aNd pie"
            $res1.Count | Should -Be 1
            $res2.Count | Should -Be 1
            $res3.Count | Should -Be 1
        }

        It "supports case-insensitive operator keywords: OR, or, Or" {
            $res1 = $script:fruits | Find-ObjectByName "apple OR cherry"
            $res2 = $script:fruits | Find-ObjectByName "apple or cherry"
            $res1.Count | Should -Be 3
            $res2.Count | Should -Be 3
        }

        It "supports operator symbol && for AND" {
            $res = $script:fruits | Find-ObjectByName "apple && pie"
            $res.Count | Should -Be 1
            $res.Name | Should -Be "apple pie"
        }

        It "supports operator symbol || for OR" {
            $res = $script:fruits | Find-ObjectByName "banana || cherry"
            $res.Count | Should -Be 2
        }

        It "supports operator symbol ! for NOT" {
            $res = $script:fruits | Find-ObjectByName "pie && !cherry"
            $res.Count | Should -Be 1
            $res.Name | Should -Be "apple pie"
        }

        It "supports PowerShell-style operator -and, -or, -not" {
            $res = $script:fruits | Find-ObjectByName "apple -and pie -not tart"
            $res.Count | Should -Be 1
            $res.Name | Should -Be "apple pie"
        }
    }

    Context "Milestone 1: Operator Precedence NOT > AND > OR (F3)" {
        BeforeAll {
            $script:triples = @(
                [PSCustomObject]@{ Name = "Apple" }
                [PSCustomObject]@{ Name = "Banana" }
                [PSCustomObject]@{ Name = "Cherry" }
                [PSCustomObject]@{ Name = "Banana Cherry" }
                [PSCustomObject]@{ Name = "Apple Banana" }
                [PSCustomObject]@{ Name = "Apple Cherry" }
                [PSCustomObject]@{ Name = "Apple Banana Cherry" }
                [PSCustomObject]@{ Name = "Date" }
            )
        }

        It "evaluates 'Apple OR Banana AND Cherry' as 'Apple OR (Banana AND Cherry)'" {
            $res = $script:triples | Find-ObjectByName "Apple OR Banana AND Cherry"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "Apple"
            $names | Should -Contain "Banana Cherry"
            $names | Should -Not -Contain "Banana"
            $names | Should -Not -Contain "Cherry"
            $names | Should -Not -Contain "Date"
            $res.Count | Should -Be 5
        }

        It "evaluates 'Apple AND Banana OR Cherry' as '(Apple AND Banana) OR Cherry'" {
            $res = $script:triples | Find-ObjectByName "Apple AND Banana OR Cherry"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "Apple Banana"
            $names | Should -Contain "Cherry"
            $names | Should -Not -Contain "Apple"
            $names | Should -Not -Contain "Banana"
            $names | Should -Not -Contain "Date"
            $res.Count | Should -Be 5
        }
    }

    Context "Milestone 1: Parentheses Grouping (F4)" {
        BeforeAll {
            $script:triples = @(
                [PSCustomObject]@{ Name = "Apple" }
                [PSCustomObject]@{ Name = "Banana" }
                [PSCustomObject]@{ Name = "Cherry" }
                [PSCustomObject]@{ Name = "Banana Cherry" }
                [PSCustomObject]@{ Name = "Apple Cherry" }
                [PSCustomObject]@{ Name = "Apple Banana Cherry" }
                [PSCustomObject]@{ Name = "Date" }
            )
        }

        It "evaluates '(Apple OR Banana) AND Cherry' overriding standard precedence" {
            $res = $script:triples | Find-ObjectByName "(Apple OR Banana) AND Cherry"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "Apple Cherry"
            $names | Should -Contain "Banana Cherry"
            $names | Should -Contain "Apple Banana Cherry"
            $names | Should -Not -Contain "Apple"
            $names | Should -Not -Contain "Banana"
            $names | Should -Not -Contain "Cherry"
            $names | Should -Not -Contain "Date"
            $res.Count | Should -Be 3
        }

        It "evaluates nested parentheses '((Apple OR Banana) AND (Cherry OR Date))'" {
            $items = @(
                [PSCustomObject]@{ Name = "Apple Cherry" }
                [PSCustomObject]@{ Name = "Banana Date" }
                [PSCustomObject]@{ Name = "Apple Elderberry" }
                [PSCustomObject]@{ Name = "Cherry Date" }
            )
            $res = $items | Find-ObjectByName "((Apple OR Banana) AND (Cherry OR Date))"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "Apple Cherry"
            $names | Should -Contain "Banana Date"
            $names | Should -Not -Contain "Apple Elderberry"
            $names | Should -Not -Contain "Cherry Date"
            $res.Count | Should -Be 2
        }

        It "throws a descriptive error on unclosed parenthesis" {
            { [PSCustomObject]@{ Name = "test" } | Find-ObjectByName "(Apple AND Banana" } | Should -Throw "*Unclosed parenthesis*"
        }
    }

    Context "Milestone 1: Unary NOT Evaluation (F5)" {
        BeforeAll {
            $script:procItems = @(
                [PSCustomObject]@{ Name = "helper.exe" }
                [PSCustomObject]@{ Name = "chrome.exe" }
                [PSCustomObject]@{ Name = "svchost.exe" }
                [PSCustomObject]@{ Name = "helper-service.exe" }
            )
        }

        It "evaluates leading unary 'NOT helper' correctly excluding matching items" {
            $res = $script:procItems | Find-ObjectByName "NOT helper"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "chrome.exe"
            $names | Should -Contain "svchost.exe"
            $names | Should -Not -Contain "helper.exe"
            $names | Should -Not -Contain "helper-service.exe"
            $res.Count | Should -Be 2
        }

        It "evaluates leading unary '!helper' symbol alias" {
            $res = $script:procItems | Find-ObjectByName "!helper"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "chrome.exe"
            $names | Should -Contain "svchost.exe"
            $names | Should -Not -Contain "helper.exe"
            $res.Count | Should -Be 2
        }

        It "evaluates leading unary '-not helper' PowerShell alias" {
            $res = $script:procItems | Find-ObjectByName "-not helper"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "chrome.exe"
            $names | Should -Contain "svchost.exe"
            $names | Should -Not -Contain "helper.exe"
            $res.Count | Should -Be 2
        }

        It "evaluates binary 'apple OR NOT cherry' correctly" {
            $fruitItems = @(
                [PSCustomObject]@{ Name = "apple" }
                [PSCustomObject]@{ Name = "banana" }
                [PSCustomObject]@{ Name = "cherry pie" }
                [PSCustomObject]@{ Name = "apple cherry" }
            )
            $res = $fruitItems | Find-ObjectByName "apple OR NOT cherry"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "apple"
            $names | Should -Contain "banana"
            $names | Should -Contain "apple cherry"
            $names | Should -Not -Contain "cherry pie"
            $res.Count | Should -Be 3
        }

        It "evaluates 'NOT (Banana OR Cherry)' with grouped exclusion" {
            $fruitItems = @(
                [PSCustomObject]@{ Name = "apple" }
                [PSCustomObject]@{ Name = "banana" }
                [PSCustomObject]@{ Name = "cherry" }
                [PSCustomObject]@{ Name = "date" }
            )
            $res = $fruitItems | Find-ObjectByName "NOT (Banana OR Cherry)"
            $names = @($res | ForEach-Object { $_.Name })
            $names | Should -Contain "apple"
            $names | Should -Contain "date"
            $names | Should -Not -Contain "banana"
            $names | Should -Not -Contain "cherry"
            $res.Count | Should -Be 2
        }
    }

    # ─── Matching modes ───────────────────────────────────────────────────────────

    Context "StartsWith mode" {
        It "matches only at the beginning" {
            $items = @(
                [PSCustomObject]@{ Name = "chrome.exe" }
                [PSCustomObject]@{ Name = "old-chrome.exe" }
            )
            $res = $items | Find-ObjectByName "chrome" -Mode StartsWith
            $res.Count | Should -Be 1
            $res.Name | Should -Be "chrome.exe"
        }
    }

    Context "Fuzzy mode" {
        It "matches non-contiguous subsequence" {
            $obj = [PSCustomObject]@{ Name = "FindObject.psm1" }
            $obj | Find-ObjectByName "fndobj" -Mode Fuzzy | Should -Be $obj
        }

        It "rejects out-of-order characters" {
            $obj = [PSCustomObject]@{ Name = "chrome.exe" }
            $obj | Find-ObjectByName "ehcrom" -Mode Fuzzy | Should -BeNullOrEmpty
        }

        It "matches single character pattern" {
            $obj = [PSCustomObject]@{ Name = "abc" }
            $obj | Find-ObjectByName "b" -Mode Fuzzy | Should -Be $obj
        }
    }

    Context "Exact mode" {
        It "matches only full string equality" {
            $items = @(
                [PSCustomObject]@{ Name = "chrome" }
                [PSCustomObject]@{ Name = "chrome.exe" }
            )
            $res = $items | Find-ObjectByName "chrome" -Mode Exact
            $res.Count | Should -Be 1
            $res.Name | Should -Be "chrome"
        }

        It "is case-insensitive by default" {
            $obj = [PSCustomObject]@{ Name = "Chrome" }
            $obj | Find-ObjectByName "chrome" -Mode Exact | Should -Be $obj
        }

        It "respects -CaseSensitive" {
            $obj = [PSCustomObject]@{ Name = "Chrome" }
            $obj | Find-ObjectByName "chrome" -Mode Exact -CaseSensitive | Should -BeNullOrEmpty
        }
    }

    Context "Regex mode" {
        It "supports .NET regex patterns" {
            $items = @(
                [PSCustomObject]@{ Name = "file001.log" }
                [PSCustomObject]@{ Name = "file002.txt" }
                [PSCustomObject]@{ Name = "readme.md" }
            )
            $res = $items | Find-ObjectByName 'file\d+\.log' -Mode Regex
            $res.Count | Should -Be 1
            $res.Name | Should -Be "file001.log"
        }

        It "is case-insensitive by default" {
            $obj = [PSCustomObject]@{ Name = "ERROR_LOG" }
            $obj | Find-ObjectByName "error" -Mode Regex | Should -Be $obj
        }
    }

    # ─── Property targeting ───────────────────────────────────────────────────────

    Context "Property targeting" {
        It "matches a custom -Property" {
            $items = @(
                [PSCustomObject]@{ Name = "n/a"; ProcessName = "svchost" }
                [PSCustomObject]@{ Name = "n/a"; ProcessName = "explorer" }
            )
            $res = $items | Find-ObjectByName "svchost" -Property ProcessName
            $res.Count | Should -Be 1
            $res.ProcessName | Should -Be "svchost"
        }

        It "-AsString matches the object's string form" {
            $res = "apple pie", "banana bread" | Find-ObjectByName "pie" -AsString
            $res | Should -Be "apple pie"
        }

        It "-Property * searches all string properties" {
            $items = @(
                [PSCustomObject]@{ Name = "alpha"; Description = "beta target" }
                [PSCustomObject]@{ Name = "gamma"; Description = "delta" }
            )
            $res = $items | Find-ObjectByName "target" -Property *
            $res.Count | Should -Be 1
            $res.Name | Should -Be "alpha"
        }

        It "skips objects missing the target property" {
            $obj = [PSCustomObject]@{ Value = "test" }
            $obj | Find-ObjectByName "test" | Should -BeNullOrEmpty
        }
    }

    # ─── Case sensitivity ─────────────────────────────────────────────────────────

    Context "Case sensitivity" {
        It "matches exact case with -CaseSensitive" {
            $items = @(
                [PSCustomObject]@{ Name = "Chrome.exe" }
                [PSCustomObject]@{ Name = "chrome.exe" }
            )
            $res = $items | Find-ObjectByName "chrome" -CaseSensitive
            $res.Count | Should -Be 1
            $res.Name | Should -Be "chrome.exe"
        }
    }

    # ─── Result limiting ──────────────────────────────────────────────────────────

    Context "Result limiting (-First, -Skip)" {
        BeforeAll {
            $script:manyItems = 1..100 | ForEach-Object { [PSCustomObject]@{ Name = "item$_" } }
        }

        It "-First N stops after N matches" {
            $res = $script:manyItems | Find-ObjectByName "item" -First 5
            $res.Count | Should -Be 5
        }

        It "-Skip N discards the first N matches" {
            $res = $script:manyItems | Find-ObjectByName "item" -Skip 95
            $res.Count | Should -Be 5
        }

        It "-First and -Skip combine for paging" {
            $res = $script:manyItems | Find-ObjectByName "item" -Skip 10 -First 5
            $res.Count | Should -Be 5
            $res[0].Name | Should -Be "item11"
        }
    }

    # ─── Count and Quiet ──────────────────────────────────────────────────────────

    Context "Count and Quiet switches" {
        BeforeAll {
            $script:items = @(
                [PSCustomObject]@{ Name = "apple" }
                [PSCustomObject]@{ Name = "banana" }
                [PSCustomObject]@{ Name = "apricot" }
            )
        }

        It "-Count returns the number of matches" {
            $res = $script:items | Find-ObjectByName "ap" -Count
            $res | Should -Be 2
            $res | Should -BeOfType [int]
        }

        It "-Quiet returns `$true when matches exist" {
            $res = $script:items | Find-ObjectByName "banana" -Quiet
            $res | Should -Be $true
        }

        It "-Quiet returns `$false when no matches" {
            $res = $script:items | Find-ObjectByName "xyz" -Quiet
            $res | Should -Be $false
        }
    }

    # ─── Null / edge cases ────────────────────────────────────────────────────────

    Context "Edge cases" {
        It "handles null pipeline input" {
            $null | Find-ObjectByName "test" | Should -BeNullOrEmpty
        }

        It "handles objects with null Name" {
            $obj = [PSCustomObject]@{ Name = $null }
            $obj | Find-ObjectByName "test" | Should -BeNullOrEmpty
        }

        It "handles whitespace-only Name" {
            $obj = [PSCustomObject]@{ Name = " " }
            $obj | Find-ObjectByName "test" | Should -BeNullOrEmpty
        }
    }

    # ─── Configuration ────────────────────────────────────────────────────────────

    Context "Configuration" {
        AfterEach {
            Set-FindObjectConfig -Mode Contains | Out-Null
        }

        It "uses config default Mode when -Mode not specified" {
            Set-FindObjectConfig -Mode Fuzzy | Out-Null
            $obj = [PSCustomObject]@{ Name = "chrome.exe" }
            $obj | Find-ObjectByName "crome" | Should -Be $obj
        }

        It "Get-FindObjectConfig reflects changes" {
            Set-FindObjectConfig -Mode StartsWith | Out-Null
            (Get-FindObjectConfig).Mode | Should -Be 'StartsWith'
        }
    }

    # ─── Performance sanity check ─────────────────────────────────────────────────

    Context "Performance" {
        It "processes 50k objects in under 10 seconds (Contains mode)" {
            $data = 1..50000 | ForEach-Object { [PSCustomObject]@{ Name = "ProcessName_$_" } }
            $sw = [System.Diagnostics.Stopwatch]::StartNew()
            $null = $data | Find-ObjectByName "ProcessName_25000"
            $sw.Stop()
            $sw.Elapsed.TotalSeconds | Should -BeLessThan 10
        }

        It "-First 1 terminates early on large input" {
            $data = 1..50000 | ForEach-Object { [PSCustomObject]@{ Name = "item_$_" } }
            $sw = [System.Diagnostics.Stopwatch]::StartNew()
            $res = $data | Find-ObjectByName "item_1" -First 1
            $sw.Stop()
            $res.Count | Should -Be 1
            $sw.Elapsed.TotalSeconds | Should -BeLessThan 2
        }
    }
}

Describe "Microsoft Graph / Intune Integration" {
    BeforeAll {
        $intunePath = Join-Path $PSScriptRoot "FindObject.Intune.psd1"
        if (-not (Test-Path $intunePath)) {
            $intunePath = Join-Path $PSScriptRoot "FindObject.Intune.psm1"
        }
        Import-Module $intunePath -Force
    }

    Context "Module exports" {
        It "exports Connect-FindObjectGraph" {
            Get-Command Connect-FindObjectGraph -Module FindObject.Intune | Should -Not -BeNullOrEmpty
        }

        It "exports Disconnect-FindObjectGraph" {
            Get-Command Disconnect-FindObjectGraph -Module FindObject.Intune | Should -Not -BeNullOrEmpty
        }

        It "exports all Get-Intune* cmdlets" {
            $expected = @('Get-IntuneDevice', 'Get-IntuneApp', 'Get-IntuneCompliancePolicy',
                'Get-IntuneConfigProfile', 'Get-IntuneAutopilotDevice', 'Get-IntuneEnrollment')
            foreach ($cmd in $expected) {
                Get-Command $cmd -Module FindObject.Intune | Should -Not -BeNullOrEmpty
            }
        }

        It "exports the 'gid' alias for Get-IntuneDevice" {
            (Get-Alias gid -ErrorAction SilentlyContinue).Definition | Should -Be 'Get-IntuneDevice'
        }
    }

    Context "Auth guard" {
        BeforeAll {
            Disconnect-FindObjectGraph
        }

        It "Get-IntuneDevice throws when not connected" {
            { Get-IntuneDevice } | Should -Throw "*Connect-FindObjectGraph*"
        }

        It "Get-IntuneApp throws when not connected" {
            { Get-IntuneApp } | Should -Throw "*Connect-FindObjectGraph*"
        }

        It "Get-IntuneCompliancePolicy throws when not connected" {
            { Get-IntuneCompliancePolicy } | Should -Throw "*Connect-FindObjectGraph*"
        }
    }

    Context "Disconnect clears state" {
        It "Disconnect-FindObjectGraph runs without error" {
            { Disconnect-FindObjectGraph } | Should -Not -Throw
        }
    }
}