tests/OutputFormat.Tests.ps1

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

<#
.SYNOPSIS
    Pester tests for OutputFormat parameter and report generation functions.
 
.DESCRIPTION
    Validates:
      - The -OutputFormat ValidateSet on Invoke-AzureScout (All/Excel/Json/Markdown/AsciiDoc/MD/Adoc/PowerBI)
      - Export-AZSCMarkdownReport function exists and generates a .md file
      - Export-AZSCAsciiDocReport function exists and generates a .adoc file
      - Export-AZSCPowerBIReport function exists and generates a CSV bundle
    No live Azure authentication is required.
 
.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
    Created: 2026-02-24
    Phase: 21 — Markdown and AsciiDoc Output
#>


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:InvokeScript    = Join-Path $script:ModuleRoot 'Modules' 'Public'   'PublicFunctions' 'Invoke-AzureScout.ps1'
    # AB#5662: the inventory report renderers moved from Modules/Private/Reporting to
    # src/report/renderers/inventory, and the files took the AZSC name their functions
    # already used (Export-AZTIMarkdownReport.ps1 -> Export-AZSCMarkdownReport.ps1).
    $script:RendererRoot    = Join-Path $script:ModuleRoot 'src' 'report' 'renderers' 'inventory'
    $script:MarkdownScript  = Join-Path $script:RendererRoot 'Export-AZSCMarkdownReport.ps1'
    $script:AsciiDocScript  = Join-Path $script:RendererRoot 'Export-AZSCAsciiDocReport.ps1'
    $script:PowerBIScript   = Join-Path $script:RendererRoot 'Export-AZSCPowerBIReport.ps1'
    $script:TempDir         = Join-Path $env:TEMP 'AZSC_OutputFormatTests'

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

    # Dot-source the public function so we can inspect its parameters
    . $script:InvokeScript
    $script:Cmd = Get-Command -Name Invoke-AzureScout -ErrorAction SilentlyContinue

    # Dot-source the reporting helpers
    . $script:MarkdownScript
    . $script:AsciiDocScript
    . $script:PowerBIScript

    # Minimal mock SmaResources for report generation
    $script:MockSmaResources = @{
        VirtualMachine = @(
            [ordered]@{ Name = 'vm-test-01'; 'Subscription' = 'Test Dev'; 'Resource Group' = 'rg-test'; 'Location' = 'eastus'; 'OS' = 'Windows'; 'Size' = 'Standard_D2s_v3'; 'Status' = 'Running' }
        )
        VirtualNetwork = @(
            [ordered]@{ Name = 'vnet-test'; 'Subscription' = 'Test Dev'; 'Resource Group' = 'rg-test'; 'Location' = 'eastus'; 'Address Space' = '10.0.0.0/16' }
        )
    }

    $script:MockSubscriptions = @(
        [PSCustomObject]@{ Id = 'sub-00000001'; Name = 'Test Dev' }
    )

    # Build a minimal ReportCache folder with a stub JSON file for Markdown/AsciiDoc to read
    $script:CacheDir = Join-Path $script:TempDir 'cache'
    New-Item -ItemType Directory -Path $script:CacheDir -Force | Out-Null

    $cacheData = @{
        VirtualMachine = $script:MockSmaResources.VirtualMachine
        VirtualNetwork = $script:MockSmaResources.VirtualNetwork
    }
    $cacheData | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $script:CacheDir 'inventory.json') -Encoding UTF8
}

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

# ===================================================================
# OutputFormat parameter metadata
# ===================================================================
Describe 'OutputFormat Parameter — Metadata' {
    It 'Invoke-AzureScout function is available' {
        $script:Cmd | Should -Not -BeNullOrEmpty
    }

    It 'OutputFormat parameter exists' {
        $script:Cmd.Parameters.ContainsKey('OutputFormat') | Should -BeTrue
    }

    # AB#5024: widened from [string] to [string[]] when inventory and assessment
    # collapsed onto the single Invoke-AzureScout entry point — assessment mode
    # renders several formats in one run (-OutputFormat Html,Pptx).
    It 'OutputFormat parameter is [string[]] type' {
        $script:Cmd.Parameters['OutputFormat'].ParameterType | Should -Be ([string[]])
    }

    It 'OutputFormat ValidateSet contains "All"' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'All'
    }

    It 'OutputFormat ValidateSet contains "Excel"' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'Excel'
    }

    It 'OutputFormat ValidateSet contains "Json"' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'Json'
    }

    It 'OutputFormat ValidateSet contains "Markdown"' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'Markdown'
    }

    It 'OutputFormat ValidateSet contains "AsciiDoc"' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'AsciiDoc'
    }

    It 'OutputFormat ValidateSet contains short alias "MD"' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'MD'
    }

    It 'OutputFormat ValidateSet contains short alias "Adoc"' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'Adoc'
    }

    It 'OutputFormat ValidateSet contains "PowerBI"' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'PowerBI'
    }

    It 'OutputFormat ValidateSet covers the 8 inventory formats plus the 7 assessment-only formats' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues.Count | Should -Be 15
    }

    It 'OutputFormat ValidateSet contains the assessment-mode formats' {
        $vs = $script:Cmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        foreach ($format in @('Html', 'Pptx', 'JsonEvidence', 'React', 'Pdf', 'Word', 'EChartsDashboard')) {
            $vs.ValidValues | Should -Contain $format
        }
    }

    It 'OutputFormat default value is "All"' {
        $default = $script:Cmd.Parameters['OutputFormat'].DefaultValue
        if ($null -eq $default) {
            Set-ItResult -Skipped -Because 'Default value not accessible via reflection'
        } else {
            $default | Should -Be 'All'
        }
    }
}

