Tests/InvokeZEP.Tests.ps1

$modulePath = Join-Path $PSScriptRoot '..\InvokeZEP.psd1'
Import-Module $modulePath -Force

$config = Get-ZEPConfiguration
if (-not $config -or [string]::IsNullOrWhiteSpace($config.BaseUri)) {
    throw 'Get-ZEPConfiguration returned an invalid configuration object.'
}

$commands = @('Get-ZEPConfiguration', 'Set-ZEPConfiguration', 'Invoke-ZEPRest', 'Get-ZEPOffer', 'Get-ZEPOffers', 'Get-ZEPOfferItems', 'Search-ZEPOffers', 'ConvertFrom-ZEPDateTime', 'ConvertTo-ZEPTypedOffer', 'Get-ZEPRecentOffers', 'Get-ZEPOffersByStatus', 'Get-ZEPOffersById', 'Get-ZEPEmployee')
foreach ($commandName in $commands) {
    if (-not (Get-Command -Name $commandName -ErrorAction SilentlyContinue)) {
        throw "Command '$commandName' was not exported from the module."
    }
}

$cacheChanged = & (Get-Command -Name Test-ZEPOffersCacheNeedsRefresh -ErrorAction Stop) -CacheObject ([pscustomobject]@{ generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); total = 10; items = @() }) -CurrentTotal 12
if ($cacheChanged -ne $true) {
    throw 'The cache refresh detection did not detect a changed total count.'
}

$cachePartial = & (Get-Command -Name Test-ZEPOffersCacheNeedsRefresh -ErrorAction Stop) -CacheObject ([pscustomobject]@{ generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); total = 10; items = @(@{ id = 1 }, @{ id = 2 }) }) -CurrentTotal 10
if ($cachePartial -ne $true) {
    throw 'The cache refresh detection did not detect a partial cache.'
}

$cacheComplete = & (Get-Command -Name Test-ZEPOffersCacheNeedsRefresh -ErrorAction Stop) -CacheObject ([pscustomobject]@{ generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); total = 10; items = @(@{ id = 1 }, @{ id = 2 }, @{ id = 3 }, @{ id = 4 }, @{ id = 5 }, @{ id = 6 }, @{ id = 7 }, @{ id = 8 }, @{ id = 9 }, @{ id = 10 }) }) -CurrentTotal 10
if ($cacheComplete -ne $false) {
    throw 'The cache refresh detection incorrectly flagged a complete cache as stale.'
}

$cacheExpired = & (Get-Command -Name Test-ZEPOffersCacheNeedsRefresh -ErrorAction Stop) -CacheObject ([pscustomobject]@{ generatedAtUtc = (Get-Date).ToUniversalTime().AddMinutes(-61).ToString('o'); total = 10; items = @(@{ id = 1 }, @{ id = 2 }, @{ id = 3 }, @{ id = 4 }, @{ id = 5 }, @{ id = 6 }, @{ id = 7 }, @{ id = 8 }, @{ id = 9 }, @{ id = 10 }) }) -CurrentTotal 10 -CacheTtlMinutes 60
if ($cacheExpired -ne $true) {
    throw 'The cache refresh detection did not detect an expired cache based on TTL.'
}

$tempCachePath = Join-Path ([System.IO.Path]::GetTempPath()) ('InvokeZEP-search-test-' + [guid]::NewGuid().ToString('N') + '.json')
try {
    $cacheFixture = [pscustomobject]@{
        version = 1
        generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        total = 3
        items = @(
            [pscustomobject]@{ id = 1; data = [pscustomobject]@{ id = 1; title = 'Test Angebot'; status = [pscustomobject]@{ name = 'Aktiv' } } },
            [pscustomobject]@{ id = 2; data = [pscustomobject]@{ id = 2; title = 'Anderes Angebot'; status = [pscustomobject]@{ name = 'Inaktiv' } } },
            [pscustomobject]@{ id = 3; data = [pscustomobject]@{ id = 3; title = 'Demo test case'; status = [pscustomobject]@{ name = 'Aktiv' } } }
        )
    }

    $cacheFixture | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tempCachePath -Encoding UTF8

    $searchResults = & (Get-Command -Name Search-ZEPOffers -ErrorAction Stop) -PropertyName 'title' -SearchTerm 'test' -ReferenceSampleSize 1000 -MaxResults 5 -ValidateCache:$false -CachePath $tempCachePath
    if (@($searchResults).Count -ne 2) {
        throw 'Search-ZEPOffers did not return the expected amount of title matches.'
    }
}
finally {
    if (Test-Path -LiteralPath $tempCachePath) {
        Remove-Item -LiteralPath $tempCachePath -Force -ErrorAction SilentlyContinue
    }
}

$manifest = Test-ModuleManifest -Path (Join-Path $PSScriptRoot '..\InvokeZEP.psd1')
if ($manifest.Version.ToString() -ne '0.4.0') {
    throw "Unexpected module version $($manifest.Version)."
}

