Tests/VSAModule.PublicFunctions.Tests.ps1

$ModuleRoot = Split-Path -Path (Split-Path -Parent $PSScriptRoot)
$ModulePath = Join-Path -Path $ModuleRoot -ChildPath 'VSAModule.psd1'

BeforeAll {
    $script:ModuleRoot = Split-Path -Path (Split-Path -Parent $PSCommandPath)
    Import-Module (Join-Path $script:ModuleRoot 'VSAModule.psd1') -Force
}

Describe "New-VSAAdminTask - sends a body (T-6.2)" {
    It "includes Reference, Description, EnabledFlag and TimeSheetFlag in the JSON body" {
        InModuleScope VSAModule {
            $script:body = $null
            Mock Invoke-VSARestMethod { $script:body = $Body }
            New-VSAAdminTask -Reference 'VSA-1' -Description 'Task one' -EnabledFlag -TimeSheetFlag | Out-Null
            $script:body | Should -Not -BeNullOrEmpty
            $obj = $script:body | ConvertFrom-Json
            $obj.Reference     | Should -Be 'VSA-1'
            $obj.Description    | Should -Be 'Task one'
            $obj.EnabledFlag    | Should -BeTrue
            $obj.TimeSheetFlag  | Should -BeTrue
        }
    }
}

Describe "Custom extension functions - Params initialised before use (T-6.4)" {
    It "New-VSACustomExtensionFolder forwards the exact VSAConnection" {
        InModuleScope VSAModule {
            $script:conn = $null
            Mock Invoke-VSARestMethod { $script:conn = $VSAConnection }
            $c = [VSAConnection]::new('https://vsa.example.com','u','t','p',[datetime]::Now.AddHours(1),$false,$false)
            New-VSACustomExtensionFolder -AgentId 10001 -Folder 'NewFolder' -VSAConnection $c | Out-Null
            $script:conn | Should -Be $c
        }
    }

    It "Publish-VSACustomExtensionFile forwards the exact VSAConnection" {
        InModuleScope VSAModule {
            $script:conn = $null
            $script:suffix = $null
            Mock Invoke-VSARestMethod { $script:conn = $VSAConnection; $script:suffix = $URISuffix }
            $tmp = New-TemporaryFile
            Set-Content -Path $tmp -Value 'hello'
            $c = [VSAConnection]::new('https://vsa.example.com','u','t','p',[datetime]::Now.AddHours(1),$false,$false)
            Publish-VSACustomExtensionFile -AgentId 10001 -SourceFilePath $tmp -DestinationFolder 'Dest' -VSAConnection $c | Out-Null
            Remove-Item $tmp -Force
            $script:conn | Should -Be $c
            $script:suffix | Should -Match 'customextensions/10001/file/Dest'
        }
    }
}

Describe "Update-VSAAPSettings - value in path AND flag in query (F-15, live Swagger docs/v1.0)" {
    # Live spec: PUT /automation/agentprocs/quicklaunch/askbeforeexecuting/{value} needs the value
    # in the path (required) AND a flag query boolean (required). The public webhelp was wrong
    # (fact #6) and the earlier F-49b change regressed this to query-only; restored here.
    It "sends the boolean in both the {value} path segment and the ?flag= query" {
        InModuleScope VSAModule {
            $script:capturedSuffix = $null
            Mock Invoke-VSARestMethod { $script:capturedSuffix = $URISuffix }
            Update-VSAAPSettings -Flag | Out-Null
            $script:capturedSuffix | Should -Be 'api/v1.0/automation/agentprocs/quicklaunch/askbeforeexecuting/true?flag=true'
        }
    }
}

Describe "Remove-VSAPatch - dangling URISuffix assignment removed (T-6.11)" {
    It "does not null out the default URISuffix before use" {
        InModuleScope VSAModule {
            $script:capturedSuffix = $null
            Mock Invoke-VSARestMethod { $script:capturedSuffix = $URISuffix }
            Remove-VSAPatch -AgentIds '979868787875855' | Out-Null
            $script:capturedSuffix | Should -Not -BeNullOrEmpty
            $script:capturedSuffix | Should -Match 'agentGuids=979868787875855'
        }
    }
}

