tests/FinOpsVMMetrics.Tests.ps1

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

BeforeAll {
    $root = Split-Path -Parent $PSScriptRoot
    # Dot-source the private (and supporting) functions directly so we can test them
    # without an Azure connection.
    . (Join-Path $root 'Private/Measure-Percentile.ps1')
    . (Join-Path $root 'Private/ConvertTo-VMPerformanceRow.ps1')
    . (Join-Path $root 'Private/Write-MetricOutput.ps1')
    . (Join-Path $root 'Private/Get-VMMetricsBlobName.ps1')
    . (Join-Path $root 'Private/New-VMMetricsStorageContext.ps1')
    . (Join-Path $root 'Private/Test-VMExcluded.ps1')
    . (Join-Path $root 'Private/Read-ResourceIdList.ps1')
    . (Join-Path $root 'Public/Publish-VMPerformanceData.ps1')
}

Describe 'Measure-Percentile' {
    It 'computes P95 with linear interpolation (Kusto-compatible)' {
        # 9*0.45 + 10*0.55 = 9.55 (rounded to output precision; raw double is 9.5499...).
        [math]::Round((Measure-Percentile -Value (1..10) -Percentile 95), 2) | Should -Be 9.55
    }
    It 'computes the median (P50)' {
        Measure-Percentile -Value (1..10) -Percentile 50 | Should -Be 5.5
    }
    It 'returns the max at P100 and the min at P0' {
        Measure-Percentile -Value (1..10) -Percentile 100 | Should -Be 10
        Measure-Percentile -Value (1..10) -Percentile 0   | Should -Be 1
    }
    It 'returns the single value when only one sample' {
        Measure-Percentile -Value @(42) -Percentile 95 | Should -Be 42
    }
    It 'returns $null for an empty collection' {
        Measure-Percentile -Value @() -Percentile 95 | Should -BeNullOrEmpty
    }
    It 'ignores nulls in the sample set' {
        Measure-Percentile -Value @(1, $null, 2, $null, 3) -Percentile 100 | Should -Be 3
    }
    It 'is order-independent' {
        $a = Measure-Percentile -Value (1..10) -Percentile 95
        $b = Measure-Percentile -Value (10..1) -Percentile 95
        $a | Should -Be $b
    }
}