# --- ConvertFrom-ZEPDateTime: ZEP sends Europe/Berlin wall clock marked as 'Z'
$summer = ConvertFrom-ZEPDateTime -Value '2026-10-07T01:24:35.000000Z'
if ($summer.Kind -ne [DateTimeKind]::Utc -or $summer.ToString('yyyy-MM-ddTHH:mm:ss') -ne '2026-10-06T23:24:35') {
    throw "ConvertFrom-ZEPDateTime (Sommerzeit) lieferte $($summer.ToString('o'))."
}

$winter = ConvertFrom-ZEPDateTime -Value '2026-12-01T10:00:00Z'
if ($winter.ToString('yyyy-MM-ddTHH:mm:ss') -ne '2026-12-01T09:00:00') {
    throw "ConvertFrom-ZEPDateTime (Winterzeit) lieferte $($winter.ToString('o'))."
}

# PowerShell 7 delivers [datetime] Kind=Utc with the unchanged wall clock; some versions Kind=Local (shifted).
$fromUtcKind = ConvertFrom-ZEPDateTime -Value ([datetime]::new(2026, 10, 7, 1, 24, 35, [DateTimeKind]::Utc))
$fromLocalKind = ConvertFrom-ZEPDateTime -Value ([datetime]::new(2026, 10, 7, 1, 24, 35, [DateTimeKind]::Utc).ToLocalTime())
if ($fromUtcKind -ne $summer -or $fromLocalKind -ne $summer) {
    throw 'ConvertFrom-ZEPDateTime behandelt [datetime]-Eingaben nicht wie den Rohstring.'
}

$dateOnly = ConvertFrom-ZEPDateTime -Value '2026-12-31T00:00:00.000000Z' -AsDate
if ($dateOnly.ToString('yyyy-MM-dd') -ne '2026-12-31' -or $dateOnly.Kind -ne [DateTimeKind]::Utc) {
    throw "ConvertFrom-ZEPDateTime -AsDate lieferte $($dateOnly.ToString('o'))."
}

if ($null -ne (ConvertFrom-ZEPDateTime -Value $null) -or $null -ne (ConvertFrom-ZEPDateTime -Value '')) {
    throw 'ConvertFrom-ZEPDateTime sollte fuer leere Werte $null liefern.'
}

# --- ConvertTo-ZEPTypedOffer
$rawOffer = '{"id":18380,"customer_id":"10000000002","department_id":1,"title":"ZZ_TEST","status":{"id":10,"name":"neu"},"status_since":"2026-10-07T01:24:35.000000Z","valid_until":"2027-01-31T00:00:00.000000Z","order_date":null,"responsible_username":"m.mustermann","processor_username":"m.mustermann","default_validity":null}' | ConvertFrom-Json
$typedOffer = ConvertTo-ZEPTypedOffer -Offer $rawOffer
if ($typedOffer.id -isnot [int] -or $typedOffer.status.id -isnot [int] -or $typedOffer.customer_id -isnot [string]) {
    throw 'ConvertTo-ZEPTypedOffer setzt die ID-Typen nicht korrekt.'
}
if ($typedOffer.status_since -ne $summer -or $typedOffer.valid_until.ToString('yyyy-MM-dd') -ne '2027-01-31' -or $null -ne $typedOffer.order_date) {
    throw 'ConvertTo-ZEPTypedOffer wandelt die Datumswerte nicht korrekt.'
}
if ($typedOffer.title -ne 'ZZ_TEST' -or $typedOffer.processor_username -ne 'm.mustermann') {
    throw 'ConvertTo-ZEPTypedOffer veraendert unbeteiligte Felder.'
}

# --- Paging, id[] and query arrays with a mocked API
$zepModule = Get-Module InvokeZEP
& $zepModule {
    $script:MockCalls = [System.Collections.Generic.List[object]]::new()
    $script:MockOffers = @(1..250 | ForEach-Object { [pscustomobject]@{ id = $_ + 1000; title = "Angebot $_"; status = [pscustomobject]@{ id = 10; name = 'neu' }; status_since = '2026-10-07T01:24:35Z'; customer_id = '1' } })

    function script:Invoke-ZEPRest {
        param ([string]$Path, [string]$Method = 'GET', [hashtable]$Query, $Body, [int]$RetryCount, [double]$ThrottleDelaySeconds)
        $script:MockCalls.Add([pscustomobject]@{ Path = $Path; Query = $Query })
        $offers = $script:MockOffers
        if ($Query.orderBy -eq 'status' -and $script:MockHonorsSort) {
            $offers = @($offers | Sort-Object { $_.status.id }, { $_.id })
        }
        if ($Query.ContainsKey('id[]')) {
            $wanted = @($Query['id[]'] | ForEach-Object { [int]$_ })
            $offers = @($offers | Where-Object { $wanted -contains $_.id })
        }
        $limit = [int]$Query.limit
        $page = [int]$Query.page
        $lastPage = [Math]::Max(1, [int][Math]::Ceiling($offers.Count / $limit))
        $data = @($offers | Select-Object -Skip (($page - 1) * $limit) -First $limit)
        return [pscustomobject]@{ data = $data; meta = [pscustomobject]@{ total = $offers.Count; last_page = $lastPage; current_page = $page } }
    }
}