# ===================================================================
# Reporting function existence
# ===================================================================
Describe 'Report Generation Functions Exist' {
    It 'Export-AZSCMarkdownReport.ps1 file exists' {
        $script:MarkdownScript | Should -Exist
    }

    It 'Export-AZSCAsciiDocReport.ps1 file exists' {
        $script:AsciiDocScript | Should -Exist
    }

    It 'Export-AZSCMarkdownReport function is loaded after dot-source' {
        Get-Command -Name Export-AZSCMarkdownReport -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
    }

    It 'Export-AZSCAsciiDocReport function is loaded after dot-source' {
        Get-Command -Name Export-AZSCAsciiDocReport -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
    }

    It 'Export-AZSCPowerBIReport.ps1 file exists' {
        $script:PowerBIScript | Should -Exist
    }

    It 'Export-AZSCPowerBIReport function is loaded after dot-source' {
        Get-Command -Name Export-AZSCPowerBIReport -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty
    }
}

# ===================================================================
# Markdown report generation
# ===================================================================
Describe 'Export-AZSCMarkdownReport — Output File Generation' {
    BeforeAll {
        $script:MdFile = Join-Path $script:TempDir 'AzureScout_Report.xlsx'  # function changes extension to .md
    }

    It 'Export-AZSCMarkdownReport does not throw with minimal inputs' {
        {
            Export-AZSCMarkdownReport `
                -ReportCache $script:CacheDir `
                -File        $script:MdFile `
                -TenantID    'tenant-test-001' `
                -Subscriptions $script:MockSubscriptions `
                -Scope       'ArmOnly'
        } | Should -Not -Throw
    }

    It 'Markdown .md file is created' {
        $mdOutput = [System.IO.Path]::ChangeExtension($script:MdFile, '.md')
        $mdOutput | Should -Exist
    }

    It 'Markdown file starts with "# Azure Scout Report"' {
        $mdOutput = [System.IO.Path]::ChangeExtension($script:MdFile, '.md')
        $first = Get-Content -Path $mdOutput | Select-Object -First 1
        $first | Should -Be '# Azure Scout Report'
    }

    It 'Markdown file contains Tenant ID entry' {
        $mdOutput = [System.IO.Path]::ChangeExtension($script:MdFile, '.md')
        $content  = Get-Content -Path $mdOutput -Raw
        $content  | Should -Match 'Tenant ID'
    }

    It 'Markdown file contains Generated date entry' {
        $mdOutput = [System.IO.Path]::ChangeExtension($script:MdFile, '.md')
        $content  = Get-Content -Path $mdOutput -Raw
        $content  | Should -Match 'Generated'
    }
}

# ===================================================================
# AsciiDoc report generation
# ===================================================================
Describe 'Export-AZSCAsciiDocReport — Output File Generation' {
    BeforeAll {
        $script:AdocFile = Join-Path $script:TempDir 'AzureScout_Report_adoc.xlsx'
    }

    It 'Export-AZSCAsciiDocReport does not throw with minimal inputs' {
        {
            Export-AZSCAsciiDocReport `
                -ReportCache $script:CacheDir `
                -File        $script:AdocFile `
                -TenantID    'tenant-test-001' `
                -Subscriptions $script:MockSubscriptions `
                -Scope       'ArmOnly'
        } | Should -Not -Throw
    }

    It 'AsciiDoc .adoc file is created' {
        $adocOutput = [System.IO.Path]::ChangeExtension($script:AdocFile, '.adoc')
        $adocOutput | Should -Exist
    }

    It 'AsciiDoc file contains document title marker (= )' {
        $adocOutput = [System.IO.Path]::ChangeExtension($script:AdocFile, '.adoc')
        $content = Get-Content -Path $adocOutput -Raw
        $content | Should -Match '= Azure Scout'
    }
}