Describe 'ConvertTo-VMPerformanceRow' {
    BeforeAll {
        $jan = [datetime]'2026-01-15T10:00:00Z'
        $feb = [datetime]'2026-02-15T10:00:00Z'
        $script:cpu = @(
            [pscustomobject]@{ TimeStamp = $jan; Average = 10.0 }
            [pscustomobject]@{ TimeStamp = $jan; Average = 30.0 }
            [pscustomobject]@{ TimeStamp = $feb; Average = 50.0 }
        )
        # 4 GiB available out of 8 GiB SKU -> 50% committed proxy, 4096 MB available.
        $script:mem = @(
            [pscustomobject]@{ TimeStamp = $jan; Average = 4GB }
            [pscustomobject]@{ TimeStamp = $jan; Average = 4GB }
        )
    }

    It 'emits one CPU row per calendar month' {
        $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint $cpu -MemPoint @()
        $cpuRows = $rows | Where-Object CounterName -eq '% Processor Time'
        $cpuRows.Count | Should -Be 2
        ($cpuRows.Date | Sort-Object) | Should -Be @('2026-01-01', '2026-02-01')
    }

    It 'aggregates CPU stats within a month' {
        $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint $cpu -MemPoint @()
        $janCpu = $rows | Where-Object { $_.CounterName -eq '% Processor Time' -and $_.Date -eq '2026-01-01' }
        $janCpu.Min     | Should -Be 10
        $janCpu.Max     | Should -Be 30
        $janCpu.Average | Should -Be 20
    }

    It 'emits exact Available MBytes' {
        $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint @() -MemPoint $mem -TotalRamMB 8192
        $avail = $rows | Where-Object CounterName -eq 'Available MBytes'
        $avail.Count        | Should -Be 1
        $avail.Average      | Should -Be 4096
    }

    It 'derives the committed-memory proxy from total RAM' {
        $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint @() -MemPoint $mem -TotalRamMB 8192
        $comm = $rows | Where-Object CounterName -eq '% Committed Bytes In Use'
        $comm.Count    | Should -Be 1
        $comm.Average  | Should -Be 50
    }

    It 'skips the committed proxy when total RAM is unknown' {
        $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint @() -MemPoint $mem
        ($rows | Where-Object CounterName -eq '% Committed Bytes In Use') | Should -BeNullOrEmpty
        ($rows | Where-Object CounterName -eq 'Available MBytes')         | Should -Not -BeNullOrEmpty
    }

    It 'produces the full VMPerformance schema on every row' {
        $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint $cpu -MemPoint $mem -TotalRamMB 8192 `
            -ResourceId '/subscriptions/S/resourceGroups/RG/providers/Microsoft.Compute/virtualMachines/vm1' -SkuName 'Standard_D4as_v5'
        $expected = 'resourceId', 'vmName', 'skuName', 'Date', 'CounterName', 'Min', 'Median', 'Percentile95', 'Percentile99', 'Max', 'Average', 'SampleCount'
        foreach ($r in $rows) {
            ($r.PSObject.Properties.Name) | Should -Be $expected
        }
    }

    It 'lowercases the resourceId for reliable Kusto joins' {
        $rows = ConvertTo-VMPerformanceRow -VMName 'vm1' -CpuPoint $cpu -MemPoint @() `
            -ResourceId '/subscriptions/S/resourceGroups/RG/providers/Microsoft.Compute/virtualMachines/MyVM'
        $rows[0].resourceId | Should -Be '/subscriptions/s/resourcegroups/rg/providers/microsoft.compute/virtualmachines/myvm'
    }
}

Describe 'Write-MetricOutput' {
    BeforeAll {
        $script:rows = ConvertTo-VMPerformanceRow -VMName 'vm1' `
            -CpuPoint @([pscustomobject]@{ TimeStamp = [datetime]'2026-01-15T10:00:00Z'; Average = 25.0 }) `
            -MemPoint @()
        $script:outDir = Join-Path ([System.IO.Path]::GetTempPath()) ("vmmetrics-test-" + [guid]::NewGuid())
        New-Item -ItemType Directory -Path $outDir -Force | Out-Null
    }
    AfterAll {
        if (Test-Path $script:outDir) { Remove-Item $script:outDir -Recurse -Force }
    }

    It 'writes a CSV with the rows' {
        $path = Join-Path $outDir 'out.csv'
        $written = Write-MetricOutput -Row $rows -Path $path -Format CSV
        Test-Path $written | Should -BeTrue
        (Import-Csv $written).Count | Should -Be $rows.Count
    }

    It 'writes a sidecar manifest that flags the memory proxy' {
        $path = Join-Path $outDir 'out2.csv'
        Write-MetricOutput -Row $rows -Path $path -Format CSV -Manifest @{ windowDays = 30 } | Out-Null
        $manifest = Get-Content (Join-Path $outDir 'out2.manifest.json') -Raw | ConvertFrom-Json
        $manifest.windowDays | Should -Be 30
        $manifest.counters.'% Committed Bytes In Use' | Should -Match 'PROXY'
    }

    It 'falls back to CSV with a warning when Parquet is unavailable' {
        $path = Join-Path $outDir 'out3.parquet'
        $written = Write-MetricOutput -Row $rows -Path $path -Format Parquet -WarningAction SilentlyContinue
        $written | Should -BeLike '*.csv'
    }
}

Describe 'Get-VMMetricsBlobName' {
    It 'builds {customer}/{date}/{file}' {
        Get-VMMetricsBlobName -Customer 'Demo' -Date '2026-06-11' -FileName 'VMPerformance-2026-06-11.csv' |
            Should -Be 'Demo/2026-06-11/VMPerformance-2026-06-11.csv'
    }
    It 'keeps spaces in real customer names' {
        Get-VMMetricsBlobName -Customer 'WorkSafe BC' -Date '2026-06-11' -FileName 'f.csv' |
            Should -Be 'WorkSafe BC/2026-06-11/f.csv'
    }
    It 'rejects a malformed date' {
        { Get-VMMetricsBlobName -Customer 'Demo' -Date '11.06.2026' -FileName 'f.csv' } | Should -Throw
    }
}

