Tests/InvokeHubspot.Tests.ps1

$moduleRoot = Split-Path -Parent $PSScriptRoot
$manifestPath = Join-Path $moduleRoot 'InvokeHubspot.psd1'

Describe 'InvokeHubspot module smoke tests' {
    It 'loads the module manifest' {
        $manifest = Test-ModuleManifest $manifestPath

        $manifest.Name | Should Be 'InvokeHubspot'
        $manifest.Version.ToString() | Should Be '0.1.3'
    }

    It 'imports the module and exposes the expected public commands' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        $commandNames = @(Get-Command -Module InvokeHubspot | Select-Object -ExpandProperty Name)

        ($commandNames -contains 'Get-HubspotConfiguration') | Should Be $true
        ($commandNames -contains 'Set-HubspotConfiguration') | Should Be $true
        ($commandNames -contains 'Set-HubspotAccessToken') | Should Be $true
        ($commandNames -contains 'Invoke-HubspotRest') | Should Be $true
        ($commandNames -contains 'Get-HubspotDeal') | Should Be $true
        ($commandNames -contains 'Get-HubspotDealDetails') | Should Be $true
        ($commandNames -contains 'Get-HubspotDeals') | Should Be $true
        ($commandNames -contains 'Search-HubspotDeals') | Should Be $true
        ($commandNames -contains 'New-HubspotDeal') | Should Be $true
        ($commandNames -contains 'Set-HubspotDeal') | Should Be $true
        ($commandNames -contains 'Remove-HubspotDeal') | Should Be $true
    }

    It 'updates configuration in-memory' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        $updatedConfiguration = Set-HubspotConfiguration -ServiceUserName 'TEST_HUBSPOT' -BaseUri 'https://api.hubapi.com/' -DealsPath '/crm/v3/objects/deals/' -DefaultPageSize 200 -DefaultRetryCount 5 -DefaultThrottleDelaySeconds 0.5

        $updatedConfiguration.ServiceUserName | Should Be 'TEST_HUBSPOT'
        $updatedConfiguration.BaseUri | Should Be 'https://api.hubapi.com'
        $updatedConfiguration.DealsPath | Should Be 'crm/v3/objects/deals'
        $updatedConfiguration.DefaultPageSize | Should Be 200
        $updatedConfiguration.DefaultRetryCount | Should Be 5
        $updatedConfiguration.DefaultThrottleDelaySeconds | Should Be 0.5
    }

    It 'converts HubSpot records into typed flat objects' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:HubspotPropertyDefinitionsByObjectType = @{
                deals = @{
                    dealname = [pscustomobject]@{ name = 'dealname'; type = 'string' }
                    amount = [pscustomobject]@{ name = 'amount'; type = 'number' }
                    closedate = [pscustomobject]@{ name = 'closedate'; type = 'date' }
                    is_closed = [pscustomobject]@{ name = 'is_closed'; type = 'bool' }
                    stage = [pscustomobject]@{ name = 'stage'; type = 'enumeration'; fieldType = 'checkbox' }
                    custom_json = [pscustomobject]@{ name = 'custom_json'; type = 'json' }
                }
            }

            $record = [pscustomobject]@{
                id = '123'
                archived = $false
                createdAt = '2026-08-06T10:11:12Z'
                updatedAt = '2026-08-06T10:11:13Z'
                properties = [pscustomobject]@{
                    dealname = 'Test Deal'
                    amount = '42.5'
                    closedate = '2026-08-06'
                    is_closed = 'true'
                    stage = 'A;B'
                    custom_json = '{"x":1}'
                }
            }

            $converted = Convert-HubspotRecordToObject -Record $record -ObjectType 'deals'

            $converted.Id | Should Be '123'
            $converted.Dealname | Should Be 'Test Deal'
            $converted.Amount | Should Be 42.5
            $converted.is_closed | Should Be $true
            $converted.stage -join ',' | Should Be 'A,B'
            $converted.custom_json.x | Should Be 1
            ([datetime]$converted.closedate).ToString('yyyy-MM-dd') | Should Be '2026-08-06'
        }
    }

    It 'translates dealstage ids to labels when pipeline metadata is available' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:HubspotPropertyDefinitionsByObjectType = @{
                deals = @{
                    pipeline = [pscustomobject]@{ name = 'pipeline'; type = 'string' }
                    dealstage = [pscustomobject]@{ name = 'dealstage'; type = 'string' }
                }
            }

            $script:HubspotDealPipelineDefinitionsById = @{
                default = [pscustomobject]@{
                    Id = 'default'
                    Label = 'Default Pipeline'
                    Stages = @{
                        contractsent = [pscustomobject]@{
                            Id = 'contractsent'
                            Label = 'Anfrage zugeordnet (hhpberlin Sales)'
                            PipelineId = 'default'
                            PipelineLabel = 'Default Pipeline'
                        }
                    }
                }
            }
            $script:HubspotDealPipelineDefinitionsLoaded = $true

            $record = [pscustomobject]@{
                id = '456'
                archived = $false
                properties = [pscustomobject]@{
                    pipeline = 'default'
                    dealstage = 'contractsent'
                }
            }

            $converted = Convert-HubspotRecordToObject -Record $record -ObjectType 'deals'

            $converted.dealstage | Should Be 'contractsent'
            $converted.dealstageLabel | Should Be 'Anfrage zugeordnet (hhpberlin Sales)'
            ($converted.PSObject.Properties.Name -contains 'dealstageId') | Should Be $false
            $converted.pipeline | Should Be 'default'
            $converted.pipelineLabel | Should Be 'Default Pipeline'
        }
    }

    It 'converts translated dealstage values back to internal ids before writing' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:HubspotDealPipelineDefinitionsById = @{
                default = [pscustomobject]@{
                    Id = 'default'
                    Label = 'Default Pipeline'
                    Stages = @{
                        contractsent = [pscustomobject]@{
                            Id = 'contractsent'
                            Label = 'Anfrage zugeordnet (hhpberlin Sales)'
                            PipelineId = 'default'
                            PipelineLabel = 'Default Pipeline'
                        }
                    }
                }
            }
            $script:HubspotDealPipelineDefinitionsLoaded = $true

            $properties = @{
                pipeline = 'default'
                dealstageId = 'contractsent'
                dealstageLabel = 'Anfrage zugeordnet (hhpberlin Sales)'
            }

            $writeProperties = Convert-HubspotDealPropertiesForWrite -Properties $properties

            $writeProperties.dealstage | Should Be 'contractsent'
            $writeProperties.pipeline | Should Be 'default'
            $writeProperties.Contains('dealstageLabel') | Should Be $false
        }
    }

    It 'returns GUI-like deal details with display labels' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:HubspotPropertyDefinitionsByObjectType = @{
                deals = @{
                    pipeline = [pscustomobject]@{ name = 'pipeline'; label = 'Pipeline'; type = 'enumeration'; fieldType = 'select'; groupName = 'dealinformation'; options = @([pscustomobject]@{ value = 'default'; label = 'hhpberlin Sales' }) }
                    dealstage = [pscustomobject]@{ name = 'dealstage'; label = 'Deal-Phase'; type = 'enumeration'; fieldType = 'select'; groupName = 'dealinformation' }
                    nda_safe = [pscustomobject]@{ name = 'nda_safe'; label = 'NDA / SAFE'; type = 'enumeration'; fieldType = 'radio'; groupName = 'dealinformation'; options = @([pscustomobject]@{ value = 'false'; label = 'Nein' }, [pscustomobject]@{ value = 'true'; label = 'Ja' }) }
                    hubspot_owner_id = [pscustomobject]@{ name = 'hubspot_owner_id'; label = 'Fuer Deal zustaendiger Mitarbeiter'; type = 'number'; fieldType = 'number'; groupName = 'dealinformation' }
                }
            }

            $script:HubspotDealPipelineDefinitionsById = @{
                default = [pscustomobject]@{
                    Id = 'default'
                    Label = 'hhpberlin Sales'
                    Stages = @{
                        '1110595160' = [pscustomobject]@{
                            Id = '1110595160'
                            Label = 'Anfrage zugeordnet'
                            PipelineId = 'default'
                            PipelineLabel = 'hhpberlin Sales'
                        }
                    }
                }
            }
            $script:HubspotDealPipelineDefinitionsLoaded = $true

            function Invoke-HubspotRest {
                param (
                    [Parameter(Mandatory = $false)][string]$Path,
                    [Parameter(Mandatory = $false)][hashtable]$Query,
                    [Parameter(Mandatory = $false)][string]$AccessToken
                )

                return [pscustomobject]@{
                    id = '63433921854'
                    archived = $true
                    createdAt = '2026-08-06T03:28:31Z'
                    updatedAt = '2026-08-06T03:28:35Z'
                    properties = [pscustomobject]@{
                        pipeline = 'default'
                        dealstage = '1110595160'
                        nda_safe = 'false'
                        hubspot_owner_id = '41629779'
                    }
                }
            }

            function Invoke-HubspotRequest {
                param (
                    [Parameter(Mandatory = $false)][string]$ResourcePath,
                    [Parameter(Mandatory = $false)][string]$AccessToken
                )

                if ($ResourcePath -eq 'crm/v3/owners/41629779') {
                    return [pscustomobject]@{
                        id = '41629779'
                        firstName = 'Jonny'
                        lastName = 'Dunger'
                        email = 'jonny.dunger@example.org'
                    }
                }

                throw "Unexpected resource path: $ResourcePath"
            }

            $details = Get-HubspotDealDetails -Id '63433921854' -Properties @('pipeline', 'dealstage', 'nda_safe', 'hubspot_owner_id')

            ($details | Measure-Object).Count | Should Be 4

            $stageDetail = $details | Where-Object { $_.PropertyName -eq 'dealstage' }
            $stageDetail.Label | Should Be 'Deal-Phase'
            $stageDetail.Value | Should Be '1110595160'
            $stageDetail.DisplayValue | Should Be 'Anfrage zugeordnet'

            $ndaDetail = $details | Where-Object { $_.PropertyName -eq 'nda_safe' }
            $ndaDetail.Label | Should Be 'NDA / SAFE'
            $ndaDetail.Value | Should Be 'false'
            $ndaDetail.DisplayValue | Should Be 'Nein'

            $ownerDetail = $details | Where-Object { $_.PropertyName -eq 'hubspot_owner_id' }
            $ownerDetail.Value | Should Be '41629779'
            $ownerDetail.DisplayValue | Should Be 'Jonny Dunger'
        }
    }

    It 'retries details read with archived=true on 404' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:HubspotPropertyDefinitionsByObjectType = @{
                deals = @{
                    pipeline = [pscustomobject]@{ name = 'pipeline'; label = 'Pipeline'; type = 'string'; fieldType = 'text'; groupName = 'dealinformation' }
                }
            }

            $script:HubspotDetailsRetryAttempt = 0

            function Invoke-HubspotRest {
                param (
                    [Parameter(Mandatory = $false)][string]$Path,
                    [Parameter(Mandatory = $false)][hashtable]$Query,
                    [Parameter(Mandatory = $false)][string]$AccessToken
                )

                $script:HubspotDetailsRetryAttempt++

                if (-not $Query.ContainsKey('archived')) {
                    $ex = [System.Exception]::new('Not Found')
                    Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value ([pscustomobject]@{ StatusCode = 404 }) -Force
                    throw $ex
                }

                return [pscustomobject]@{
                    id = '63433921854'
                    archived = $true
                    properties = [pscustomobject]@{
                        pipeline = 'default'
                    }
                }
            }

            $details = Get-HubspotDealDetails -Id '63433921854' -Properties @('pipeline')

            ($details | Measure-Object).Count | Should Be 1
            $details[0].PropertyName | Should Be 'pipeline'
            $script:HubspotDetailsRetryAttempt | Should Be 2
        }
    }

    It 'keeps explicitly requested empty fields in details output' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:HubspotPropertyDefinitionsByObjectType = @{
                deals = @{
                    nda_safe = [pscustomobject]@{ name = 'nda_safe'; label = 'NDA / SAFE'; type = 'enumeration'; fieldType = 'radio'; groupName = 'dealinformation' }
                }
            }

            function Invoke-HubspotRest {
                param (
                    [Parameter(Mandatory = $false)][string]$Path,
                    [Parameter(Mandatory = $false)][hashtable]$Query,
                    [Parameter(Mandatory = $false)][string]$AccessToken
                )

                return [pscustomobject]@{
                    id = '1'
                    archived = $false
                    properties = [pscustomobject]@{
                        nda_safe = $null
                    }
                }
            }

            $details = Get-HubspotDealDetails -Id '1' -Properties @('nda_safe')
            ($details | Measure-Object).Count | Should Be 1
            $details[0].PropertyName | Should Be 'nda_safe'
            $details[0].Value | Should Be $null
        }
    }

    It 'uses HubSpot standard attributes by default and requests all only with -AllProperties' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:HubspotPropertyDefinitionsByObjectType = @{
                deals = @{
                    dealname = [pscustomobject]@{ name = 'dealname'; type = 'string' }
                    dealstage = [pscustomobject]@{ name = 'dealstage'; type = 'string' }
                    pipeline = [pscustomobject]@{ name = 'pipeline'; type = 'string' }
                }
            }

            $script:CapturedGetDealQuery = $null

            function Invoke-HubspotRest {
                param (
                    [Parameter(Mandatory = $false)][string]$Path,
                    [Parameter(Mandatory = $false)][hashtable]$Query,
                    [Parameter(Mandatory = $false)][string]$AccessToken
                )

                $script:CapturedGetDealQuery = $Query

                return [pscustomobject]@{
                    id = '123'
                    archived = $false
                    properties = [pscustomobject]@{
                        dealname = 'X'
                        dealstage = 'closedlost'
                        pipeline = 'default'
                    }
                }
            }

            $null = Get-HubspotDeal -Id '123'

            $script:CapturedGetDealQuery.ContainsKey('properties') | Should Be $false

            $null = Get-HubspotDeal -Id '123' -AllProperties

            $script:CapturedGetDealQuery.ContainsKey('properties') | Should Be $true
            (($script:CapturedGetDealQuery.properties | Sort-Object) -join ',') | Should Be 'dealname,dealstage,pipeline'
        }
    }

    It 're-prompts token and retries once on invalid authentication' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:TokenPromptCalls = 0
            $script:InvokeRestCalls = 0

            function Get-HubspotBearerToken {
                param (
                    [Parameter(Mandatory = $false)][string]$AccessToken,
                    [Parameter(Mandatory = $false)][switch]$ForcePrompt
                )

                if ($ForcePrompt.IsPresent) {
                    $script:TokenPromptCalls++
                    return 'new-token'
                }

                return 'old-token'
            }

            function Invoke-RestMethod {
                param (
                    [Parameter(Mandatory = $false)][uri]$Uri,
                    [Parameter(Mandatory = $false)][string]$Method,
                    [Parameter(Mandatory = $false)][hashtable]$Headers
                )

                $script:InvokeRestCalls++

                if ($Headers.Authorization -eq 'Bearer old-token') {
                    $ex = [System.Exception]::new('Authentication credentials not found')
                    Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value ([pscustomobject]@{ StatusCode = 401 }) -Force
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new($ex, 'INVALID_AUTHENTICATION', [System.Management.Automation.ErrorCategory]::AuthenticationError, $null)
                    $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new('{"status":"error","category":"INVALID_AUTHENTICATION"}')
                    throw $errorRecord
                }

                return [pscustomobject]@{ results = @() }
            }

            $null = Invoke-HubspotRequest -ResourcePath 'crm/v3/objects/deals'

            $script:TokenPromptCalls | Should Be 1
            $script:InvokeRestCalls | Should Be 2
        }
    }

    It 're-prompts token when invalid authentication has no status code' {
        Remove-Module InvokeHubspot -ErrorAction SilentlyContinue
        Import-Module $manifestPath -Force -ErrorAction Stop

        InModuleScope InvokeHubspot {
            $script:TokenPromptCalls = 0
            $script:InvokeRestCalls = 0

            function Get-HubspotBearerToken {
                param (
                    [Parameter(Mandatory = $false)][string]$AccessToken,
                    [Parameter(Mandatory = $false)][switch]$ForcePrompt
                )

                if ($ForcePrompt.IsPresent) {
                    $script:TokenPromptCalls++
                    return 'new-token'
                }

                return 'old-token'
            }

            function Invoke-RestMethod {
                param (
                    [Parameter(Mandatory = $false)][uri]$Uri,
                    [Parameter(Mandatory = $false)][string]$Method,
                    [Parameter(Mandatory = $false)][hashtable]$Headers
                )

                $script:InvokeRestCalls++

                if ($Headers.Authorization -eq 'Bearer old-token') {
                    $ex = [System.Exception]::new('Authentication credentials not found. This API supports OAuth 2.0 authentication')
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new($ex, 'INVALID_AUTHENTICATION', [System.Management.Automation.ErrorCategory]::AuthenticationError, $null)
                    $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new('{"status":"error","category":"INVALID_AUTHENTICATION"}')
                    throw $errorRecord
                }

                return [pscustomobject]@{ results = @() }
            }

            $null = Invoke-HubspotRequest -ResourcePath 'crm/v3/objects/deals'

            $script:TokenPromptCalls | Should Be 1
            $script:InvokeRestCalls | Should Be 2
        }
    }
}