$recent = @(Get-ZEPRecentOffers -AfterId 1245 -PageSize 100)
if (($recent.id -join ',') -ne '1246,1247,1248,1249,1250') {
    throw "Get-ZEPRecentOffers -AfterId lieferte: $($recent.id -join ',')"
}
$pagesRequested = @(& $zepModule { $script:MockCalls } | Where-Object { $_.Query.limit -eq 100 } | ForEach-Object { $_.Query.page })
if (($pagesRequested -join ',') -ne '3') {
    throw "Get-ZEPRecentOffers hat unnoetige Seiten gelesen: $($pagesRequested -join ',')"
}

& $zepModule { $script:MockCalls.Clear() }
$recentAcrossPages = @(Get-ZEPRecentOffers -AfterId 1140 -PageSize 100)
if ($recentAcrossPages.Count -ne 110 -or $recentAcrossPages[0].id -ne 1141 -or $recentAcrossPages[-1].id -ne 1250) {
    throw 'Get-ZEPRecentOffers liest ueber Seitengrenzen nicht korrekt.'
}

$lastFive = @(Get-ZEPRecentOffers -Last 5 -PageSize 100)
if (($lastFive.id -join ',') -ne '1246,1247,1248,1249,1250' -or $lastFive[0].status_since -ne $summer) {
    throw 'Get-ZEPRecentOffers -Last liefert falsche oder untypisierte Angebote.'
}

& $zepModule { $script:MockCalls.Clear() }
$byId = @(Get-ZEPOffersById -Id 1100, 1001, 1100, 99999)
if (($byId.id -join ',') -ne '1001,1100') {
    throw "Get-ZEPOffersById lieferte: $($byId.id -join ',')"
}
$idCall = & $zepModule { $script:MockCalls[0] }
if ((@($idCall.Query['id[]']) -join ',') -ne '1100,1001,99999') {
    throw 'Get-ZEPOffersById uebergibt die IDs nicht als Liste.'
}

$manyIds = @(Get-ZEPOffersById -Id (1001..1250))
if ($manyIds.Count -ne 250 -or @(& $zepModule { $script:MockCalls } | Where-Object { $_.Query.ContainsKey('id[]') }).Count -ne 4) {
    throw 'Get-ZEPOffersById teilt grosse ID-Listen nicht in Bloecke zu 100.'
}

# --- Get-ZEPOffersByStatus: 1001-1250 with mixed status, sorted by status via orderBy
& $zepModule {
    $script:MockCalls.Clear()
    $statusCycle = @(10, 20, 30, 60, 200)
    foreach ($offer in $script:MockOffers) {
        $statusValue = $statusCycle[($offer.id - 1001) % 5]
        $offer.status = [pscustomobject]@{ id = $statusValue; name = "s$statusValue" }
    }
    $script:MockHonorsSort = $true
}

$byStatus = @(Get-ZEPOffersByStatus -StatusId 10, 20 -AfterId 1200 -PageSize 50)
$expectedIds = @(1201..1250 | Where-Object { @(0, 1) -contains (($_ - 1001) % 5) })
if (($byStatus.id -join ',') -ne ($expectedIds -join ',') -or $byStatus[0].status.id -isnot [int]) {
    throw "Get-ZEPOffersByStatus lieferte: $($byStatus.id -join ',')"
}
# 50 offers per status: page 1 = status 10, page 2 = status 20, page 3 = status 30 -> stop
$statusPages = @(& $zepModule { $script:MockCalls } | ForEach-Object { $_.Query.page })
if (($statusPages -join ',') -ne '1,2,3') {
    throw "Get-ZEPOffersByStatus hat falsche Seiten gelesen: $($statusPages -join ',')"
}

& $zepModule { $script:MockHonorsSort = $false }
$unsortedFailed = $false
try { $null = Get-ZEPOffersByStatus -StatusId 10, 20 -PageSize 50 }
catch { $unsortedFailed = $_.Exception.Message -match 'nicht nach Status sortiert' }
if (-not $unsortedFailed) {
    throw 'Get-ZEPOffersByStatus bricht bei unsortierter Liste nicht ab.'
}

Remove-Module InvokeZEP -Force
Import-Module $modulePath -Force

# --- Invoke-ZEPRest builds repeated keys for array values
& (Get-Module InvokeZEP) {
    function script:Get-ZEPBearerToken { return 'test-token' }
    function script:Invoke-RestMethod { param ([uri]$Uri, [string]$Method, [hashtable]$Headers, $ErrorAction) $script:CapturedUri = $Uri.AbsoluteUri; return [pscustomobject]@{ data = @() } }
    $null = Invoke-ZEPRest -Path 'offers' -Query @{ 'id[]' = @(1, 2) } -ThrottleDelaySeconds 0
    if ($script:CapturedUri -notmatch 'id%5b%5d=1&id%5b%5d=2') {
        throw "Invoke-ZEPRest baut Listenparameter falsch: $($script:CapturedUri)"
    }
}

Remove-Module InvokeZEP -Force
Import-Module $modulePath -Force

Write-Host 'InvokeZEP module import and export validation passed.' -ForegroundColor Green