Describe "New-VSAAPScheduled - AgentID/AgentProcedureId accept large numeric IDs (T-6.6)" {
    # New-VSAAPScheduled's DynamicParam block has a pre-existing, unrelated bug (a nonexistent
    # 'Create-Parameter' helper passed a bare [int] type-literal, which PowerShell's binder
    # rejects) that fires on every invocation regardless of -Repeat, so the cmdlet itself cannot
    # be invoked end-to-end here. That bug belongs to a separate remediation item; this test
    # verifies the T-6.6 fix directly against the static parameter metadata instead.
    It "declares AgentID as [string], not [int] (would overflow Int32 for real VSA IDs)" {
        $cmd = Get-Command -Name New-VSAAPScheduled
        $cmd.Parameters['AgentID'].ParameterType.Name | Should -Be 'String'
    }

    It "declares AgentProcedureId as [string], not [int]" {
        $cmd = Get-Command -Name New-VSAAPScheduled
        $cmd.Parameters['AgentProcedureId'].ParameterType.Name | Should -Be 'String'
    }

    It "the AgentID ValidateScript accepts a value exceeding Int32.MaxValue" {
        $cmd = Get-Command -Name New-VSAAPScheduled
        $validator = $cmd.Parameters['AgentID'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateScriptAttribute] }
        $underscoreVar = New-Object System.Management.Automation.PSVariable('_', '979868787875855')
        { $validator.ScriptBlock.InvokeWithContext($null, $underscoreVar) } | Should -Not -Throw
    }
}

Describe "Enable-VSATenantRoleType - correct body for both parameter sets (T-6.3)" {
    It "ByName produces a non-null body containing the resolved role type IDs" {
        InModuleScope VSAModule {
            $script:body = $null
            # Role-type name->Id is now resolved from the live VSA (F-64), not a hardcoded map.
            Mock Get-VSARoleType {
                @(
                    [pscustomobject]@{ RoleTypeId = 105; RoleTypeName = 'SB Admin' }
                    [pscustomobject]@{ RoleTypeId = 116; RoleTypeName = 'KDP Admin' }
                )
            }
            Mock Invoke-VSARestMethod { $script:body = $Body }
            Enable-VSATenantRoleType -TenantId 10001 -RoleType 'SB Admin', 'KDP Admin' | Out-Null
            $script:body | Should -Not -BeNullOrEmpty
            $script:body | Should -Not -Be 'null'
            $ids = $script:body | ConvertFrom-Json
            $ids | Should -Contain 105
            $ids | Should -Contain 116
        }
    }

    It "ById produces a non-null body containing the given role type IDs" {
        InModuleScope VSAModule {
            $script:body = $null
            Mock Invoke-VSARestMethod { $script:body = $Body }
            Enable-VSATenantRoleType -TenantId 10001 -RoleTypeId 105, 116 | Out-Null
            $script:body | Should -Not -BeNullOrEmpty
            $script:body | Should -Not -Be 'null'
            $ids = $script:body | ConvertFrom-Json
            $ids | Should -Contain 105
            $ids | Should -Contain 116
        }
    }
}

Describe "New-VSAOrganization - body built from bound params (T-6.7)" {
    It "preserves values containing commas and ampersands exactly" {
        InModuleScope VSAModule {
            $script:body = $null
            Mock Invoke-VSARestMethod { $script:body = $Body }
            New-VSAOrganization -OrgName 'Smith, Jones & Co' -OrgRef 'SJC' -Website 'https://x/?a=1' | Out-Null
            $script:body | Should -Not -BeNullOrEmpty
            $obj = $script:body | ConvertFrom-Json
            $obj.OrgName | Should -Be 'Smith, Jones & Co'
            $obj.Website | Should -Be 'https://x/?a=1'
            $obj.OrgRef  | Should -Be 'SJC'
        }
    }
}

Describe "Get-VSAAudit AllAgentsSummaries hits the base audit collection (F-24)" {
    It "builds URISuffix 'api/v1.0/assetmgmt/audit' (no /{0}) for the default AuditOf" {
        InModuleScope VSAModule {
            $script:auditSuffix = $null
            Mock Invoke-VSARestMethod { $script:auditSuffix = $URISuffix }
            $conn = [VSAConnection]::new('https://vsa.example.com','u','tok','pat',[datetime]::Now.AddHours(1),$false,$false)
            Get-VSAAudit -VSAConnection $conn | Out-Null   # default AuditOf = AllAgentsSummaries, no AgentID
            $script:auditSuffix | Should -Be 'api/v1.0/assetmgmt/audit'
        }
    }
    It "still targets the agent sub-resource for a specific AuditOf" {
        InModuleScope VSAModule {
            $script:auditSuffix = $null
            Mock Invoke-VSARestMethod { $script:auditSuffix = $URISuffix }
            $conn = [VSAConnection]::new('https://vsa.example.com','u','tok','pat',[datetime]::Now.AddHours(1),$false,$false)
            Get-VSAAudit -VSAConnection $conn -AuditOf Summary -AgentID 123 | Out-Null
            $script:auditSuffix | Should -Be 'api/v1.0/assetmgmt/audit/123/summary'
        }
    }
}