Describe 'Publish-VMPerformanceData' {
    BeforeAll {
        $script:pubDir = Join-Path ([System.IO.Path]::GetTempPath()) ("vmmetrics-pub-" + [guid]::NewGuid())
        New-Item -ItemType Directory -Path $pubDir -Force | Out-Null
        $script:csv = Join-Path $pubDir 'VMPerformance-2026-06-11.csv'
        'vmName,Date,CounterName' | Set-Content $csv
        'vm1,2026-06-01,% Processor Time' | Add-Content $csv
        Set-Content (Join-Path $pubDir 'VMPerformance-2026-06-11.manifest.json') '{"rowCount":1}'
        # A real (offline) storage context so the mocked Set-AzStorageBlobContent's
        # -Context type transform passes. New-AzStorageContext builds it locally, no network.
        $script:fakeCtx = New-AzStorageContext -StorageAccountName 'acct' `
            -StorageAccountKey ([Convert]::ToBase64String([byte[]](1..64)))
    }
    AfterAll {
        if (Test-Path $script:pubDir) { Remove-Item $script:pubDir -Recurse -Force }
    }

    It 'uploads CSV + manifest to {customer}/{date}/ using the parsed date' {
        Mock New-VMMetricsStorageContext { $script:fakeCtx }
        # Pester exposes the bound parameters ($Container, $Blob) as variables in the mock body.
        Mock Set-AzStorageBlobContent {
            [pscustomobject]@{ ICloudBlob = [pscustomobject]@{ Uri = [pscustomobject]@{ AbsoluteUri = "https://acct/$Container/$Blob" } } }
        }

        $res = Publish-VMPerformanceData -Path $csv -Customer 'Demo' `
            -StorageAccountName 'acct' -StorageAccountResourceGroup 'rg'

        $res.BlobUri     | Should -Be 'https://acct/vmperformance/Demo/2026-06-11/VMPerformance-2026-06-11.csv'
        $res.ManifestUri | Should -Be 'https://acct/vmperformance/Demo/2026-06-11/VMPerformance-2026-06-11.manifest.json'
        Should -Invoke Set-AzStorageBlobContent -Times 2
    }
}

Describe 'New-VMMetricsStorageContext (SAS auth)' {
    It 'uses the SAS token and skips key fetch + container ops' {
        Mock New-AzStorageContext { 'ctx-from-sas' } -ParameterFilter { $SasToken }
        Mock Get-AzStorageAccountKey { throw 'key fetch should not be called with a SAS token' }
        Mock Get-AzStorageContainer { throw 'container ops should not be called with a SAS token' }
        Mock New-AzStorageContainer { throw 'container ops should not be called with a SAS token' }

        $ctx = New-VMMetricsStorageContext -StorageAccountName 'acct' -ContainerName 'vmperformance' -SasToken '?sv=x&sig=y'

        $ctx | Should -Be 'ctx-from-sas'
        Should -Invoke Get-AzStorageAccountKey -Times 0
        Should -Invoke New-AzStorageContainer -Times 0
    }
}

