Tests/Unit/Export-SPCReport.Tests.ps1

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

BeforeAll {
    . (Join-Path $PSScriptRoot '../../Private/Test-SPCConnection.ps1')
    . (Join-Path $PSScriptRoot '../../Public/Report/Export-SPCReport.ps1')

    $script:SPCContext = [PSCustomObject]@{
        TenantName           = 'contoso'
        AuthMethod           = 'Interactive'
        ConnectedAt          = (Get-Date).ToUniversalTime()
        PnPContext           = $null
        GraphAccessToken     = 'fake-token'
        _ClientId            = $null
        _CertificatePath     = $null
        _CertificatePassword = $null
        _ClientSecret        = $null
    }

    # ── Shared fake orphan objects ───────────────────────────────────────────────

    function New-FakeOrphan {
        param(
            [string] $UPN         = 'jdoe@contoso.com',
            [string] $OrphanType  = 'Deleted',
            [string] $RiskLevel   = 'HIGH',
            [string] $SiteUrl     = 'https://contoso.sharepoint.com/sites/HR'
        )
        $o = [PSCustomObject][ordered]@{
            SiteUrl              = $SiteUrl
            SiteTitle            = 'Human Resources'
            UserId               = 42
            LoginName            = "i:0#.f|membership|$UPN"
            DisplayName          = 'John Doe'
            Email                = $UPN
            UPN                  = $UPN
            OrphanType           = $OrphanType
            RiskLevel            = $RiskLevel
            HasDirectPermissions = $true
            GroupMemberships     = @('HR Members', 'Site Owners')
            LastActivityDate     = [datetime]'2026-01-15T10:00:00Z'
            DetectedAt           = [datetime]'2026-06-22T00:00:00Z'
        }
        $o.PSObject.TypeNames.Insert(0, 'SPC.OrphanedUser')
        $o
    }

    $script:FakeOrphans = @(
        (New-FakeOrphan -UPN 'alice@contoso.com' -OrphanType 'Deleted'      -RiskLevel 'HIGH')
        (New-FakeOrphan -UPN 'bob@contoso.com'   -OrphanType 'SoftDeleted'  -RiskLevel 'MEDIUM')
        (New-FakeOrphan -UPN 'carol@contoso.com' -OrphanType 'GuestOrphaned' -RiskLevel 'LOW')
    )

    # Temp file helper — cleans up after each test
    $script:TempFiles = [System.Collections.Generic.List[string]]::new()

    function Get-TempReportPath {
        param([string] $Extension = 'html')
        $p = [System.IO.Path]::Combine(
            [System.IO.Path]::GetTempPath(),
            "SPCleanTest_$([System.IO.Path]::GetRandomFileName()).$Extension"
        )
        $script:TempFiles.Add($p)
        $p
    }
}

AfterAll {
    foreach ($f in $script:TempFiles) {
        if (Test-Path $f) { Remove-Item $f -ErrorAction SilentlyContinue }
    }
}

