tests/Diagram.Tests.ps1

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

<#
.SYNOPSIS
    Pester tests for the Draw.io diagram generation pipeline (AB#342).

.DESCRIPTION
    Exercises Start-AZSCDiagramOrganization, Start-AZSCDiagramNetwork,
    Start-AZSCDiagramSubscription, and Set-AZSCDiagramFile end-to-end against
    small synthetic fixtures (no live Azure connection required), then
    XML-parses the merged .drawio output to assert it is a well-formed,
    multi-page mxfile/diagram/mxGraphModel document with consistent styling:

      - every vertex cell has a non-empty style and a non-zero-size mxGeometry
      - edge cells are not also marked as vertices
      - edges use consistent orthogonal routing (no legacy edgeStyle=none)
      - long resource names are truncated for the on-canvas label while the
        full value is retained in a dedicated custom attribute

    The module is imported normally (Import-Module ./AzureScout.psd1), which
    is deliberate: the assessment platform under src/ calls
    Set-StrictMode -Version Latest at file scope, and because every *.ps1
    under Modules/ and src/ is dot-sourced into the same module SessionState,
    that strict mode setting is ambient for the whole module -- including
    these diagram functions -- in real usage. Fixtures here are intentionally
    "small but shallow" (few resources, exactly one item in several
    collections) because that shape is what previously tripped
    empty-array/scalar-collapse member-enumeration errors under strict mode.

.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
#>


BeforeAll {
    $script:ModuleRoot = Split-Path -Parent $PSScriptRoot
    $script:ManifestPath = Join-Path $script:ModuleRoot 'AzureScout.psd1'
    $script:DiagramPath = Join-Path $script:ModuleRoot 'Modules' 'Public' 'PublicFunctions' 'Diagram'

    Import-Module $script:ManifestPath -Force

    $script:TestRoot = Join-Path $script:ModuleRoot 'tests' 'test-output' 'DiagramCache'
    if (Test-Path $script:TestRoot) { Remove-Item -Path $script:TestRoot -Recurse -Force }
    New-Item -ItemType Directory -Path $script:TestRoot | Out-Null

    $script:CacheDir = Join-Path $script:TestRoot 'DiagramCache'
    New-Item -ItemType Directory -Path $script:CacheDir | Out-Null
    $script:LogFile = Join-Path $script:TestRoot 'log.txt'
    $script:DDFile = Join-Path $script:TestRoot 'AzureScout.drawio'

    $script:LongVnetName = 'vnet-hub-prod-westus2-shared-services-connectivity-platform-team-01'
    $script:LongMgName = 'Very Long Management Group Display Name Used To Exercise The New Truncation Helper 001'
    $script:LongSubName = 'This Is An Extremely Long Subscription Name That Should Be Truncated For Display In The Org Diagram'

    # ---- Organization fixture: a single subscription, two levels below the
    # tenant root (a very common, shallow real-world shape) ----
    $mgEntry = [PSCustomObject]@{ name = 'mg-eng'; displayname = $script:LongMgName }
    $rootEntry = [PSCustomObject]@{ name = 'tenant root group'; displayname = 'Tenant Root Group' }

    $orgSub = [PSCustomObject]@{
        Type           = 'microsoft.resources/subscriptions'
        subscriptionid = 'sub-0001'
        id             = 'sub-0001'
        name           = $script:LongSubName
        properties     = [PSCustomObject]@{
            managementgroupancestorschain = @($mgEntry, $rootEntry)
        }
    }
    $orgRg = [PSCustomObject]@{ Type = 'microsoft.resources/subscriptions/resourcegroups'; subscriptionid = 'sub-0001'; Name = 'rg-network' }
    $script:ResourceContainers = @($orgSub, $orgRg)

    # ---- Network fixture: one VNET, one (empty/delegation-less) subnet --
    # the most common real subnet shape, and the one that previously tripped
    # empty-collection member-enumeration under strict mode.
    $subnet1 = [PSCustomObject]@{
        Name       = 'snet-default'
        id         = '/subscriptions/sub-0001/resourceGroups/rg-network/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/snet-default'
        properties = [PSCustomObject]@{
            addressPrefix                      = '10.0.0.0/24'
            networkSecurityGroup               = $null
            routeTable                         = $null
            delegations                        = @()
            resourceNavigationLinks             = @()
            serviceAssociationLinks             = @()
            applicationGatewayIPConfigurations  = @()
            ipconfigurations                    = @()
        }
    }

    $script:Vnet1 = [PSCustomObject]@{
        Name           = $script:LongVnetName
        id             = '/subscriptions/sub-0001/resourceGroups/rg-network/providers/Microsoft.Network/virtualNetworks/vnet1'
        subscriptionId = 'sub-0001'
        properties     = [PSCustomObject]@{
            addressSpace           = [PSCustomObject]@{ addressPrefixes = @('10.0.0.0/16') }
            subnets                = @($subnet1)
            virtualNetworkPeerings = @()
            enableDdosProtection   = $false
            dhcpoptions            = [PSCustomObject]@{ dnsServers = @() }
        }
    }

    $script:Job = @{
        AZVNETs = @($script:Vnet1); AZLGWs = @(); AZEXPROUTEs = @(); AZVPNSITES = @(); AZVERs = @()
        AKS = @(); VMSS = @(); NIC = @(); PrivEnd = @(); VM = @(); ARO = @(); Kusto = @(); AppGtw = @()
        Databricks = @(); AppWeb = @(); APIM = @(); LB = @(); Bastion = @(); FW = @(); NetProf = @()
        Container = @(); ANF = @(); AZVGWs = @(); AZCONs = @(); PIPs = @(); AZVWAN = @(); AZVHUB = @()
    }

    $script:Subscriptions = @([PSCustomObject]@{ id = 'sub-0001'; name = 'Sub One' })

    # ---- Subscription fixture: a single resource group with one VM ----
    $script:Resources = @(
        [PSCustomObject]@{ Type = 'microsoft.compute/virtualmachines'; subscriptionId = 'sub-0001'; resourceGroup = 'rg-network'; name = 'vm-test-01' }
    )

    function Get-DiagramXml {
        [xml]$xml = Get-Content -Path $script:DDFile -Raw
        return $xml
    }
}

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

# =====================================================================
# FILE EXISTENCE / SYNTAX
# =====================================================================
Describe 'Diagram module files exist and parse cleanly' {
    $diagramFiles = @(
        'Build-AZTIDiagramSubnet.ps1',
        'Set-AZTIDiagramFile.ps1',
        'Start-AZTIDiagramJob.ps1',
        'Start-AZTIDiagramNetwork.ps1',
        'Start-AZTIDiagramOrganization.ps1',
        'Start-AZTIDiagramSubscription.ps1',
        'Start-AZTIDrawIODiagram.ps1'
    )

    It '<_> exists' -ForEach $diagramFiles {
        Join-Path $script:DiagramPath $_ | Should -Exist
    }

    It '<_> parses without errors' -ForEach $diagramFiles {
        $filePath = Join-Path $script:DiagramPath $_
        $errors = $null
        [System.Management.Automation.Language.Parser]::ParseFile($filePath, [ref]$null, [ref]$errors) | Out-Null
        $errors | Should -BeNullOrEmpty
    }
}

# =====================================================================
# GENERATION (no live Azure connection required)
# =====================================================================
Describe 'Draw.io diagram generation end-to-end' {

    It 'Start-AZSCDiagramOrganization generates Organization.xml without throwing' {
        { Start-AZSCDiagramOrganization -ResourceContainers $script:ResourceContainers -DiagramCache $script:CacheDir -LogFile $script:LogFile } |
            Should -Not -Throw
        Join-Path $script:CacheDir 'Organization.xml' | Should -Exist
    }

    It 'Organization.xml is well-formed XML with an mxfile root' {
        $orgFile = Join-Path $script:CacheDir 'Organization.xml'
        { [xml](Get-Content -Path $orgFile -Raw) } | Should -Not -Throw
        [xml]$orgXml = Get-Content -Path $orgFile -Raw
        $orgXml.mxfile | Should -Not -BeNullOrEmpty
        $orgXml.mxfile.diagram.name | Should -Be 'Organization'
    }

    It 'Start-AZSCDiagramNetwork generates the main .drawio file without throwing' {
        { Start-AZSCDiagramNetwork -Subscriptions $script:Subscriptions -Job $script:Job -Advisories @() `
            -DiagramCache $script:CacheDir -FullEnvironment $false -DDFile $script:DDFile `
            -XMLFiles @() -LogFile $script:LogFile -Automation ([switch]$true) } | Should -Not -Throw
        $script:DDFile | Should -Exist
    }

    It 'the main .drawio file is well-formed XML with a Network Topology page' {
        { [xml](Get-Content -Path $script:DDFile -Raw) } | Should -Not -Throw
        [xml]$netXml = Get-Content -Path $script:DDFile -Raw
        $netXml.mxfile.diagram.name | Should -Be 'Network Topology'
    }

    It 'Start-AZSCDiagramSubscription generates Subscriptions.xml without throwing' {
        { Start-AZSCDiagramSubscription -Subscriptions $script:Subscriptions -Resources $script:Resources `
            -DiagramCache $script:CacheDir -LogFile $script:LogFile } | Should -Not -Throw
        Join-Path $script:CacheDir 'Subscriptions.xml' | Should -Exist
    }

    It 'Subscriptions.xml is well-formed XML with one page per subscription' {
        $subFile = Join-Path $script:CacheDir 'Subscriptions.xml'
        { [xml](Get-Content -Path $subFile -Raw) } | Should -Not -Throw
        [xml]$subXml = Get-Content -Path $subFile -Raw
        $subXml.mxfile.diagram.name | Should -Be 'Sub One'
    }

    It 'Set-AZSCDiagramFile merges Organization.xml and Subscriptions.xml into the main file as additional pages' {
        $xmlFiles = @(
            (Join-Path $script:CacheDir 'Organization.xml'),
            (Join-Path $script:CacheDir 'Subscriptions.xml')
        )

        { Set-AZSCDiagramFile -XMLFiles $xmlFiles -DDFile $script:DDFile -LogFile $script:LogFile } | Should -Not -Throw

        # The merge function deletes each source cache file once merged.
        Join-Path $script:CacheDir 'Organization.xml' | Should -Not -Exist
        Join-Path $script:CacheDir 'Subscriptions.xml' | Should -Not -Exist

        $mergeErrors = Get-Content $script:LogFile | Select-String -Pattern 'Error merging'
        $mergeErrors | Should -BeNullOrEmpty
    }
}

# =====================================================================
# MERGED OUTPUT QUALITY (well-formed XML + consistent styling)
# =====================================================================
Describe 'Merged .drawio output quality' {

    It 'is still well-formed XML after the merge' {
        { Get-DiagramXml } | Should -Not -Throw
    }

    It 'contains all three diagram pages' {
        $xml = Get-DiagramXml
        $names = $xml.mxfile.diagram.name
        $names | Should -Contain 'Network Topology'
        $names | Should -Contain 'Organization'
        $names | Should -Contain 'Sub One'
    }

    It 'has at least one vertex cell' {
        $xml = Get-DiagramXml
        $vertices = $xml.SelectNodes('//mxCell[@vertex="1"]')
        $vertices.Count | Should -BeGreaterThan 0
    }

    It 'every vertex cell has a non-empty style and non-zero-size geometry' {
        $xml = Get-DiagramXml
        $vertices = $xml.SelectNodes('//mxCell[@vertex="1"]')
        foreach ($vertex in $vertices) {
            $vertex.style | Should -Not -BeNullOrEmpty -Because "cell id=$($vertex.id) should be styled"
            $geometry = $vertex.mxGeometry
            $geometry | Should -Not -BeNullOrEmpty -Because "cell id=$($vertex.id) should carry geometry"
            [double]$geometry.width | Should -BeGreaterThan 0 -Because "cell id=$($vertex.id) should not be zero-width"
            [double]$geometry.height | Should -BeGreaterThan 0 -Because "cell id=$($vertex.id) should not be zero-height"
        }
    }

    It 'never marks the same cell as both an edge and a vertex' {
        $xml = Get-DiagramXml
        $badCells = $xml.SelectNodes('//mxCell[@edge="1" and @vertex="1"]')
        $badCells.Count | Should -Be 0
    }

    It 'uses consistent orthogonal edge routing (no legacy edgeStyle=none)' {
        $xml = Get-DiagramXml
        $edges = $xml.SelectNodes('//mxCell[@edge="1"]')
        $edges.Count | Should -BeGreaterThan 0
        foreach ($edge in $edges) {
            $edge.style | Should -Match 'edgeStyle=orthogonalEdgeStyle'
        }
    }

    It 'truncates the long VNET name in the label but retains the full value in Full_VNET_Name' {
        $xml = Get-DiagramXml
        $vnetNode = $xml.SelectSingleNode('//object[@Full_VNET_Name]')
        $vnetNode | Should -Not -BeNullOrEmpty
        $vnetNode.Full_VNET_Name | Should -Be $script:LongVnetName
        $vnetNode.label | Should -Not -Be $script:LongVnetName
        ($vnetNode.label -split "`n")[0].Length | Should -BeLessOrEqual 40
    }

    It 'truncates the long subscription name in the Organization page but retains it in Full_Subscription_Name' {
        $xml = Get-DiagramXml
        $subNode = $xml.SelectSingleNode('//object[@Full_Subscription_Name]')
        $subNode | Should -Not -BeNullOrEmpty
        $subNode.Full_Subscription_Name | Should -Be $script:LongSubName
        $subNode.label | Should -Not -Be $script:LongSubName
    }

    It 'truncates the long management group title on its container swimlane' {
        $xml = Get-DiagramXml
        $mgCell = $xml.SelectSingleNode("//mxCell[contains(@style,'swimlane') and string-length(@value) > 0 and string-length(@value) < string-length('$($script:LongMgName)')]")
        $mgCell | Should -Not -BeNullOrEmpty
    }
}

# =====================================================================
# REGRESSION: subnet with no recognizable resource type ("EmptySubnet")
# =====================================================================
Describe 'Build-AZSCDiagramSubnet handles a subnet with no matching resource type' {

    BeforeAll {
        # A subnet with no delegation, no links, and no IP configurations at all
        # drives Get-AZSCDiagramSubnetResourceType to its 'EmptySubnet' fallback,
        # which none of the branches in the nested Get-AZSCDiagramSubnetResourcesName
        # switch match. Before the fix, $ResNames/$RESNames was never assigned in
        # that function's scope for this case, and `return $ResNames` threw "the
        # variable '$ResNames' cannot be retrieved because it has not been set"
        # under Set-StrictMode -Version Latest -- breaking the diagram for any
        # ordinary unused/empty subnet (an extremely common real-world shape).
        $script:EmptySubnetFixture = [PSCustomObject]@{
            Name       = 'snet-truly-empty'
            id         = '/subscriptions/sub-0001/resourceGroups/rg-network/providers/Microsoft.Network/virtualNetworks/vnet-empty/subnets/snet-truly-empty'
            properties = [PSCustomObject]@{
                addressPrefix                      = '10.0.2.0/24'
                networkSecurityGroup               = $null
                routeTable                         = $null
                delegations                        = @()
                resourceNavigationLinks            = @()
                serviceAssociationLinks            = @()
                applicationGatewayIPConfigurations = @()
                ipconfigurations                   = @()
            }
        }

        $script:EmptySubnetVnet = [PSCustomObject]@{
            Name           = 'vnet-empty-test'
            id             = '/subscriptions/sub-0001/resourceGroups/rg-network/providers/Microsoft.Network/virtualNetworks/vnet-empty'
            subscriptionId = 'sub-0001'
            properties     = [PSCustomObject]@{
                addressSpace           = [PSCustomObject]@{ addressPrefixes = @('10.0.2.0/16') }
                subnets                = @($script:EmptySubnetFixture)
                virtualNetworkPeerings = @()
                enableDdosProtection   = $false
                dhcpoptions            = [PSCustomObject]@{ dnsServers = @() }
            }
        }

        # Empty on every resource-type bucket the nested resolver switches on, so
        # none of its branches match and $ResNames is left to fall through.
        $script:EmptyJob = @{
            AZVNETs = @($script:EmptySubnetVnet); AZLGWs = @(); AZEXPROUTEs = @(); AZVPNSITES = @(); AZVERs = @()
            AKS = @(); VMSS = @(); NIC = @(); PrivEnd = @(); VM = @(); ARO = @(); Kusto = @(); AppGtw = @()
            Databricks = @(); AppWeb = @(); APIM = @(); LB = @(); Bastion = @(); FW = @(); NetProf = @()
            Container = @(); ANF = @(); AZVGWs = @(); AZCONs = @(); PIPs = @(); AZVWAN = @(); AZVHUB = @()
        }
    }

    It 'does not throw when the subnet has no delegation, links, or IP configurations and no resource matches it' {
        { Build-AZSCDiagramSubnet -SubnetLocation 0 -VNET $script:EmptySubnetVnet -IDNum 0 `
            -DiagramCache $script:CacheDir -ContainerID 1 -Job $script:EmptyJob -LogFile $script:LogFile } |
            Should -Not -Throw
    }

    It 'still produces a subnet cache .xml file for the empty subnet' {
        Get-ChildItem -Path $script:CacheDir -Filter '*.xml' | Should -Not -BeNullOrEmpty
    }
}