Describe 'Test-VMExcluded' {
    BeforeAll {
        function MakeRec {
            param($Name = 'vm', $Rg = 'rg-prod', $Sku = 'Standard_D4as_v5',
                  $Vmss = '', $Priority = 'Regular', $Tags = @{}, $Created = $null)
            [pscustomobject]@{
                Id = "/subscriptions/s/resourceGroups/$Rg/providers/Microsoft.Compute/virtualMachines/$Name"
                Name = $Name; ResourceGroup = $Rg; Location = 'westeurope'; SkuName = $Sku
                Tags = $Tags; Vmss = $Vmss; Priority = $Priority; Created = $Created
            }
        }
        $script:defaults = @{
            ExcludeResourceGroupPattern = @('^databricks-rg-', '^MC_')
            ExcludeTag                  = @{ Vendor = 'Databricks' }
        }
    }

    It 'keeps a normal standalone prod VM' {
        Test-VMExcluded -VM (MakeRec) @defaults | Should -BeNullOrEmpty
    }
    It 'excludes a VMSS (Flex) member' {
        Test-VMExcluded -VM (MakeRec -Vmss '/subscriptions/s/.../vmss1') @defaults | Should -Be 'VMSS member'
    }
    It 'keeps a VMSS member when -IncludeScaleSetMember' {
        Test-VMExcluded -VM (MakeRec -Vmss '/x') -IncludeScaleSetMember @defaults | Should -BeNullOrEmpty
    }
    It 'excludes a Spot VM' {
        Test-VMExcluded -VM (MakeRec -Priority 'Spot') @defaults | Should -Be 'Spot'
    }
    It 'excludes a Databricks managed resource group' {
        Test-VMExcluded -VM (MakeRec -Rg 'databricks-rg-myws-abc123') @defaults | Should -Match 'databricks'
    }
    It 'excludes an AKS node resource group' {
        Test-VMExcluded -VM (MakeRec -Rg 'MC_rg_cluster_westeurope') @defaults | Should -Match 'MC_'
    }
    It 'excludes by Vendor=Databricks tag (case-insensitive)' {
        Test-VMExcluded -VM (MakeRec -Tags @{ vendor = 'databricks' }) @defaults | Should -Be 'tag vendor=databricks'
    }
    It 'excludes by a custom name pattern' {
        Test-VMExcluded -VM (MakeRec -Name 'spark-worker-7') -ExcludeNamePattern 'worker' | Should -Match 'worker'
    }
    It 'excludes VMs younger than MinAgeDays' {
        $now = [datetime]'2026-06-12T00:00:00Z'
        Test-VMExcluded -VM (MakeRec -Created $now.AddDays(-2)) -MinAgeDays 7 -Now $now | Should -Match 'younger'
    }
    It 'keeps VMs older than MinAgeDays' {
        $now = [datetime]'2026-06-12T00:00:00Z'
        Test-VMExcluded -VM (MakeRec -Created $now.AddDays(-30)) -MinAgeDays 7 -Now $now | Should -BeNullOrEmpty
    }
}

Describe 'Read-ResourceIdList' {
    BeforeAll {
        $script:dir = Join-Path ([System.IO.Path]::GetTempPath()) ("vmm-ids-" + [guid]::NewGuid())
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        $script:vmId  = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-app-01'
        $script:vmId2 = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-app-02'
    }
    AfterAll { if (Test-Path $script:dir) { Remove-Item $script:dir -Recurse -Force } }

    It 'reads a CSV with a ResourceId column (ignores other columns)' {
        $p = Join-Path $dir 'sel.csv'
        "ResourceId,Sku,RunFraction`n$vmId,Standard_D4as_v5,0.99`n$vmId2,Standard_D2s_v5,0.97" | Set-Content $p
        $ids = Read-ResourceIdList -Path $p
        $ids.Count | Should -Be 2
        $ids[0] | Should -Be $vmId
    }
    It 'reads a plain one-id-per-line file (with comments/blanks)' {
        $p = Join-Path $dir 'sel.txt'
        "# my list`n$vmId`n`n$vmId2" | Set-Content $p
        (Read-ResourceIdList -Path $p).Count | Should -Be 2
    }
    It 'drops non-VM and malformed lines, and de-duplicates' {
        $p = Join-Path $dir 'mixed.txt'
        "$vmId`n$vmId`n/subscriptions/x/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa`ngarbage" | Set-Content $p
        $ids = @(Read-ResourceIdList -Path $p)
        $ids.Count | Should -Be 1
        $ids[0] | Should -Be $vmId
    }
    It 'throws when the file is missing' {
        { Read-ResourceIdList -Path (Join-Path $dir 'nope.csv') } | Should -Throw
    }
}