Describe 'Export-SPCReport' {

    Context 'AC-05: HTML report generation' {
        It 'AC-05: creates an HTML file at the specified OutputPath' {
            $path   = Get-TempReportPath 'html'
            $script:FakeOrphans | Export-SPCReport -Format HTML -OutputPath $path | Out-Null
            Test-Path $path | Should -BeTrue
        }

        It 'AC-05: HTML file contains DOCTYPE declaration' {
            $path = Get-TempReportPath 'html'
            $script:FakeOrphans | Export-SPCReport -Format HTML -OutputPath $path | Out-Null
            Get-Content $path -Raw | Should -Match '<!DOCTYPE html>'
        }

        It 'AC-05: HTML file contains all required table headers' {
            $path = Get-TempReportPath 'html'
            $script:FakeOrphans | Export-SPCReport -Format HTML -OutputPath $path | Out-Null
            $html = Get-Content $path -Raw
            foreach ($header in @('SiteUrl','DisplayName','UPN','OrphanType','RiskLevel')) {
                $html | Should -Match $header
            }
        }

        It 'AC-05: HIGH risk badge uses correct color #dc3545' {
            $high = @(New-FakeOrphan -RiskLevel 'HIGH')
            $path = Get-TempReportPath 'html'
            $high | Export-SPCReport -Format HTML -OutputPath $path | Out-Null
            Get-Content $path -Raw | Should -Match '#dc3545'
        }

        It 'AC-05: MEDIUM risk badge uses correct color #ffc107' {
            $med  = @(New-FakeOrphan -RiskLevel 'MEDIUM' -OrphanType 'SoftDeleted')
            $path = Get-TempReportPath 'html'
            $med  | Export-SPCReport -Format HTML -OutputPath $path | Out-Null
            Get-Content $path -Raw | Should -Match '#ffc107'
        }

        It 'AC-05: LOW risk badge uses correct color #28a745' {
            $low  = @(New-FakeOrphan -RiskLevel 'LOW' -OrphanType 'GuestOrphaned')
            $path = Get-TempReportPath 'html'
            $low  | Export-SPCReport -Format HTML -OutputPath $path | Out-Null
            Get-Content $path -Raw | Should -Match '#28a745'
        }

        It 'AC-05: HTML file contains inline JavaScript (sortable columns)' {
            $path = Get-TempReportPath 'html'
            $script:FakeOrphans | Export-SPCReport -Format HTML -OutputPath $path | Out-Null
            Get-Content $path -Raw | Should -Match '<script>'
        }

        It 'AC-05: HTML report footer contains tenant name' {
            $path = Get-TempReportPath 'html'
            $script:FakeOrphans | Export-SPCReport -Format HTML -OutputPath $path | Out-Null
            Get-Content $path -Raw | Should -Match 'contoso'
        }

        It 'AC-05: -IncludeSummary adds summary section to HTML' {
            $path = Get-TempReportPath 'html'
            $script:FakeOrphans | Export-SPCReport -Format HTML -OutputPath $path -IncludeSummary | Out-Null
            Get-Content $path -Raw | Should -Match 'Total Orphans'
        }
    }

    Context 'AC-05: CSV report generation' {
        It 'AC-05: creates a CSV file' {
            $path = Get-TempReportPath 'csv'
            $script:FakeOrphans | Export-SPCReport -Format CSV -OutputPath $path | Out-Null
            Test-Path $path | Should -BeTrue
        }

        It 'AC-05: CSV file has correct header row' {
            $path = Get-TempReportPath 'csv'
            $script:FakeOrphans | Export-SPCReport -Format CSV -OutputPath $path | Out-Null
            $firstLine = (Get-Content $path -TotalCount 1)
            $firstLine | Should -Be 'SiteUrl,SiteTitle,DisplayName,Email,UPN,OrphanType,RiskLevel,HasDirectPermissions,GroupMemberships,LastActivityDate,DetectedAt'
        }

        It 'AC-05: CSV GroupMemberships column is semicolon-delimited' {
            $path = Get-TempReportPath 'csv'
            $script:FakeOrphans | Export-SPCReport -Format CSV -OutputPath $path | Out-Null
            $content = Get-Content $path -Raw
            $content | Should -Match 'HR Members;Site Owners'
        }

        It 'AC-05: CSV file starts with UTF-8 BOM' {
            $path  = Get-TempReportPath 'csv'
            $script:FakeOrphans | Export-SPCReport -Format CSV -OutputPath $path | Out-Null
            $bytes = [System.IO.File]::ReadAllBytes($path)
            # UTF-8 BOM: EF BB BF
            $bytes[0] | Should -Be 0xEF
            $bytes[1] | Should -Be 0xBB
            $bytes[2] | Should -Be 0xBF
        }
    }

    Context 'AC-05: JSON report generation' {
        It 'AC-05: creates a JSON file' {
            $path = Get-TempReportPath 'json'
            $script:FakeOrphans | Export-SPCReport -Format JSON -OutputPath $path | Out-Null
            Test-Path $path | Should -BeTrue
        }

        It 'AC-05: JSON file is valid JSON' {
            $path = Get-TempReportPath 'json'
            $script:FakeOrphans | Export-SPCReport -Format JSON -OutputPath $path | Out-Null
            { Get-Content $path -Raw | ConvertFrom-Json } | Should -Not -Throw
        }
    }

    Context 'SPC.ReportResult output object' {
        It 'AC-05: output has TypeName SPC.ReportResult' {
            $path   = Get-TempReportPath 'html'
            $result = $script:FakeOrphans | Export-SPCReport -Format HTML -OutputPath $path
            $result.PSObject.TypeNames | Should -Contain 'SPC.ReportResult'
        }

        It 'AC-05: TotalOrphansReported matches input count' {
            $path   = Get-TempReportPath 'csv'
            $result = $script:FakeOrphans | Export-SPCReport -Format CSV -OutputPath $path
            $result.TotalOrphansReported | Should -Be 3
        }

        It 'AC-05: FilePath in result resolves to the written file' {
            $path   = Get-TempReportPath 'csv'
            $result = $script:FakeOrphans | Export-SPCReport -Format CSV -OutputPath $path
            $result.FilePath | Should -Be $result.FilePath   # existence verified by Test-Path above
            Test-Path $result.FilePath | Should -BeTrue
        }

        It 'AC-05: -PassThru pipes input objects through after writing' {
            $path       = Get-TempReportPath 'csv'
            $allOutputs = @($script:FakeOrphans | Export-SPCReport -Format CSV -OutputPath $path -PassThru)
            # First object is SPC.ReportResult, remaining are SPC.OrphanedUser
            $allOutputs.Count | Should -BeGreaterThan 1
            $allOutputs | Where-Object { $_.PSObject.TypeNames -contains 'SPC.OrphanedUser' } |
                Should -HaveCount 3
        }
    }

    Context 'Default path generation' {
        It 'AC-05: auto-generated path includes tenant name and timestamp when -OutputPath omitted' {
            $result = $script:FakeOrphans | Export-SPCReport -Format HTML
            $result.FilePath | Should -Match 'SPClean_OrphanedUsers_contoso_\d{12}\.html'
            $script:TempFiles.Add($result.FilePath)
        }
    }

    Context 'Warning on empty input' {
        It 'AC-05: emits a warning and no result when input is empty' {
            $result = @() | Export-SPCReport -Format CSV -WarningVariable wv -WarningAction SilentlyContinue
            $result | Should -BeNullOrEmpty
            $wv     | Should -Match 'No input objects received'
        }
    }

    Context 'AC-12: no credentials in output streams' {
        It 'AC-12: verbose and information streams contain no credential strings' {
            $path = Get-TempReportPath 'html'
            $out  = & {
                $script:SPCContext = $script:SPCContext
                $script:FakeOrphans | Export-SPCReport -Format HTML -OutputPath $path -Verbose 4>&1 5>&1
            } 2>&1
            $out | ForEach-Object { [string]$_ } |
                Should -Not -Match 'password|secret|token|pfx|credential'
        }
    }
}