tests/Test-IQService.ps1

param(
    [string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'modules/ISC.IQService.psm1'),
    [string]$ConsolePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'modules/ISC.OperatorConsole.psm1')
)

$ErrorActionPreference = 'Stop'
$script:AssertionCount = 0

function Assert-True {
    param([bool]$Condition, [string]$Message)
    $script:AssertionCount++
    if (-not $Condition) { throw "Assertion failed: $Message" }
}

function Assert-Equal {
    param($Expected, $Actual, [string]$Message)
    $script:AssertionCount++
    if ($Expected -ne $Actual) {
        throw "Assertion failed: $Message. Expected '$Expected', got '$Actual'."
    }
}

Import-Module $ConsolePath -Force -WarningAction SilentlyContinue
Initialize-OperatorConsole -NonInteractive
Import-Module $ModulePath -Force -WarningAction SilentlyContinue
Initialize-IQServiceData -NonInteractive

function New-TestService {
    param([string]$Name, [string]$InstallPath, [string]$Status = 'Running')
    return [PSCustomObject]@{
        Name        = $Name
        DisplayName = $Name
        Status      = $Status
        StartType   = 'Automatic'
        ImagePath   = "$InstallPath\IQService.exe"
        InstallPath = $InstallPath
        StartName   = 'SERI\svc-iqservice'
    }
}

function New-TestRegistryInstance {
    param([string]$InstanceName, $Port, $TlsPort, [string]$TraceFile, $TraceLevel = 2)
    return [PSCustomObject]@{
        InstanceName    = $InstanceName
        Port            = $Port
        TlsPort         = $TlsPort
        TraceFile       = $TraceFile
        TraceLevel      = $TraceLevel
        MaxTraceFiles   = 10
        TraceFileSize   = 10240
        ClientAuthUsers = $null
    }
}