Describe "Get-VSAMachineGroup -ResolveIDs resolves orgs without a duplicate-key error (F-26)" {
    It "does not throw and attaches the resolved Organization" {
        InModuleScope VSAModule {
            Mock Update-VSAConnection {}
            Mock Invoke-VSARestMethod {
                if ($URISuffix -match 'machinegroups') { [pscustomobject]@{ MachineGroupId = 1; OrgId = 99 } }
                elseif ($URISuffix -match 'orgs')       { [pscustomobject]@{ OrgId = 99; OrgName = 'Acme' } }
            }
            $c = [VSAConnection]::new('https://vsa.example.com','u','t','p',[datetime]::Now.AddHours(1),$false,$false)
            # Before the F-26 fix this threw "Item has already been added: 'VSAConnection'".
            $r = Get-VSAMachineGroup -VSAConnection $c -ResolveIDs
            $r.Organization.OrgName | Should -Be 'Acme'
        }
    }
}

Describe "Get-VSAAuditDocument -DownloadDocument forwards OutFile, not DownloadsFolder (F-27)" {
    It "passes -OutFile (folder + document leaf) to the transport" {
        InModuleScope VSAModule {
            $script:outfile = 'UNSET'
            Mock Invoke-VSARestMethod { $script:outfile = $OutFile }
            $c = [VSAConnection]::new('https://vsa.example.com','u','t','p',[datetime]::Now.AddHours(1),$false,$false)
            Get-VSAAuditDocument -VSAConnection $c -AgentID 123 -Path 'sub/Doc.txt' -DownloadDocument -DownloadsFolder '/tmp/dl' | Out-Null
            $script:outfile | Should -Be (Join-Path '/tmp/dl' 'Doc.txt')
        }
    }
}

Describe "New-VSAAgentInstallPkg forwards the explicit VSAConnection (F-31)" {
    It "passes -VSAConnection through to the transport (was dropped -> only worked persistent)" {
        InModuleScope VSAModule {
            $script:conn = 'UNSET'
            Mock Invoke-VSARestMethod { $script:conn = $VSAConnection }
            $c = [VSAConnection]::new('https://vsa.example.com','u','t','p',[datetime]::Now.AddHours(1),$false,$false)
            New-VSAAgentInstallPkg -VSAConnection $c -MachineGroupId 123 -AgentType windows -PackageName 'p' -PackageDescription 'd' | Out-Null
            $script:conn | Should -Be $c
        }
    }
}

Describe "Get-VSAAPFile -DownloadFile routes through the transport so cert-bypass applies (F-32)" {
    It "calls Invoke-VSARestMethod (not a raw Invoke-RestMethod) with GET + OutFile + the getfiles/file URISuffix" {
        InModuleScope VSAModule {
            $script:dlUri = $null; $script:dlMethod = $null; $script:dlOut = $null; $script:dlConn = 'UNSET'
            # If the download path still used a raw Invoke-RestMethod, this mock would NOT be hit
            # (the call would try a real request with no cert-bypass). Asserting it IS hit, with an
            # OutFile, is the regression guard for F-32.
            Mock Invoke-VSARestMethod { $script:dlUri = $URISuffix; $script:dlMethod = $Method; $script:dlOut = $OutFile; $script:dlConn = $VSAConnection }
            $c = [VSAConnection]::new('https://vsa.example.com','u','t','p',[datetime]::Now.AddHours(1),$false,$false)
            Get-VSAAPFile -VSAConnection $c -AgentId 123 -Path 'sub\file.txt' -DownloadsFolder 'TestDrive:\' -DownloadFile | Out-Null
            $script:dlMethod | Should -Be 'GET'
            $script:dlUri    | Should -Match '^api/v1\.0/assetmgmt/getfiles/123/file/'
            $script:dlOut    | Should -Match 'file\.txt$'
            $script:dlConn   | Should -Be $c
        }
    }
    It "does not use a raw Invoke-RestMethod in the DownloadFile branch (source guard)" {
        # Strip comment lines first so an explanatory comment mentioning the cmdlet name by prose
        # can't trip the guard; we only care about actual code invoking the raw cmdlet.
        # Nested 2-arg Join-Path: Windows PowerShell 5.1's Join-Path takes only -Path/-ChildPath,
        # so the 3+-segment form (valid on PS7) throws "A positional parameter cannot be found" and
        # this source guard never runs on 5.1 (F-73).
        $code = (Get-Content (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'public') 'Get-VSAAPFile.ps1')) |
            Where-Object { $_ -notmatch '^\s*#' }
        ($code -join "`n") | Should -Not -Match '\bInvoke-RestMethod\b'
    }
}

