tests/Analytics.Module.Tests.ps1

#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
#Requires -Modules ImportExcel

<#
.SYNOPSIS
    Pester tests for all Analytics inventory modules.
.DESCRIPTION
    Tests both Processing and Reporting phases for each Analytics module
    using synthetic mock data. No live Azure authentication is required.
.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
    Created: 2026-02-25
#>


# ===================================================================
# DISCOVERY-TIME
# ===================================================================
$AnalyticsPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Modules' 'Public' 'InventoryModules' 'Analytics'

$AnalyticsModules = @(
    @{ Name = 'Databricks';          File = 'Databricks.ps1';          Type = 'microsoft.databricks/workspaces';           Worksheet = 'Databricks' }
    @{ Name = 'DataExplorerCluster'; File = 'DataExplorerCluster.ps1'; Type = 'microsoft.kusto/clusters';                  Worksheet = 'Data Explorer Clusters' }
    @{ Name = 'EvtHub';              File = 'EvtHub.ps1';              Type = 'microsoft.eventhub/namespaces';              Worksheet = 'Event Hubs' }
    @{ Name = 'Purview';             File = 'Purview.ps1';             Type = 'microsoft.purview/accounts';                 Worksheet = 'Purview' }
    @{ Name = 'Streamanalytics';     File = 'Streamanalytics.ps1';     Type = 'microsoft.streamanalytics/streamingjobs';    Worksheet = 'Stream Analytics Jobs' }
    @{ Name = 'Synapse';             File = 'Synapse.ps1';             Type = 'microsoft.synapse/workspaces';               Worksheet = 'Synapse' }
)

