Tests/StructMatcher.Tests.ps1
|
$operatorCases = @( @{ Name = "Equals"; Operator = "Equals"; Path = @("person", "department"); Check = "Finance" } @{ Name = "NotEquals"; Operator = "NotEquals"; Path = @("person", "department"); Check = "IT" } @{ Name = "Contains"; Operator = "Contains"; Path = @("person", "roles"); Check = "Approver" } @{ Name = "NotContains"; Operator = "NotContains"; Path = @("person", "roles"); Check = "Guest" } @{ Name = "In"; Operator = "In"; Path = @("person", "department"); Check = '["HR","Finance"]' } @{ Name = "NotIn"; Operator = "NotIn"; Path = @("person", "department"); Check = '["HR","IT"]' } @{ Name = "Like"; Operator = "Like"; Path = @("person", "name"); Check = "John*" } @{ Name = "NotLike"; Operator = "NotLike"; Path = @("person", "name"); Check = "Jane*" } @{ Name = "Match"; Operator = "Match"; Path = @("person", "name"); Check = "^John" } @{ Name = "NotMatch"; Operator = "NotMatch"; Path = @("person", "name"); Check = "^Jane" } @{ Name = "GreaterThan"; Operator = "GreaterThan"; Path = @("person", "age"); Check = 30 } @{ Name = "LessThan"; Operator = "LessThan"; Path = @("person", "age"); Check = 40 } @{ Name = "GreaterOrEqual"; Operator = "GreaterOrEqual"; Path = @("person", "age"); Check = 35 } @{ Name = "LessOrEqual"; Operator = "LessOrEqual"; Path = @("person", "age"); Check = 35 } ) $operatorNegativeCases = @( @{ Name = "Equals"; Operator = "Equals"; Path = @("person", "department"); Check = "IT" } @{ Name = "NotEquals"; Operator = "NotEquals"; Path = @("person", "department"); Check = "Finance" } @{ Name = "Contains"; Operator = "Contains"; Path = @("person", "roles"); Check = "Guest" } @{ Name = "NotContains"; Operator = "NotContains"; Path = @("person", "roles"); Check = "Approver" } @{ Name = "In"; Operator = "In"; Path = @("person", "department"); Check = '["HR","IT"]' } @{ Name = "NotIn"; Operator = "NotIn"; Path = @("person", "department"); Check = '["HR","Finance"]' } @{ Name = "Like"; Operator = "Like"; Path = @("person", "name"); Check = "Jane*" } @{ Name = "NotLike"; Operator = "NotLike"; Path = @("person", "name"); Check = "John*" } @{ Name = "Match"; Operator = "Match"; Path = @("person", "name"); Check = "^Jane" } @{ Name = "NotMatch"; Operator = "NotMatch"; Path = @("person", "name"); Check = "^John" } @{ Name = "GreaterThan"; Operator = "GreaterThan"; Path = @("person", "age"); Check = 40 } @{ Name = "LessThan"; Operator = "LessThan"; Path = @("person", "age"); Check = 30 } @{ Name = "GreaterOrEqual"; Operator = "GreaterOrEqual"; Path = @("person", "age"); Check = 36 } @{ Name = "LessOrEqual"; Operator = "LessOrEqual"; Path = @("person", "age"); Check = 34 } ) $missingPathCases = @( @{ Name = "Equals"; Operator = "Equals"; ShouldMatch = $false } @{ Name = "NotEquals"; Operator = "NotEquals"; ShouldMatch = $true } @{ Name = "Contains"; Operator = "Contains"; ShouldMatch = $false } @{ Name = "NotContains"; Operator = "NotContains"; ShouldMatch = $true } @{ Name = "In"; Operator = "In"; ShouldMatch = $false } @{ Name = "NotIn"; Operator = "NotIn"; ShouldMatch = $true } @{ Name = "Like"; Operator = "Like"; ShouldMatch = $false } @{ Name = "NotLike"; Operator = "NotLike"; ShouldMatch = $true } @{ Name = "Match"; Operator = "Match"; ShouldMatch = $false } @{ Name = "NotMatch"; Operator = "NotMatch"; ShouldMatch = $true } @{ Name = "GreaterThan"; Operator = "GreaterThan"; ShouldMatch = $false } @{ Name = "LessThan"; Operator = "LessThan"; ShouldMatch = $false } @{ Name = "GreaterOrEqual"; Operator = "GreaterOrEqual"; ShouldMatch = $false } @{ Name = "LessOrEqual"; Operator = "LessOrEqual"; ShouldMatch = $false } ) $negativeOperatorCases = @( @{ Name = "NotEquals"; Operator = "NotEquals" } @{ Name = "NotContains"; Operator = "NotContains" } @{ Name = "NotIn"; Operator = "NotIn" } @{ Name = "NotLike"; Operator = "NotLike" } @{ Name = "NotMatch"; Operator = "NotMatch" } ) $positiveOperatorCases = @( @{ Name = "Equals"; Operator = "Equals" } @{ Name = "Contains"; Operator = "Contains" } @{ Name = "In"; Operator = "In" } @{ Name = "Like"; Operator = "Like" } @{ Name = "Match"; Operator = "Match" } @{ Name = "GreaterThan"; Operator = "GreaterThan" } @{ Name = "LessThan"; Operator = "LessThan" } @{ Name = "GreaterOrEqual"; Operator = "GreaterOrEqual" } @{ Name = "LessOrEqual"; Operator = "LessOrEqual" } ) $invalidPathCases = @( @{ Name = "missing"; Condition = @{ operator = "Equals"; check = "Finance" } } @{ Name = "null"; Condition = @{ path = $null; operator = "Equals"; check = "Finance" } } @{ Name = "empty"; Condition = @{ path = ""; operator = "Equals"; check = "Finance" } } @{ Name = "whitespace"; Condition = @{ path = " "; operator = "Equals"; check = "Finance" } } ) $invalidConditionPropertyCases = @( @{ Name = "level"; Condition = @{ level = @("person","department"); operator = "Equals"; check = "Finance" } } ) $jsonRuleInputCases = @( @{ Name = "JSON file content" Rules = @' [ { "conditions": [ { "path": ["person","department"], "operator": "Equals", "check": "Finance" } ], "result": "Finance" } ] '@ Expected = @("Finance") } ) Describe "Invoke-StructMatcher" { BeforeAll { $script:oldVerbosePreference = $VerbosePreference $script:oldInformationPreference = $InformationPreference $script:oldWarningPreference = $WarningPreference $script:oldProgressPreference = $ProgressPreference $VerbosePreference = 'SilentlyContinue' $InformationPreference = 'SilentlyContinue' $WarningPreference = 'Continue' $ProgressPreference = 'SilentlyContinue' Import-Module "$PSScriptRoot/../StructMatcher.psm1" -Force $hashtableData = @{ person = @{ department = "Finance" name = "John Doe" type = "employee" age = 35 roles = @("Employee", "Approver") tags = '["active","managed"]' address = @{ city = "Alkmaar" } } } $jsonData = '{ "person": { "department": "Finance", "name": "John Doe", "type": "employee", "age": 35, "roles": ["Employee", "Approver"], "address": { "city": "Alkmaar" } } }' $customObjectData = $jsonData | ConvertFrom-Json $matchingRules = @( @{ conditions = @( @{ path = @("person", "department") operator = "Equals" check = "Finance" } @{ path = @("person", "type") operator = "Equals" check = "employee" } ) result = "Finance employees" } @{ conditions = @( @{ path = @("person", "department") operator = "Equals" check = "IT" } ) result = "IT employees" } @{ conditions = @( @{ path = @("person", "type") operator = "Equals" check = "employee" } ) result = "Employees" } @{ conditions = @( @{ path = @("person", "type") operator = "Equals" check = "intern" } ) result = "Interns" } ) $defaultOperatorRule = @{ conditions = @( @{ path = @("person", "department") check = "Finance" } ) result = "Default Equals" } $missingPropertyRule = @{ conditions = @( @{ path = @("person", "manager", "department") operator = "Equals" check = "Finance" } ) result = "Unexpected match" } $invalidOperatorRule = @{ conditions = @( @{ path = @("person", "department") operator = "Unknown" check = "Finance" } ) result = "Unexpected match" } $duplicateRules = @( @{ conditions = @(@{ path = @("person", "department") operator = "Equals" check = "Finance" }) result = "Finance employees" } @{ conditions = @(@{ path = @("person", "type") operator = "Equals" check = "employee" }) result = "Finance employees" } @{ conditions = @(@{ path = @("person", "name") operator = "Equals" check = "John Doe" }) result = "John Doe result" } ) } AfterAll { $VerbosePreference = $script:oldVerbosePreference $InformationPreference = $script:oldInformationPreference $WarningPreference = $script:oldWarningPreference $ProgressPreference = $script:oldProgressPreference } # Rule evaluation Context "Rule evaluation" { It "matches multiple rules and returns all unique results" { $results = @(Invoke-StructMatcher -Rules $matchingRules -Data $hashtableData) $results | Should -HaveCount 2 $results | Should -Contain "Finance employees" $results | Should -Contain "Employees" } It "returns unique results in first-match order" { $results = @(Invoke-StructMatcher -Rules $duplicateRules -Data $hashtableData) $results | Should -HaveCount 2 $results[0] | Should -Be "Finance employees" $results[1] | Should -Be "John Doe result" } It "returns no results when no rule matches" { $results = @(Invoke-StructMatcher -Rules $matchingRules -Data @{ person = @{ department = "Sales" } }) $results | Should -BeNullOrEmpty } It "requires every condition in a rule to match" { $results = @(Invoke-StructMatcher -Rules @($matchingRules[0]) -Data @{ person = @{ department = "Finance"; type = "intern" } }) $results | Should -BeNullOrEmpty } It "matches a rule with an empty conditions collection" { $rules = @( @{ conditions = @() result = "Default result" } ) $results = @(Invoke-StructMatcher -Rules $rules -Data $hashtableData) $results | Should -Be "Default result" } It "continues evaluating rules after a rule does not match" { $rules = @( @{ conditions = @( @{ path = @("person", "department"); operator = "Equals"; check = "Finance" } @{ path = @("person", "type"); operator = "Equals"; check = "intern" } ) result = "Wrong result" } @{ conditions = @( @{ path = @("person", "type"); operator = "Equals"; check = "employee" } ) result = "Employees" } ) $results = @(Invoke-StructMatcher -Rules $rules -Data $hashtableData) $results | Should -Be @("Employees") } It "matches a rule when the conditions property is omitted" { $rule = @{ result = "Default result" } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) $results | Should -Be @("Default result") } It "returns no results for an empty rules collection" { $results = @(Invoke-StructMatcher -Rules @() -Data $hashtableData) $results | Should -BeNullOrEmpty } It "does not modify the input data" { $before = $hashtableData | ConvertTo-Json -Depth 10 -Compress [void](Invoke-StructMatcher -Rules $matchingRules -Data $hashtableData) $after = $hashtableData | ConvertTo-Json -Depth 10 -Compress $after | Should -Be $before } It "does not modify the input rules" { $before = $matchingRules | ConvertTo-Json -Depth 10 -Compress [void](Invoke-StructMatcher -Rules $matchingRules -Data $hashtableData) $after = $matchingRules | ConvertTo-Json -Depth 10 -Compress $after | Should -Be $before } } # Input normalization Context "Input normalization" { It "supports hashtable, PSCustomObject, and JSON input" { $hashtableResults = @(Invoke-StructMatcher -Rules $matchingRules -Data $hashtableData) $objectResults = @(Invoke-StructMatcher -Rules $matchingRules -Data $customObjectData) $jsonResults = @(Invoke-StructMatcher -Rules $matchingRules -Data $jsonData) ($hashtableResults | ConvertTo-Json -Compress) | Should -Be ($objectResults | ConvertTo-Json -Compress) ($objectResults | ConvertTo-Json -Compress) | Should -Be ($jsonResults | ConvertTo-Json -Compress) } It "supports iterable rule collections such as ArrayList" { $ruleList = [System.Collections.ArrayList]::new() foreach ($rule in $matchingRules) { [void]$ruleList.Add($rule) } $results = @(Invoke-StructMatcher -Rules $ruleList -Data $hashtableData) $results | Should -HaveCount 2 $results | Should -Contain "Finance employees" $results | Should -Contain "Employees" } It "supports dictionary implementations such as OrderedDictionary for data" { $orderedData = [ordered]@{ person = [ordered]@{ department = "Finance" name = "John Doe" type = "employee" age = 35 roles = @("Employee", "Approver") } } $results = @(Invoke-StructMatcher -Rules $matchingRules -Data $orderedData) $results | Should -HaveCount 2 $results | Should -Contain "Finance employees" $results | Should -Contain "Employees" } It "supports rules provided as JSON text" { $jsonRules = '[ { "conditions": [ { "path": ["person", "department"], "operator": "Equals", "check": "Finance" }, { "path": ["person", "type"], "operator": "Equals", "check": "employee" } ], "result": "Finance employees" }, { "conditions": [ { "path": ["person", "type"], "operator": "Equals", "check": "employee" } ], "result": "Employees" } ]' $results = @(Invoke-StructMatcher -Rules $jsonRules -Data $hashtableData) $results | Should -HaveCount 2 $results | Should -Contain "Finance employees" $results | Should -Contain "Employees" } It "supports a single rule object" { $rule = @{ conditions = @( @{ path = "person.department" operator = "Equals" check = "Finance" } ) result = "Finance" } $results = @(Invoke-StructMatcher -Rules $rule -Data $hashtableData) $results | Should -Be @("Finance") } It "supports a single rule object as JSON text" { $jsonRule = @' { "conditions": [ { "path": "person.department", "operator": "Equals", "check": "Finance" } ], "result": "Finance" } '@ $results = @(Invoke-StructMatcher -Rules $jsonRule -Data $hashtableData) $results | Should -Be @("Finance") } It "supports rules supplied as <Name>" -ForEach $jsonRuleInputCases { $results = @(Invoke-StructMatcher -Rules $Rules -Data $hashtableData) $results | Should -Be $Expected } It "supports rules loaded from a file using Get-Content -Raw" { $rules = Get-Content ` "$PSScriptRoot\Data\Rules.json" ` -Raw $rules.GetType().FullName | Should -Be 'System.String' $results = @(Invoke-StructMatcher ` -Rules $rules ` -Data $hashtableData) $results | Should -Be @("Finance") } It "supports rules supplied as PSCustomObject" { $rules = @' [ { "conditions": [ { "path": ["person", "department"], "operator": "Equals", "check": "Finance" } ], "result": "Finance" } ] '@ | ConvertFrom-Json $results = @(Invoke-StructMatcher ` -Rules $rules ` -Data $hashtableData) $results | Should -Be @("Finance") } } # Operator evaluation Context "Operator evaluation" { It "supports the default Equals operator" { $results = @(Invoke-StructMatcher -Rules @($defaultOperatorRule) -Data $hashtableData) $results | Should -Be "Default Equals" } It "supports operator <Name>" -ForEach $operatorCases { $rule = @{ conditions = @(@{ path = $Path operator = $Operator check = $Check }) result = $Name } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) $results | Should -Contain $Name } It "does not match operator <Name> for a negative check" -ForEach $operatorNegativeCases { $rule = @{ conditions = @(@{ path = $Path operator = $Operator check = $Check }) result = $Name } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) $results | Should -BeNullOrEmpty } } # Path handling Context "Path handling" { It "supports JSON-array values in data properties for membership checks" { $rule = @{ conditions = @( @{ path = @("person", "tags"); operator = "Contains"; check = "active" } ) result = "Has active tag" } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) $results | Should -Be "Has active tag" } It "supports a single-item JSON array string for Contains" { $data = @{ person = @{ tags = '["active"]' } } $rule = @{ conditions = @( @{ path = "person.tags" operator = "Contains" check = "active" } ) result = "Has active tag" } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $data) $results | Should -Contain "Has active tag" } It "supports missing intermediate properties without throwing" { $results = @(Invoke-StructMatcher -Rules @($missingPropertyRule) -Data $hashtableData) $results | Should -BeNullOrEmpty } It "handles missing path for operator <Name>" -ForEach $missingPathCases { $rule = @{ conditions = @( @{ path = @("person", "doesNotExist") operator = $Operator check = "whatever" } ) result = $Name } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) if ($ShouldMatch) { $results | Should -Contain $Name } else { $results | Should -BeNullOrEmpty } } It "supports dotted path notation" { $rule = @{ conditions = @( @{ path = "person.address.city" operator = "Equals" check = "Alkmaar" } ) result = "Match" } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) $results | Should -Contain "Match" } It "distinguishes existing null values from missing properties" { $data = @{ person = @{ department = $null } } $rule = @{ conditions = @( @{ path = @("person","department") operator = "Equals" check = $null } ) result = "NullMatch" } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $data) $results | Should -Contain "NullMatch" } It "does not match missing paths for <Name> when missingOk is false" -ForEach $negativeOperatorCases { $rule = @{ conditions = @( @{ path = @("person", "doesNotExist") operator = $Operator check = "whatever" missingOk = $false } ) result = $Name } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) $results | Should -BeNullOrEmpty } It "matches missing paths for <Name> when missingOk is explicitly true" -ForEach $negativeOperatorCases { $rule = @{ conditions = @( @{ path = @("person", "doesNotExist") operator = $Operator check = "whatever" missingOk = $true } ) result = $Name } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) $results | Should -Contain $Name } It "does not match a missing path for positive operator <Name> when missingOk is true" -ForEach $positiveOperatorCases { $rule = @{ conditions = @( @{ path = "person.doesNotExist" operator = $Operator check = "whatever" missingOk = $true } ) result = $Name } $results = @(Invoke-StructMatcher -Rules @($rule) -Data $hashtableData) $results | Should -BeNullOrEmpty } } # Contract validation Context "Contract validation" { It "throws when result is explicitly null" { $rule = @{ conditions = @() result = $null } { Invoke-StructMatcher -Rules @($rule) -Data $hashtableData } | Should -Throw "Rule.result can't be null*" } It "throws when the result property is missing" { $rule = @{ conditions = @() } { Invoke-StructMatcher -Rules @($rule) -Data $hashtableData } | Should -Throw "Rule.result can't be null*" } It "throws when condition path is <Name>" -ForEach $invalidPathCases { $rule = @{ conditions = @($Condition) result = "Unexpected" } { Invoke-StructMatcher -Rules @($rule) -Data $hashtableData } | Should -Throw "Condition is missing a 'path' property.*" } It "throws when condition contains unknown property <Name>" -ForEach $invalidConditionPropertyCases { $rule = @{ conditions = @($Condition) result = "Unexpected" } { Invoke-StructMatcher -Rules @($rule) -Data $hashtableData } | Should -Throw "*$Name*" } } # Error handling Context "Error handling" { It "throws for an unsupported operator" { { Invoke-StructMatcher -Rules @($invalidOperatorRule) -Data $hashtableData } | Should -Throw "Unsupported operator*" } It "throws when data input is an empty string" { { Invoke-StructMatcher -Rules $matchingRules -Data "" } | Should -Throw "Input cannot be an empty string.*" } It "throws when data input contains invalid JSON" { { Invoke-StructMatcher -Rules $matchingRules -Data "{ invalid json }" } | Should -Throw "Failed to parse JSON input*" } It "throws when rules input contains invalid JSON" { { Invoke-StructMatcher -Rules "{ invalid json }" -Data $hashtableData } | Should -Throw "Failed to parse JSON input*" } It "throws when rules input has an unsupported type" { { Invoke-StructMatcher -Rules 42 -Data $hashtableData } | Should -Throw "Unsupported input type*" } It "throws when data input has an unsupported type" { { Invoke-StructMatcher -Rules $matchingRules -Data 42 } | Should -Throw "Unsupported input type*" } It "throws when rules input contains only whitespace" { { Invoke-StructMatcher -Rules " " -Data $hashtableData } | Should -Throw "Input cannot be an empty string.*" } It "throws when data input contains only whitespace" { { Invoke-StructMatcher -Rules $matchingRules -Data " " } | Should -Throw "Input cannot be an empty string.*" } It "throws when rules input is an empty string" { { Invoke-StructMatcher -Rules "" -Data $hashtableData } | Should -Throw "Input cannot be an empty string.*" } } } |