Describe "New-VSAAPScheduled omits SpecificDayOfMonth unless supplied (F-35)" {
    It "does NOT send SpecificDayOfMonth when only -DayOfMonth is used (server: 'Both ... cannot be set')" {
        InModuleScope VSAModule {
            $script:body = $null
            Mock Invoke-VSARestMethod { $script:body = $Body }
            New-VSAAPScheduled -AgentID 1 -AgentProcedureId 2 -Repeat 'Months' -DayOfMonth 'LastDay' `
                -EndAt '1345' -EndOn ([datetime]'2030-12-31') -StartOn ([datetime]'2030-01-01') | Out-Null
            $obj = $script:body | ConvertFrom-Json
            $obj.Recurrence.DayOfMonth | Should -Be 'LastDay'
            # Before F-35 this was always present (=0), colliding with DayOfMonth -> HTTP 400.
            ($obj.Recurrence.PSObject.Properties.Name -contains 'SpecificDayOfMonth') | Should -BeFalse
        }
    }
    It "DOES send SpecificDayOfMonth when the caller supplies it" {
        InModuleScope VSAModule {
            $script:body = $null
            Mock Invoke-VSARestMethod { $script:body = $Body }
            New-VSAAPScheduled -AgentID 1 -AgentProcedureId 2 -Repeat 'Months' -SpecificDayOfMonth 15 `
                -EndAt '1345' -EndOn ([datetime]'2030-12-31') -StartOn ([datetime]'2030-01-01') | Out-Null
            $obj = $script:body | ConvertFrom-Json
            $obj.Recurrence.SpecificDayOfMonth | Should -Be 15
        }
    }
}

Describe "Get-VSASDTicket refuses ambiguity with an ACTIONABLE message" {

    # The VSA API has no "all tickets" collection -- a bare api/v1.0/automation/servicedesktickets
    # returns HTTP 403 -- so tickets are addressable only per service desk. Requiring an id is
    # therefore correct, but "provide an id" is a dead end unless the message says where ids come
    # from. Reported by a user running `Get-VSASDTicket -VSAConnection $c -Parallel`.

    It "names both parameters, explains the API constraint, and points at Get-VSASD" {
        InModuleScope VSAModule {
            $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't'
            $err = $null
            try { Get-VSASDTicket -VSAConnection $conn } catch { $err = $_ }
            $err | Should -Not -BeNullOrEmpty
            $err.Exception.Message | Should -BeLike '*-ServiceDeskId*'
            $err.Exception.Message | Should -BeLike '*-ServiceDeskTicketId*'
            $err.Exception.Message | Should -BeLike '*no "all tickets" endpoint*'
            $err.Exception.Message | Should -BeLike '*Get-VSASD*'
        }
    }

    It "still rejects supplying BOTH ids" {
        InModuleScope VSAModule {
            $conn = [VSAConnection]::new(); $conn.URI = 'https://vsa.example/'; $conn.Token = 't'
            { Get-VSASDTicket -VSAConnection $conn -ServiceDeskId 1 -ServiceDeskTicketId 2 } |
                Should -Throw '*exactly one*'
        }
    }

    It "the Get-VSASD alias it recommends actually exists and is exported" {
        # Guards the message against naming a cmdlet that does not exist.
        (Get-Command Get-VSASD -ErrorAction SilentlyContinue) | Should -Not -BeNullOrEmpty
        (Import-PowerShellDataFile "$ModuleRoot/VSAModule.psd1").AliasesToExport | Should -Contain 'Get-VSASD'
    }
}