# ===================================================================
# EXECUTION-TIME SETUP
# ===================================================================
BeforeAll {
    $script:ModuleRoot     = Split-Path -Parent $PSScriptRoot

    # Collectors are executed here as bare scriptblocks, NOT through the imported module, so any
    # private helper they call has to be dot-sourced in explicitly. Get-AZSCSafeProperty is the
    # StrictMode-safe dotted-path read that converted collectors use instead of a raw `$data.a.b`
    # chain (which throws when an intermediate segment is genuinely ABSENT rather than $null), and
    # Get-AZSCCollectedValue is its member-ENUMERATION counterpart for a read over a collection
    # that may be empty. Both are needed here from AB#5671 onwards.
    . (Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main' 'Get-AZSCSafeProperty.ps1')
    . (Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main' 'Get-AZTICollectedValue.ps1')
    # Get-AZSCIdSegment (AB#5671) guards the FIXED .split('/')[8] index ~30 collectors use to pull
    # a name out of a related resource id -- an out-of-range index THROWS under StrictMode, where
    # without it the same expression quietly returned $null.
    . (Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main' 'Get-AZSCIdSegment.ps1')
    $script:AnalyticsPath  = Join-Path $script:ModuleRoot 'Modules' 'Public' 'InventoryModules' 'Analytics'
    $script:TempDir        = Join-Path $env:TEMP 'AZSC_AnalyticsTests'

    if (Test-Path $script:TempDir) { Remove-Item $script:TempDir -Recurse -Force }
    New-Item -ItemType Directory -Path $script:TempDir -Force | Out-Null

    function New-MockAnalyticsResource {
        param([string]$Id, [string]$Name, [string]$Type, [string]$Kind = '',
              [string]$Location = 'eastus', [string]$RG = 'rg-analytics',
              [string]$SubscriptionId = 'sub-00000001', [object]$Props, [object]$SKU = $null)
        [PSCustomObject]@{
            id             = $Id
            NAME           = $Name
            TYPE           = $Type
            KIND           = $Kind
            LOCATION       = $Location
            RESOURCEGROUP  = $RG
            subscriptionId = $SubscriptionId
            tags           = [PSCustomObject]@{}
            PROPERTIES     = $Props
            SKU            = $SKU
        }
    }

    $script:MockResources = @()

    # --- Databricks ---
    $script:MockResources += New-MockAnalyticsResource -Id '/subscriptions/sub-00000001/resourceGroups/rg-analytics/providers/microsoft.databricks/workspaces/dbw-prod' `
        -Name 'dbw-prod' -Type 'microsoft.databricks/workspaces' `
        -SKU ([PSCustomObject]@{ name = 'premium'; tier = 'Premium' }) `
        -Props ([PSCustomObject]@{
            createdDateTime = '2025-06-15T10:00:00Z'
            parameters = [PSCustomObject]@{
                enableNoPublicIp = [PSCustomObject]@{ value = 'False' }
                customVirtualNetworkId = [PSCustomObject]@{ value = '/subscriptions/sub-00000001/resourceGroups/rg-net/providers/Microsoft.Network/virtualNetworks/vnet-dbw/subnets/private' }
                storageAccountName = [PSCustomObject]@{ value = 'sadbwprod01' }
                storageAccountSkuName = [PSCustomObject]@{ value = 'Standard_LRS' }
                requireInfrastructureEncryption = [PSCustomObject]@{ value = $true }
                prepareEncryption = [PSCustomObject]@{ value = $false }
                customPrivateSubnetName = [PSCustomObject]@{ value = 'private-subnet' }
                customPublicSubnetName = [PSCustomObject]@{ value = 'public-subnet' }
            }
            managedResourceGroupId = '/subscriptions/sub-00000001/resourceGroups/rg-dbw-managed/providers/foo/bar'
            workspaceUrl = 'adb-123456.azuredatabricks.net'
        })

    # --- Data Explorer Cluster ---
    $script:MockResources += New-MockAnalyticsResource -Id '/subscriptions/sub-00000001/resourceGroups/rg-analytics/providers/microsoft.kusto/clusters/dec-prod' `
        -Name 'dec-prod' -Type 'microsoft.kusto/clusters' `
        -SKU ([PSCustomObject]@{ name = 'Standard_D13_v2'; capacity = 2 }) `
        -Props ([PSCustomObject]@{
            state = 'Running'; stateReason = ''
            virtualNetworkConfiguration = [PSCustomObject]@{
                subnetid = '/subscriptions/sub-00000001/resourceGroups/rg-net/providers/Microsoft.Network/virtualNetworks/vnet-kusto/subnets/kusto-subnet'
                dataManagementPublicIpId = '/subscriptions/sub-00000001/resourceGroups/rg-net/providers/Microsoft.Network/publicIPAddresses/pip-kusto-dm'
                enginePublicIpId = '/subscriptions/sub-00000001/resourceGroups/rg-net/providers/Microsoft.Network/publicIPAddresses/pip-kusto-engine'
            }
            trustedExternalTenants = @([PSCustomObject]@{ value = '*' })
            optimizedAutoscale = [PSCustomObject]@{ isEnabled = 'true'; minimum = 2; maximum = 10 }
            enableDiskEncryption = $true; enableStreamingIngest = $true
            uri = 'https://dec-prod.eastus.kusto.windows.net'
            dataIngestionUri = 'https://ingest-dec-prod.eastus.kusto.windows.net'
        })

    # --- Event Hub ---
    $script:MockResources += New-MockAnalyticsResource -Id '/subscriptions/sub-00000001/resourceGroups/rg-analytics/providers/microsoft.eventhub/namespaces/eh-prod' `
        -Name 'eh-prod' -Type 'microsoft.eventhub/namespaces' `
        -SKU ([PSCustomObject]@{ name = 'Standard'; capacity = 2 }) `
        -Props ([PSCustomObject]@{
            createdAt = '2025-03-10T08:30:00Z'; status = 'Active'
            zoneRedundant = $true; disablelocalauth = $false
            isAutoInflateEnabled = $true; maximumThroughputUnits = 20
            kafkaEnabled = $true; minimumtlsversion = '1.2'
            serviceBusEndpoint = 'https://eh-prod.servicebus.windows.net:443/'
        })

    # --- Purview ---
    $script:MockResources += New-MockAnalyticsResource -Id '/subscriptions/sub-00000001/resourceGroups/rg-analytics/providers/microsoft.purview/accounts/purv-prod' `
        -Name 'purv-prod' -Type 'microsoft.purview/accounts' `
        -Props ([PSCustomObject]@{
            cloudConnectors = @([PSCustomObject]@{ id = 'cc1' })
            privateEndpointConnections = @([PSCustomObject]@{ id = 'pe1' }, [PSCustomObject]@{ id = 'pe2' })
            createdAt = '2025-01-20T14:00:00Z'
            sku = [PSCustomObject]@{ name = 'Standard'; capacity = 1 }
            friendlyName = 'Production Purview'
            managedResourceGroupName = 'rg-purview-managed'
            managedResources = [PSCustomObject]@{
                storageAccount = '/subscriptions/sub-00000001/resourceGroups/rg-purview/providers/Microsoft.Storage/storageAccounts/sapurvprod'
                eventHubNamespace = '/subscriptions/sub-00000001/resourceGroups/rg-purview/providers/Microsoft.EventHub/namespaces/ehpurvprod'
            }
            publicNetworkAccess = 'Enabled'
            createdBy = 'admin@contoso.com'
        })

    # --- Stream Analytics (needs both cluster and job resources) ---
    $script:MockResources += New-MockAnalyticsResource -Id '/subscriptions/sub-00000001/resourceGroups/rg-analytics/providers/microsoft.streamanalytics/clusters/sac-prod' `
        -Name 'sac-prod' -Type 'microsoft.streamanalytics/clusters' `
        -SKU ([PSCustomObject]@{ name = 'Default'; capacity = 36 }) `
        -Props ([PSCustomObject]@{
            capacityallocated = 36; capacityassigned = 12
            createddate = '2025-02-01T10:00:00Z'
        })

    $script:MockResources += New-MockAnalyticsResource -Id '/subscriptions/sub-00000001/resourceGroups/rg-analytics/providers/microsoft.streamanalytics/streamingjobs/saj-transform' `
        -Name 'saj-transform' -Type 'microsoft.streamanalytics/streamingjobs' `
        -Props ([PSCustomObject]@{
            cluster = [PSCustomObject]@{ id = '/subscriptions/sub-00000001/resourceGroups/rg-analytics/providers/microsoft.streamanalytics/clusters/sac-prod' }
            createdDate = '2025-04-01T09:00:00Z'
            sku = [PSCustomObject]@{ name = 'Standard' }
            compatibilityLevel = '1.2'
            jobstorageaccount = [PSCustomObject]@{ accountname = 'sajstorage01'; authenticationmode = 'ConnectionString' }
            contentStoragePolicy = 'SystemAccount'
            dataLocale = 'en-US'
            eventsLateArrivalMaxDelayInSeconds = 5
            eventsOutOfOrderMaxDelayInSeconds = 0
            eventsOutOfOrderPolicy = 'Adjust'
            jobState = 'Running'; jobType = 'Cloud'
            lastOutputEventTime = '2025-06-01T12:00:00Z'
            outputStartTime = '2025-04-01T10:00:00Z'
            outputErrorPolicy = 'Stop'
        })

    # --- Synapse ---
    $script:MockResources += New-MockAnalyticsResource -Id '/subscriptions/sub-00000001/resourceGroups/rg-analytics/providers/microsoft.synapse/workspaces/syn-prod' `
        -Name 'syn-prod' -Type 'microsoft.synapse/workspaces' `
        -Props ([PSCustomObject]@{
            publicNetworkAccess = 'Enabled'
            privateEndpointConnections = @([PSCustomObject]@{ id = 'pe1' })
            encryption = [PSCustomObject]@{ doubleEncryptionEnabled = $true }
            trustedServiceBypassEnabled = $true
            sqlAdministratorLogin = 'sqladmin'
            extraProperties = [PSCustomObject]@{ IsScopeEnabled = $true; WorkspaceType = 'Normal' }
            managedVirtualNetworkSettings = [PSCustomObject]@{ preventDataExfiltration = $true }
            managedVirtualNetwork = 'default'
            managedResourceGroupName = 'rg-synapse-managed'
        })
}

AfterAll {
    if (Test-Path $script:TempDir) { Remove-Item $script:TempDir -Recurse -Force }
}

# ===================================================================
# TESTS
# ===================================================================
Describe 'Analytics Module Files Exist' {
    It 'Analytics module folder exists' {
        $script:AnalyticsPath | Should -Exist
    }
    It '<Name> module file exists' -ForEach $AnalyticsModules {
        Join-Path $script:AnalyticsPath $File | Should -Exist
    }
}

Describe 'Analytics Module Processing Phase — <Name>' -ForEach $AnalyticsModules {
    BeforeAll {
        $script:ModFile = Join-Path $script:AnalyticsPath $File
        $script:ResType = $Type
    }
    It 'Processing returns results when matching resources are present' {
        $matchedResources = $script:MockResources | Where-Object { $_.TYPE -eq $script:ResType }
        if ($matchedResources) {
            $content = Get-Content -Path $script:ModFile -Raw
            $sb = [ScriptBlock]::Create($content)
            $result = Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $script:MockResources, $null, 'Processing', $null, $null, 'Light20', $null
            $result | Should -Not -BeNullOrEmpty
        } else {
            Set-ItResult -Skipped -Because "No mock resource of type '$script:ResType'"
        }
    }
    It 'Processing does not throw when given an empty resource list' {
        $content = Get-Content -Path $script:ModFile -Raw
        $sb = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }
}

Describe 'Analytics Module Reporting Phase — <Name>' -ForEach $AnalyticsModules {
    BeforeAll {
        $script:ModFile  = Join-Path $script:AnalyticsPath $File
        $script:ResType  = $Type
        $script:XlsxFile = Join-Path $script:TempDir ("Analytics_{0}_{1}.xlsx" -f $Name, [System.IO.Path]::GetRandomFileName())

        $matchedResources = $script:MockResources | Where-Object { $_.TYPE -eq $script:ResType }
        if ($matchedResources) {
            $content = Get-Content -Path $script:ModFile -Raw
            $sb = [ScriptBlock]::Create($content)
            $script:ProcessedData = Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $script:MockResources, $null, 'Processing', $null, $null, 'Light20', $null
        } else {
            $script:ProcessedData = $null
        }
    }
    It 'Reporting phase does not throw' {
        if ($script:ProcessedData) {
            $content = Get-Content -Path $script:ModFile -Raw
            $sb = [ScriptBlock]::Create($content)
            { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $null, $null, 'Reporting', $script:XlsxFile, $script:ProcessedData, 'Light20', $null } | Should -Not -Throw
        } else {
            Set-ItResult -Skipped -Because "No mock resource of type '$script:ResType'"
        }
    }
    It 'Excel file is created' {
        if ($script:ProcessedData) {
            $script:XlsxFile | Should -Exist
        } else {
            Set-ItResult -Skipped -Because "No mock resource of type '$script:ResType'"
        }
    }
}