Public/Tests/New-OpenApiModule.Tests.ps1

BeforeAll {
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'
    $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.openapi.psd1') -Force
    $RepoRoot = Split-Path -Path (Split-Path -Path $ModuleRoot -Parent) -Parent
    . (Join-Path -Path $RepoRoot -ChildPath 'tests/Helpers/New-TestOpenApiModel.ps1')

    function Get-TreeContent {
        param([string]$Path)
        $root = (Resolve-Path -LiteralPath $Path).ProviderPath
        $result = @{}
        foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File) {
            $result[$file.FullName.Substring($root.Length).Replace('\', '/')] = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($file.FullName))
        }
        $result
    }
}

Describe 'New-OpenApiModule' {
    BeforeEach {
        $out = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('n'))
    }

    It 'writes the module and returns the result object' {
        $result = New-OpenApiModule -Document (New-TestOpenApiModel -Name petstore) -ModuleName 'PetStore' -OutputPath $out -NounPrefix 'PetStore'
        $result.PSObject.TypeNames | Should -Contain 'Tcs.OpenApi.GenerationResult'
        $result.ModuleName | Should -Be 'PetStore'
        $result.Path | Should -Be (Join-Path -Path $out -ChildPath 'PetStore')
        $result.ManifestPath | Should -Be (Join-Path -Path $result.Path -ChildPath 'PetStore.psd1')
        Test-Path -LiteralPath $result.ManifestPath | Should -BeTrue
        $pet = $result.Functions | Where-Object -FilterScript { $_.OperationId -eq 'showPetById' }
        $pet.Name | Should -Be 'Get-PetStorePetById'
        $pet.File | Should -Be 'Public/Pets/Get-PetStorePetById.ps1'
        $pet.Action | Should -Be 'Created'
        @($result.Skipped).Count | Should -Be 0
        @($result.Files).Count | Should -Be 18
        Test-ModuleManifest -Path $result.ManifestPath -ErrorAction SilentlyContinue | Out-Null
        (Import-PowerShellDataFile -Path $result.ManifestPath).FunctionsToExport.Count | Should -Be 12
    }

    It 'requires tcs.openapi at the version of the generator' {
        $result = New-OpenApiModule -Document (New-TestOpenApiModel -Name petstore) -ModuleName 'PetStore' -OutputPath $out
        $version = (Import-PowerShellDataFile -Path (Join-Path -Path $ModuleRoot -ChildPath 'tcs.openapi.psd1')).ModuleVersion
        (Import-PowerShellDataFile -Path $result.ManifestPath).RequiredModules[0].ModuleVersion | Should -Be $version
    }

    It 'merges the document findings with the generator findings' {
        $result = New-OpenApiModule -Document (New-TestOpenApiModel -Name reserved) -ModuleName 'Reserved' -OutputPath $out
        $codes = @($result.Findings.Code)
        $codes | Should -Contain 'OA020'
        $codes | Should -Contain 'OA040'
        $codes | Should -Contain 'OA041'
        $codes | Should -Contain 'OA070'
        $codes[0] | Should -Be 'OA020' -Because 'the document findings come first'
        $result.Skipped.OperationId | Should -Be @('getRemote')
    }

    It 'accepts the document from the pipeline' {
        $result = New-TestOpenApiModel -Name bodies | New-OpenApiModule -ModuleName 'Shop' -OutputPath $out -NounPrefix 'Shop'
        $result.Functions.Name | Should -Contain 'New-ShopOrder'
    }

    It 'writes nothing with -WhatIf' {
        $result = New-OpenApiModule -Document (New-TestOpenApiModel -Name petstore) -ModuleName 'PetStore' -OutputPath $out -WhatIf
        Test-Path -LiteralPath $out | Should -BeFalse
        @($result.Files.Action | Select-Object -Unique) | Should -Be @('Skipped')
        @($result.Functions.Action | Select-Object -Unique) | Should -Be @('Skipped')
    }

    It 'produces byte-identical output for the same input' {
        $first = Join-Path -Path $out -ChildPath 'one'
        $second = Join-Path -Path $out -ChildPath 'two'
        New-OpenApiModule -Document (New-TestOpenApiModel -Name bodies) -ModuleName 'Shop' -OutputPath $first -NounPrefix 'Shop' | Out-Null
        New-OpenApiModule -Document (New-TestOpenApiModel -Name bodies) -ModuleName 'Shop' -OutputPath $second -NounPrefix 'Shop' | Out-Null
        $a = Get-TreeContent -Path $first
        $b = Get-TreeContent -Path $second
        @($a.Keys | Sort-Object) | Should -Be @($b.Keys | Sort-Object)
        foreach ($key in $a.Keys) {
            $b[$key] | Should -BeExactly $a[$key] -Because $key
        }
    }

    Context 'regenerating' {
        BeforeEach {
            $result = New-OpenApiModule -Document (New-TestOpenApiModel -Name petstore) -ModuleName 'PetStore' -OutputPath $out -NounPrefix 'PetStore'
            $overrides = Join-Path -Path $result.Path -ChildPath 'Overrides.ps1'
            $generated = Join-Path -Path $result.Path -ChildPath 'README.md'
        }

        It 'reports unchanged files when nothing changed' {
            $again = New-OpenApiModule -Document (New-TestOpenApiModel -Name petstore) -ModuleName 'PetStore' -OutputPath $out -NounPrefix 'PetStore'
            @($again.Files | Where-Object -FilterScript { $_.RelativePath -ne 'Overrides.ps1' } | ForEach-Object -Process { $_.Action } | Select-Object -Unique) | Should -Be @('Unchanged')
            ($again.Files | Where-Object -FilterScript { $_.RelativePath -eq 'Overrides.ps1' }).Action | Should -Be 'Preserved'
        }

        It 'refuses to replace changed generated files without -Force' {
            Add-Content -LiteralPath $generated -Value 'edited'
            { New-OpenApiModule -Document (New-TestOpenApiModel -Name petstore) -ModuleName 'PetStore' -OutputPath $out -NounPrefix 'PetStore' -ErrorAction Stop } | Should -Throw '*-Force*'
            Get-Content -LiteralPath $generated -Raw | Should -Match 'edited'
        }

        It 'replaces changed generated files with -Force and keeps Overrides.ps1' {
            Add-Content -LiteralPath $generated -Value 'edited'
            Set-Content -LiteralPath $overrides -Value 'function Get-PetStoreHealth { ''mine'' }'
            $again = New-OpenApiModule -Document (New-TestOpenApiModel -Name petstore) -ModuleName 'PetStore' -OutputPath $out -NounPrefix 'PetStore' -Force
            ($again.Files | Where-Object -FilterScript { $_.RelativePath -eq 'README.md' }).Action | Should -Be 'Replaced'
            ($again.Files | Where-Object -FilterScript { $_.RelativePath -eq 'Overrides.ps1' }).Action | Should -Be 'Preserved'
            Get-Content -LiteralPath $generated -Raw | Should -Not -Match 'edited'
            Get-Content -LiteralPath $overrides -Raw | Should -Match 'mine'
        }

        It 'removes the command of an operation that was removed from the document, with -Force' {
            $document = New-TestOpenApiModel -Name petstore
            $document.Operations = @($document.Operations | Where-Object -FilterScript { $_.OperationId -ne 'getPetPhoto' })
            $again = New-OpenApiModule -Document $document -ModuleName 'PetStore' -OutputPath $out -NounPrefix 'PetStore' -Force
            ($again.Files | Where-Object -FilterScript { $_.RelativePath -eq 'Public/Pets/Get-PetStorePetPhoto.ps1' }).Action | Should -Be 'Removed'
            Test-Path -LiteralPath (Join-Path -Path $result.Path -ChildPath 'Public/Pets/Get-PetStorePetPhoto.ps1') | Should -BeFalse
        }
    }

    It 'fails clearly for -Path when Import-OpenApiDocument is not available' {
        Mock -ModuleName tcs.openapi Get-Command { $null } -ParameterFilter { $Name -eq 'Import-OpenApiDocument' }
        { New-OpenApiModule -Path (Join-Path -Path $TestDrive -ChildPath 'x.json') -ModuleName 'X' -OutputPath $out -ErrorAction Stop } | Should -Throw '*Import-OpenApiDocument*-Document*'
    }

    It 'reads -Path and -Uri with Import-OpenApiDocument when it is available' {
        function global:Import-TestDocumentStub {
            param([string]$Path, [uri]$Uri)
            $global:TestImportArguments = @($Path, $Uri)
            New-TestOpenApiModel -Name petstore
        }
        try {
            Mock -ModuleName tcs.openapi Get-Command { Microsoft.PowerShell.Core\Get-Command -Name 'Import-TestDocumentStub' } -ParameterFilter { $Name -eq 'Import-OpenApiDocument' }
            $result = New-OpenApiModule -Path './petstore.json' -ModuleName 'PetStore' -OutputPath $out
            $global:TestImportArguments[0] | Should -Be './petstore.json'
            $result.Functions.Count | Should -Be 12
            New-OpenApiModule -Uri 'https://example.com/openapi.json' -ModuleName 'PetStore2' -OutputPath $out | Out-Null
            [string]$global:TestImportArguments[1] | Should -Be 'https://example.com/openapi.json'
        }
        finally {
            Remove-Item -Path 'function:global:Import-TestDocumentStub' -ErrorAction SilentlyContinue
            Remove-Variable -Name TestImportArguments -Scope Global -ErrorAction SilentlyContinue
        }
    }

    It 'generates a module for a document with a default server and no security scheme' {
        $text = '{"openapi":"3.0.3","info":{"title":"Open","version":"1"},"servers":[{"url":"https://api.example.test"}],"paths":{"/items":{"get":{"operationId":"listItems","responses":{"200":{"description":"ok"}}}}}}'
        $document = Import-OpenApiDocument -InputObject $text
        $result = New-OpenApiModule -Document $document -ModuleName 'Open' -OutputPath $out -NounPrefix 'Open'
        $result.Functions.Name | Should -Contain 'Get-OpenItem'
        $readme = Get-Content -LiteralPath (Join-Path -Path $result.Path -ChildPath 'README.md') -Raw
        $readme | Should -Match '(?m)^Set-OpenContext\r?$'
    }

    It 'rejects an object that is not a document model' {
        { New-OpenApiModule -Document ([pscustomobject]@{ Title = 'x' }) -ModuleName 'X' -OutputPath $out -ErrorAction Stop } | Should -Throw '*no Operations*'
    }

    It 'stores -UnwrapProperty in the metadata of the operations whose response has that property' {
        $wrapped = New-TestSchema -Type object -Properties ([ordered]@{ data = New-TestSchema -Type object -RefName 'Host'; traceId = New-TestSchema -Type string })
        $plain = New-TestSchema -Type object -Properties ([ordered]@{ success = New-TestSchema -Type boolean })
        $document = New-TestDocument -Operations @(
            (New-TestOperation -OperationId 'getHost' -Method GET -Path '/hosts/{id}' -Parameters @((New-TestParameter -Name 'id' -In path)) -Responses @((New-TestResponse -Content @((New-TestMediaType -ContentType 'application/json' -Schema $wrapped)))))
            (New-TestOperation -OperationId 'ping' -Method POST -Path '/ping' -Responses @((New-TestResponse -Content @((New-TestMediaType -ContentType 'application/json' -Schema $plain)))))
        )
        $result = New-OpenApiModule -Document $document -ModuleName 'Wrap' -OutputPath $out -NounPrefix 'W' -UnwrapProperty 'data'
        $metadata = Get-Content -LiteralPath (Join-Path -Path $result.Path -ChildPath 'OpenApi/operations.json') -Raw | ConvertFrom-Json
        $metadata[0].OperationId | Should -Be 'getHost'
        $metadata[0].UnwrapProperty | Should -Be 'data'
        $metadata[0].ResponseTypeName | Should -Be 'Wrap.Host'
        $metadata[1].PSObject.Properties.Name | Should -Not -Contain 'UnwrapProperty'
        Get-Content -LiteralPath (Join-Path -Path $result.Path -ChildPath 'Public/Default/Get-WHost.ps1') -Raw | Should -Match "OutputType\('Wrap.Host'\)"
    }

    It 'rejects an invalid module name or noun prefix' {
        { New-OpenApiModule -Document (New-TestDocument) -ModuleName '1x' -OutputPath $out } | Should -Throw
        { New-OpenApiModule -Document (New-TestDocument) -ModuleName 'X' -NounPrefix 'a-b' -OutputPath $out } | Should -Throw
    }

    It 'has help with a synopsis, a description, every parameter and examples' {
        $help = Get-Help -Name New-OpenApiModule -Full
        $help.Synopsis | Should -Not -BeNullOrEmpty
        $help.Description | Should -Not -BeNullOrEmpty
        @($help.Examples.Example).Count | Should -BeGreaterThan 0
        foreach ($name in @('Path', 'Uri', 'Document', 'ModuleName', 'OutputPath', 'NounPrefix', 'UnwrapProperty', 'ModuleVersion', 'Author', 'Force')) {
            ($help.Parameters.Parameter | Where-Object -FilterScript { $_.Name -eq $name }).Description | Should -Not -BeNullOrEmpty -Because $name
        }
    }
}