Describe "Update-VSAStaff always sends Function, because the backend requires it (F-69)" {

    # Found by real (non -WhatIf) writes against a live VSA: the stored procedure behind this
    # endpoint takes this field as '@purpose' and rejects the WHOLE update with HTTP 500 --
    # "expects parameter '@purpose', which was not supplied" -- when the key is absent.
    # It was historically sent unconditionally for this reason; the F-52 prune-by-bound-state
    # refactor made it conditional and silently reintroduced the failure.

    It "sends Function even when the caller omits it, using the CURRENT value (no wipe)" {
        InModuleScope VSAModule {
            $script:sentBody = $null
            Mock Get-VSAStaff { [pscustomobject]@{ OrgStaffId = '42'; Function = 'Engineer' } }
            Mock Invoke-VSAWriteRequest { $script:sentBody = $Body }
            Update-VSAStaff -OrgStaffId '42' -OrgIdNumber '7' -StaffFullName 'Someone' -Confirm:$false
            $body = $script:sentBody | ConvertFrom-Json
            $body.PSObject.Properties.Name | Should -Contain 'Function' -Because 'omitting the key 500s the whole update'
            $body.Function | Should -Be 'Engineer' -Because 'the existing job function must be preserved, not wiped'
        }
    }

    It "honours an explicitly supplied Function" {
        InModuleScope VSAModule {
            $script:sentBody = $null
            Mock Get-VSAStaff { [pscustomobject]@{ OrgStaffId = '42'; Function = 'Engineer' } }
            Mock Invoke-VSAWriteRequest { $script:sentBody = $Body }
            Update-VSAStaff -OrgStaffId '42' -OrgIdNumber '7' -StaffFullName 'Someone' -Function 'Manager' -Confirm:$false
            ($script:sentBody | ConvertFrom-Json).Function | Should -Be 'Manager'
        }
    }
}

Describe "Set-VSARCService preserves ClientApp/Path when omitted (F-79)" {

    # The update endpoint null-refs (HTTP 500) if the body lacks ClientApp/Path, so an update of just
    # ServiceName/Port (their mandatory params) failed live. The cmdlet now reads the current service
    # and re-sends the existing ClientApp/Path when the caller does not override them.

    It "sends the current ClientApp/Path when the caller supplies only ServiceName/Port" {
        InModuleScope VSAModule {
            Mock Get-VSARCService { [pscustomobject]@{ ServiceId = 'svc-1'; ServiceName = 'old'; Port = 22; ClientApp = 'https'; Path = 'C:\Keep' } }
            $script:body = $null
            Mock Invoke-VSAWriteRequest { $script:body = $Body }
            Set-VSARCService -ServiceId 'svc-1' -ServiceName 'new' -Port 3390 -Confirm:$false
            $script:body['ServiceName'] | Should -Be 'new'
            $script:body['Port']        | Should -Be 3390
            $script:body['ClientApp']   | Should -Be 'https' -Because 'preserved from the current service, not dropped'
            $script:body['Path']        | Should -Be 'C:\Keep'
        }
    }

    It "honours an explicitly supplied ClientApp/Path over the current values" {
        InModuleScope VSAModule {
            Mock Get-VSARCService { [pscustomobject]@{ ServiceId = 'svc-1'; ClientApp = 'https'; Path = 'C:\Keep' } }
            $script:body = $null
            Mock Invoke-VSAWriteRequest { $script:body = $Body }
            Set-VSARCService -ServiceId 'svc-1' -ServiceName 'new' -Port 3390 -ClientApp 'ssh' -Path '' -Confirm:$false
            $script:body['ClientApp'] | Should -Be 'ssh'
        }
    }
}

Describe "Send-VSAEmail always sends a UniqueTag (F-80)" {

    # The server rejects a missing UniqueTag with HTTP 400 "UniqueTag can't be null". -UniqueTag stays
    # optional for the caller; the module generates one when it is omitted.

    It "generates a UniqueTag when the caller omits it" {
        InModuleScope VSAModule {
            $script:body = $null
            Mock Invoke-VSAWriteRequest { $script:body = $Body }
            Send-VSAEmail -FromAddress 'a@b.com' -ToAddress 'c@d.com' -Subject 's' -Body 'b' -Confirm:$false
            $obj = $script:body | ConvertFrom-Json
            $obj.UniqueTag | Should -Not -BeNullOrEmpty
        }
    }

    It "honours an explicit UniqueTag" {
        InModuleScope VSAModule {
            $script:body = $null
            Mock Invoke-VSAWriteRequest { $script:body = $Body }
            Send-VSAEmail -FromAddress 'a@b.com' -ToAddress 'c@d.com' -Subject 's' -Body 'b' -UniqueTag 'mytag' -Confirm:$false
            ($script:body | ConvertFrom-Json).UniqueTag | Should -Be 'mytag'
        }
    }
}