# ===================================================================
# OutputFormat routing in Invoke-AzureScout source
# ===================================================================
Describe 'OutputFormat routing logic in Invoke-AzureScout source' {
    BeforeAll {
        $script:FunctionSource = Get-Content -Path $script:InvokeScript -Raw
    }

    It 'Source contains Markdown format routing' {
        $script:FunctionSource | Should -Match "Markdown|'MD'"
    }

    It 'Source contains AsciiDoc format routing' {
        $script:FunctionSource | Should -Match "AsciiDoc|'Adoc'"
    }

    It 'Source contains JSON export routing' {
        $script:FunctionSource | Should -Match "Json"
    }

    It 'Source contains Excel export routing (Export-Excel or Start-AZSCExcelJob)' {
        $script:FunctionSource | Should -Match 'Excel|ExcelJob'
    }

    It 'Source contains Power BI format routing' {
        $script:FunctionSource | Should -Match 'PowerBI'
    }
}

# ===================================================================
# Power BI CSV report generation
# ===================================================================
Describe 'Export-AZSCPowerBIReport — Output File Generation' {
    BeforeAll {
        $script:PbiBaseFile = Join-Path $script:TempDir 'AzureScout_PBI_Report.xlsx'

        # Build a minimal ReportCache that matches the InventoryModules folder structure
        $script:PbiCacheDir = Join-Path $script:TempDir 'pbi-cache'
        if (Test-Path $script:PbiCacheDir) { Remove-Item $script:PbiCacheDir -Recurse -Force }
        New-Item -ItemType Directory -Path $script:PbiCacheDir -Force | Out-Null

        # Create a Compute.json cache file matching the Compute folder modules
        $computeCache = @{
            VirtualMachine = @(
                [ordered]@{ Name = 'vm-pbi-01'; Subscription = 'Test Dev'; 'Resource Group' = 'rg-pbi'; Location = 'eastus'; 'Resource U' = 1 }
            )
        }
        $computeCache | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $script:PbiCacheDir 'Compute.json') -Encoding UTF8

        # Create an Identity.json cache file for Entra
        $identityCache = @{
            Users = @(
                [ordered]@{ 'Display Name' = 'Test User'; 'User Principal Name' = 'test@contoso.com'; 'Resource U' = 1 }
            )
        }
        $identityCache | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $script:PbiCacheDir 'Identity.json') -Encoding UTF8
    }

    It 'Export-AZSCPowerBIReport does not throw with minimal inputs' {
        {
            Export-AZSCPowerBIReport `
                -ReportCache $script:PbiCacheDir `
                -File        $script:PbiBaseFile `
                -TenantID    'tenant-pbi-001' `
                -Subscriptions $script:MockSubscriptions `
                -Scope       'All'
        } | Should -Not -Throw
    }

    It 'PowerBI output directory is created' {
        $pbiDir = Join-Path (Split-Path $script:PbiBaseFile -Parent) 'PowerBI'
        $pbiDir | Should -Exist
    }

    It '_metadata.csv is created' {
        $metaFile = Join-Path (Split-Path $script:PbiBaseFile -Parent) 'PowerBI' '_metadata.csv'
        $metaFile | Should -Exist
    }

    It '_metadata.csv contains TenantId' {
        $metaFile = Join-Path (Split-Path $script:PbiBaseFile -Parent) 'PowerBI' '_metadata.csv'
        $content = Import-Csv $metaFile
        ($content | Where-Object { $_.Property -eq 'TenantId' }).Value | Should -Be 'tenant-pbi-001'
    }

    It 'Subscriptions.csv is created' {
        $subsFile = Join-Path (Split-Path $script:PbiBaseFile -Parent) 'PowerBI' 'Subscriptions.csv'
        $subsFile | Should -Exist
    }

    It '_relationships.json is created with valid JSON' {
        $relFile = Join-Path (Split-Path $script:PbiBaseFile -Parent) 'PowerBI' '_relationships.json'
        $relFile | Should -Exist
        { Get-Content $relFile -Raw | ConvertFrom-Json } | Should -Not -Throw
    }

    It 'At least one Resources_*.csv file is created' {
        $pbiDir = Join-Path (Split-Path $script:PbiBaseFile -Parent) 'PowerBI'
        $csvFiles = Get-ChildItem -Path $pbiDir -Filter 'Resources_*.csv' -ErrorAction SilentlyContinue
        @($csvFiles).Count | Should -BeGreaterThan 0
    }

    It 'At least one Entra_*.csv file is created' {
        $pbiDir = Join-Path (Split-Path $script:PbiBaseFile -Parent) 'PowerBI'
        $csvFiles = Get-ChildItem -Path $pbiDir -Filter 'Entra_*.csv' -ErrorAction SilentlyContinue
        @($csvFiles).Count | Should -BeGreaterThan 0
    }

    It 'Resource CSV files contain _Category and _Module columns' {
        $pbiDir = Join-Path (Split-Path $script:PbiBaseFile -Parent) 'PowerBI'
        $csvFile = Get-ChildItem -Path $pbiDir -Filter 'Resources_*.csv' | Select-Object -First 1
        $data = Import-Csv $csvFile.FullName
        $data[0].PSObject.Properties.Name | Should -Contain '_Category'
        $data[0].PSObject.Properties.Name | Should -Contain '_Module'
    }
}