# Path comparison
Assert-True (Test-SameIQServicePath -Left 'C:\SailPoint\IQService' -Right 'c:\sailpoint\iqservice\') 'path comparison ignores case and trailing separator'
Assert-True (-not (Test-SameIQServicePath -Left 'C:\SailPoint\IQService' -Right 'C:\SailPoint\IQService2')) 'sibling install paths are not the same instance'
Assert-True (-not (Test-SameIQServicePath -Left '' -Right 'C:\SailPoint\IQService')) 'an unknown path never matches'
Assert-Equal 'C:\SailPoint\IQService' (Get-IQServiceParentPath -Path 'C:\SailPoint\IQService\iqtrace.log') 'Windows trace paths yield their parent directory'
Assert-Equal '/opt/sailpoint' (Get-IQServiceParentPath -Path '/opt/sailpoint/iqtrace.log') 'forward-slash paths yield their parent directory'
Assert-Equal '' (Get-IQServiceParentPath -Path 'iqtrace.log') 'a bare file name has no parent directory'

# Two instances, correlated by matching service and registry key names
$records = @(Resolve-IQServiceInstanceRecords -Services @(
    (New-TestService -Name 'SailPointIQService' -InstallPath 'C:\SailPoint\IQService')
    (New-TestService -Name 'SailPointIQService2' -InstallPath 'D:\SailPoint\IQService2')
) -RegistryInstances @(
    (New-TestRegistryInstance -InstanceName 'SailPointIQService2' -Port 5051 -TlsPort 5551 -TraceFile 'D:\SailPoint\IQService2\iqtrace.log')
    (New-TestRegistryInstance -InstanceName 'SailPointIQService' -Port 5050 -TlsPort 5550 -TraceFile 'C:\SailPoint\IQService\iqtrace.log')
))
Assert-Equal 2 $records.Count 'two services produce two instance records'
$first = $records | Where-Object { $_.InstallPath -eq 'C:\SailPoint\IQService' }
$second = $records | Where-Object { $_.InstallPath -eq 'D:\SailPoint\IQService2' }
Assert-Equal 5050 $first.Port 'first instance keeps its own port despite registry enumeration order'
Assert-Equal 5051 $second.Port 'second instance keeps its own port'
Assert-Equal 'ServiceName' $first.MatchedBy 'matching names correlate by service name'
Assert-Equal 'D:\SailPoint\IQService2\iqtrace.log' $second.TraceFile 'each instance keeps its own trace file'

# Registry keys named differently from the services fall back to the trace file directory
$records = @(Resolve-IQServiceInstanceRecords -Services @(
    (New-TestService -Name 'SailPointIQService' -InstallPath 'C:\SailPoint\IQService')
    (New-TestService -Name 'SailPointIQService2' -InstallPath 'D:\SailPoint\IQService2')
) -RegistryInstances @(
    (New-TestRegistryInstance -InstanceName 'prod-b' -Port 5051 -TlsPort $null -TraceFile 'D:\SailPoint\IQService2\iq2.log')
    (New-TestRegistryInstance -InstanceName 'prod-a' -Port 5050 -TlsPort $null -TraceFile 'C:\SailPoint\IQService\iq1.log')
))
$first = $records | Where-Object { $_.InstallPath -eq 'C:\SailPoint\IQService' }
$second = $records | Where-Object { $_.InstallPath -eq 'D:\SailPoint\IQService2' }
Assert-Equal 'prod-a' $first.InstanceName 'trace file location correlates the first instance'
Assert-Equal 'TraceFilePath' $first.MatchedBy 'fallback correlation is reported as TraceFilePath'
Assert-Equal 5051 $second.Port 'trace file location correlates the second instance'

# A single unnameable instance may still be paired, but only when it is the only one
$records = @(Resolve-IQServiceInstanceRecords -Services @(
    (New-TestService -Name 'SailPointIQService' -InstallPath 'C:\SailPoint\IQService')
) -RegistryInstances @(
    (New-TestRegistryInstance -InstanceName 'default' -Port 5050 -TlsPort $null -TraceFile 'C:\Windows\system32\iqtrace.log')
))
Assert-Equal 'SoleInstance' $records[0].MatchedBy 'a lone service pairs with a lone registry key'
Assert-Equal 5050 $records[0].Port 'the lone pairing carries the port through'

# With two services and one uncorrelatable key, nothing is guessed
$records = @(Resolve-IQServiceInstanceRecords -Services @(
    (New-TestService -Name 'SailPointIQService' -InstallPath 'C:\SailPoint\IQService')
    (New-TestService -Name 'SailPointIQService2' -InstallPath 'D:\SailPoint\IQService2')
) -RegistryInstances @(
    (New-TestRegistryInstance -InstanceName 'mystery' -Port 5050 -TlsPort $null -TraceFile 'C:\Windows\system32\iqtrace.log')
))
$services = @($records | Where-Object { $_.HasService })
Assert-Equal 2 $services.Count 'both services are still reported'
Assert-Equal 0 @($services | Where-Object { $_.HasRegistry }).Count 'an ambiguous registry key is not attributed to either service'
$orphan = @($records | Where-Object { -not $_.HasService })
Assert-Equal 1 $orphan.Count 'the uncorrelated registry key is reported on its own'
Assert-Equal 'mystery' $orphan[0].InstanceName 'the orphan keeps its registry key name'

# Missing optional registry values must not throw under Set-StrictMode
$records = @(Resolve-IQServiceInstanceRecords -Services @(
    (New-TestService -Name 'SailPointIQService' -InstallPath 'C:\SailPoint\IQService')
) -RegistryInstances @(
    [PSCustomObject]@{ InstanceName = 'SailPointIQService'; Port = 5050 }
))
Assert-Equal 5050 $records[0].Port 'a registry key holding only a port still correlates'
Assert-True ($null -eq $records[0].TlsPort) 'an absent tlsPort reads as null instead of throwing'

# Services with no registry key at all
$records = @(Resolve-IQServiceInstanceRecords -Services @(
    (New-TestService -Name 'SailPointIQService' -InstallPath 'C:\SailPoint\IQService')
) -RegistryInstances @())
Assert-Equal 1 $records.Count 'a service without registry settings is still an instance'
Assert-True (-not $records[0].HasRegistry) 'the record reports that it has no registry settings'
Assert-True ($null -eq $records[0].TraceFile) 'no trace file is invented for it'
Assert-Equal 0 @(Resolve-IQServiceInstanceRecords -Services @() -RegistryInstances @()).Count 'a host with no IQService yields no instances'

# Scoped lookup
$instances = @(Resolve-IQServiceInstanceRecords -Services @(
    (New-TestService -Name 'SailPointIQService' -InstallPath 'C:\SailPoint\IQService')
    (New-TestService -Name 'SailPointIQService2' -InstallPath 'D:\SailPoint\IQService2')
) -RegistryInstances @(
    (New-TestRegistryInstance -InstanceName 'SailPointIQService' -Port 5050 -TlsPort 5550 -TraceFile 'C:\SailPoint\IQService\iqtrace.log')
    (New-TestRegistryInstance -InstanceName 'SailPointIQService2' -Port 5051 -TlsPort 5551 -TraceFile 'D:\SailPoint\IQService2\iqtrace.log' -TraceLevel 3)
))
$scoped = Get-IQServiceInstanceForPath -InstallPath 'd:\sailpoint\iqservice2\' -Instances $instances
Assert-Equal 5051 $scoped.Port 'scoped lookup normalizes the path it is given'
Assert-Equal 3 $scoped.TraceLevel 'scoped lookup returns that instance trace level'
Assert-True ($null -eq (Get-IQServiceInstanceForPath -InstallPath 'E:\Elsewhere' -Instances $instances)) 'an unknown install path resolves to no instance'
Assert-True ((Get-IQServiceInstanceLabel -Instance $scoped) -like '*SailPointIQService2*D:\SailPoint\IQService2*') 'instance labels name the instance and its path'
Assert-Equal '(unknown instance)' (Get-IQServiceInstanceLabel -Instance $null) 'a missing instance still labels safely'

# Install arguments
Assert-Equal '-i' ((Build-IQServiceInstallArguments) -join ' ') 'install defaults to bare registration'
Assert-Equal '-i -p 5050 -o 5550 -b' ((Build-IQServiceInstallArguments -Port 5050 -TlsPort 5550 -SkipSecondary) -join ' ') 'ports and secondary skip are passed through'
Assert-Equal '-i' ((Build-IQServiceInstallArguments -Port 0 -TlsPort 0) -join ' ') 'zero ports are omitted'

Assert-Equal 'C:\SailPoint\IQService' (Get-IQServiceDefaultInstallPath) 'the default install path is available without prior initialization'

Write-Host "PASS ($script:AssertionCount assertions)"