functions/_getExtensionFromGit.Tests.ps1

# <copyright file="_getExtensionFromGit.Tests.ps1" company="Endjin Limited">
# Copyright (c) Endjin Limited. All rights reserved.
# </copyright>

BeforeAll {
    # sut
    . $PSCommandPath.Replace('.Tests.ps1','.ps1')

    # in-module dependencies
    . (Join-Path (Split-Path -Parent $PSCommandPath) 'Get-InstalledExtensionDetails.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_getCompleteExtensionVersion.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_copyFolderFromGitRepo.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_getExtensionVersionRange.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_testExtensionVersionInRange.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_getVersionFromGitTag.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_getGitTagVersions.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_getGitExtensionFolderName.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_getNormalizedGitRepositoryUrl.ps1')
    . (Join-Path (Split-Path -Parent $PSCommandPath) '_removeFolderWithRetry.ps1')

    Set-StrictMode -Version Latest

    # The name of the folder that an extension from the given source is installed in
    function getFolderName([string] $Ref, [string] $Repository = 'https://github.com/zerofailed/ZeroFailed.Build.Test', [string] $FolderPath = 'module') {
        _getGitExtensionFolderName -RepositoryUri $Repository -RepositoryFolderPath $FolderPath -GitRef $Ref
    }

    # Creates the minimum folder structure of an installed extension, optionally recording the release tag it came from
    function newInstalledExtension([string] $Path, [string] $Name, [string] $Commit, [string] $Ref, [string] $Repository) {
        New-Item -ItemType Directory -Path $Path -Force | Out-Null
        Set-Content -Path (Join-Path $Path "$Name.psd1") -Value "@{ ModuleVersion = '1.0.0' }"
        if ($Ref) {
            @{ repository = $Repository; folderPath = 'module'; ref = $Ref; commit = $Commit } | ConvertTo-Json | Set-Content -Path (Join-Path $Path '.zf-source.json')
        }
        elseif ($Commit) {
            @{ commit = $Commit } | ConvertTo-Json | Set-Content -Path (Join-Path $Path '.zf-source.json')
        }
    }
}

Describe '_getExtensionFromGit' {

    BeforeAll {
        $name = 'ZeroFailed.Build.Test'
        $repo = 'https://github.com/zerofailed/ZeroFailed.Build.Test'
        $mainFolder = getFolderName -Ref 'main'

        Mock Write-Host {}

        # Simulate a checkout by creating the extension folder; the commit is the pinned one, or the latest one
        Mock _copyFolderFromGitRepo {
            if (Test-Path $DestinationPath) { Remove-Item $DestinationPath -Recurse -Force }
            newInstalledExtension -Path $DestinationPath -Name 'ZeroFailed.Build.Test'
            $Commit ? $Commit : 'latest-commit'
        }
    }

    BeforeEach {
        $targetPath = Join-Path $TestDrive "extensions-$(New-Guid)"
    }

    Context 'When the extension is not installed' {
        BeforeEach {
            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main'
        }

        It 'Installs the extension into a folder named after the git reference and the source' {
            $result.Path | Should -Be (Join-Path $targetPath $name $mainFolder)
            $result.Enabled | Should -Be $true
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter {
                $RepoUrl -eq $repo -and $GitRef -eq 'main' -and $RepoFolderPath -eq 'module' -and !$Commit
            }
        }

        It 'Returns the commit that was installed' {
            $result.ResolvedVersion | Should -Be 'latest-commit'
        }

        It 'Records where the extension was installed from' {
            $sourceInfo = Get-Content -Raw (Join-Path $result.Path '.zf-source.json') | ConvertFrom-Json -AsHashtable

            $sourceInfo.repository | Should -Be $repo
            $sourceInfo.folderPath | Should -Be 'module'
            $sourceInfo.ref | Should -Be 'main'
            $sourceInfo.commit | Should -Be 'latest-commit'
        }

        It 'Does not return a release tag' {
            $result.ContainsKey('ResolvedRef') | Should -Be $false
        }

        It 'Copies the files straight into the install folder' {
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $DestinationPath -eq (Join-Path $targetPath $name $mainFolder) }
            (Get-ChildItem -Path (Join-Path $targetPath $name) -Force).Name | Should -Be @($mainFolder)
        }
    }

    Context 'When neither a git reference nor a minimum version is requested' {
        It 'Throws an error' {
            { _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath } |
                Should -Throw "A 'GitRef' or 'MinimumVersion' is required to install extension $name from $repo repository"
        }
    }

    Context 'When the git reference contains a /' {
        It 'Uses a folder name with the / replaced' {
            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'feature/foo'

            $result.Path | Should -Be (Join-Path $targetPath $name (getFolderName -Ref 'feature/foo'))
            Split-Path -Leaf $result.Path | Should -BeLike 'feature-foo-*'
        }
    }

    Context 'When the extension is already installed' {
        It 'Uses the installed extension and returns its recorded commit' {
            newInstalledExtension -Path (Join-Path $targetPath $name $mainFolder) -Name $name -Commit 'installed-commit'

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main'

            $result.Path | Should -Be (Join-Path $targetPath $name $mainFolder)
            $result.ResolvedVersion | Should -Be 'installed-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 0
        }

        It 'Uses the installed extension when it was installed from the same source, ignoring case and a trailing /' {
            newInstalledExtension -Path (Join-Path $targetPath $name $mainFolder) -Name $name -Commit 'installed-commit' -Ref 'main' -Repository "$($repo.ToUpperInvariant())/"

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main'

            $result.ResolvedVersion | Should -Be 'installed-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 0
        }

        It 'Uses the installed extension when it was installed from the same repository with a trailing .git' {
            newInstalledExtension -Path (Join-Path $targetPath $name $mainFolder) -Name $name -Commit 'installed-commit' -Ref 'main' -Repository $repo

            $result = _getExtensionFromGit -Name $name -RepositoryUri "$repo.git" -TargetPath $targetPath -GitRef 'main'

            $result.Path | Should -Be (Join-Path $targetPath $name $mainFolder)
            $result.ResolvedVersion | Should -Be 'installed-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 0
        }

        It 'Installs the extension in a separate folder when the same git reference was installed from a different <Description>' -ForEach @(
            @{ Description = 'repository'; Repository = 'https://github.com/myfork/ZeroFailed.Build.Test'; FolderPath = 'module' }
            @{ Description = 'folder in the repository'; Repository = 'https://github.com/zerofailed/ZeroFailed.Build.Test'; FolderPath = 'src' }
            @{ Description = 'case of the folder in the repository'; Repository = 'https://github.com/zerofailed/ZeroFailed.Build.Test'; FolderPath = 'Module' }
        ) {
            $otherPath = Join-Path $targetPath $name (getFolderName -Ref 'main' -Repository $Repository -FolderPath $FolderPath)
            newInstalledExtension -Path $otherPath -Name $name -Commit 'other-commit' -Ref 'main' -Repository $Repository
            $otherSourceInfoPath = Join-Path $otherPath '.zf-source.json'
            $otherSourceInfo = Get-Content -Raw $otherSourceInfoPath | ConvertFrom-Json -AsHashtable
            $otherSourceInfo.folderPath = $FolderPath
            $otherSourceInfo | ConvertTo-Json | Set-Content -Path $otherSourceInfoPath

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main'

            $result.Path | Should -Be (Join-Path $targetPath $name $mainFolder)
            $result.Path | Should -Not -Be $otherPath
            $result.ResolvedVersion | Should -Be 'latest-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $RepoUrl -eq $repo -and $GitRef -eq 'main' }
            (Get-Content -Raw (Join-Path $result.Path '.zf-source.json') | ConvertFrom-Json).repository | Should -Be $repo
            (Get-Content -Raw $otherSourceInfoPath | ConvertFrom-Json).commit | Should -Be 'other-commit'
        }

        It 'Re-installs the extension when its folder holds a copy from a different <Description>' -ForEach @(
            @{ Description = 'repository'; Repository = 'https://github.com/myfork/ZeroFailed.Build.Test'; FolderPath = 'module' }
            @{ Description = 'case of the folder in the repository'; Repository = 'https://github.com/zerofailed/ZeroFailed.Build.Test'; FolderPath = 'Module' }
        ) {
            # Only possible if two sources' hashes collide, or the folder was changed by hand
            $installedPath = Join-Path $targetPath $name $mainFolder
            newInstalledExtension -Path $installedPath -Name $name -Commit 'other-commit' -Ref 'main' -Repository $Repository
            $installedSourceInfoPath = Join-Path $installedPath '.zf-source.json'
            $installedSourceInfo = Get-Content -Raw $installedSourceInfoPath | ConvertFrom-Json -AsHashtable
            $installedSourceInfo.folderPath = $FolderPath
            $installedSourceInfo | ConvertTo-Json | Set-Content -Path $installedSourceInfoPath

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main'

            $result.Path | Should -Be $installedPath
            $result.ResolvedVersion | Should -Be 'latest-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $RepoUrl -eq $repo -and $GitRef -eq 'main' }
            (Get-Content -Raw (Join-Path $installedPath '.zf-source.json') | ConvertFrom-Json).repository | Should -Be $repo
        }

        It 'Installs the extension again when it is in a folder named after the git reference only, as earlier versions of ZeroFailed did' {
            newInstalledExtension -Path (Join-Path $targetPath $name 'main') -Name $name -Commit 'installed-commit' -Ref 'main' -Repository $repo

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main'

            $result.Path | Should -Be (Join-Path $targetPath $name $mainFolder)
            $result.ResolvedVersion | Should -Be 'latest-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1
        }

        It 'Installs the extension again when its install did not complete' {
            newInstalledExtension -Path (Join-Path $targetPath $name $mainFolder) -Name $name

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main'

            $result.Path | Should -Be (Join-Path $targetPath $name $mainFolder)
            $result.ResolvedVersion | Should -Be 'latest-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1
            Join-Path $result.Path '.zf-source.json' | Should -Exist
        }
    }

    Context 'When a commit is locked' {
        It 'Uses the installed extension when it is at the locked commit' {
            newInstalledExtension -Path (Join-Path $targetPath $name $mainFolder) -Name $name -Commit 'locked-commit'

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main' -LockedCommit 'locked-commit'

            $result.ResolvedVersion | Should -Be 'locked-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 0
        }

        It 'Re-installs the extension when it is at a different commit' {
            newInstalledExtension -Path (Join-Path $targetPath $name $mainFolder) -Name $name -Commit 'other-commit'

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main' -LockedCommit 'locked-commit'

            $result.ResolvedVersion | Should -Be 'locked-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $Commit -eq 'locked-commit' -and $GitRef -eq 'main' }
            (Get-Content -Raw (Join-Path $result.Path '.zf-source.json') | ConvertFrom-Json).commit | Should -Be 'locked-commit'
        }

        It 'Re-installs the extension when its install did not complete' {
            newInstalledExtension -Path (Join-Path $targetPath $name $mainFolder) -Name $name

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main' -LockedCommit 'locked-commit'

            $result.ResolvedVersion | Should -Be 'locked-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $Commit -eq 'locked-commit' }
        }

        It 'Installs the locked commit when the extension is not installed' {
            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main' -LockedCommit 'locked-commit'

            $result.ResolvedVersion | Should -Be 'locked-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $Commit -eq 'locked-commit' }
        }
    }

    Context 'When a minimum version is requested' {
        BeforeAll {
            # Simulate a repository with release tags, including some outside the range
            Mock _getGitTagVersions {
                foreach ($tag in '1.3.0', '1.4.0', 'v1.6.1', '1.7.0-beta.1', '2.0.0') {
                    @{ Ref = "refs/tags/$tag"; Version = [semver]$tag.TrimStart('v') }
                }
            }
        }

        It 'Installs the newest release tag in the range' {
            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -MinimumVersion '1.4.0'

            $result.Path | Should -Be (Join-Path $targetPath $name (getFolderName -Ref 'refs/tags/v1.6.1'))
            $result.ResolvedRef | Should -Be 'refs/tags/v1.6.1'
            $result.ResolvedVersion | Should -Be 'latest-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $GitRef -eq 'refs/tags/v1.6.1' -and !$Commit }
            (Get-Content -Raw (Join-Path $result.Path '.zf-source.json') | ConvertFrom-Json).ref | Should -Be 'refs/tags/v1.6.1'
        }

        It 'Includes pre-release tags in the range when they are requested' {
            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -MinimumVersion '1.4.0' -PreRelease

            $result.ResolvedRef | Should -Be 'refs/tags/1.7.0-beta.1'
        }

        It 'Uses an installed release in the range without listing the tags' {
            newInstalledExtension -Path (Join-Path $targetPath $name (getFolderName -Ref 'refs/tags/1.5.0')) -Name $name -Commit 'installed-commit' -Ref 'refs/tags/1.5.0' -Repository "$repo/"

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -MinimumVersion '1.4.0'

            $result.Path | Should -Be (Join-Path $targetPath $name (getFolderName -Ref 'refs/tags/1.5.0'))
            $result.ResolvedRef | Should -Be 'refs/tags/1.5.0'
            $result.ResolvedVersion | Should -Be 'installed-commit'
            Should -Invoke _getGitTagVersions -Times 0
            Should -Invoke _copyFolderFromGitRepo -Times 0
        }

        It 'Ignores installed releases from another repository, or outside the range' {
            $otherRepo = 'https://github.com/other/ZeroFailed.Build.Test'
            newInstalledExtension -Path (Join-Path $targetPath $name (getFolderName -Ref 'refs/tags/1.5.0' -Repository $otherRepo)) -Name $name -Commit 'other-commit' -Ref 'refs/tags/1.5.0' -Repository $otherRepo
            newInstalledExtension -Path (Join-Path $targetPath $name (getFolderName -Ref 'refs/tags/2.0.0')) -Name $name -Commit 'major-commit' -Ref 'refs/tags/2.0.0' -Repository $repo

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -MinimumVersion '1.4.0'

            $result.ResolvedRef | Should -Be 'refs/tags/v1.6.1'
            Should -Invoke _getGitTagVersions -Times 1
        }

        It 'Installs a release tag in a separate folder when another repository with the same tag is installed' {
            # The other repository's copy is ignored when looking for an installed release, so the newest tag is
            # chosen, and it is installed in its own folder
            $otherPath = Join-Path $targetPath $name (getFolderName -Ref 'refs/tags/v1.6.1' -Repository 'https://github.com/other/ZeroFailed.Build.Test')
            newInstalledExtension -Path $otherPath -Name $name -Commit 'other-commit' -Ref 'refs/tags/v1.6.1' -Repository 'https://github.com/other/ZeroFailed.Build.Test'

            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -MinimumVersion '1.4.0'

            $result.Path | Should -Be (Join-Path $targetPath $name (getFolderName -Ref 'refs/tags/v1.6.1'))
            $result.ResolvedRef | Should -Be 'refs/tags/v1.6.1'
            $result.ResolvedVersion | Should -Be 'latest-commit'
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $RepoUrl -eq $repo -and $GitRef -eq 'refs/tags/v1.6.1' }
            (Get-Content -Raw (Join-Path $result.Path '.zf-source.json') | ConvertFrom-Json).repository | Should -Be $repo
            (Get-Content -Raw (Join-Path $otherPath '.zf-source.json') | ConvertFrom-Json).commit | Should -Be 'other-commit'
        }

        It 'Throws when no release tag is in the range' {
            $expectedMessage = "No release of extension $name in $repo repository matches [3.0.0,4.0.0)."

            { _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -MinimumVersion '3.0' } |
                Should -Throw -ExpectedMessage "$([WildcardPattern]::Escape($expectedMessage))*"
        }

        It 'Throws when the release tags cannot be listed' {
            Mock _getGitTagVersions { throw 'git ls-remote failed' }

            { _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -MinimumVersion '1.4.0' } |
                Should -Throw "Failed to list the release tags of extension $name from $repo repository: git ls-remote failed"
        }

        It 'Installs the locked commit of the release tag in the lock file, without listing the tags' {
            $result = _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -MinimumVersion '1.4.0' -GitRef 'refs/tags/1.4.0' -LockedCommit 'locked-commit'

            $result.Path | Should -Be (Join-Path $targetPath $name (getFolderName -Ref 'refs/tags/1.4.0'))
            $result.ResolvedRef | Should -Be 'refs/tags/1.4.0'
            $result.ResolvedVersion | Should -Be 'locked-commit'
            Should -Invoke _getGitTagVersions -Times 0
            Should -Invoke _copyFolderFromGitRepo -Times 1 -ParameterFilter { $GitRef -eq 'refs/tags/1.4.0' -and $Commit -eq 'locked-commit' }
        }
    }

    Context 'When the extension cannot be installed' {
        BeforeAll {
            Mock _copyFolderFromGitRepo { throw 'git fetch failed' }
        }

        It 'Throws an error that includes the reason' {
            { _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main' } |
                Should -Throw "Failed to install extension $name (main) from $repo repository: git fetch failed"
        }

        It 'Leaves an install folder without a source record when the copy stops part way' {
            Mock _copyFolderFromGitRepo {
                newInstalledExtension -Path $DestinationPath -Name 'ZeroFailed.Build.Test'
                throw 'Copy stopped'
            }

            { _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main' } | Should -Throw '*Copy stopped*'

            Join-Path $targetPath $name $mainFolder '.zf-source.json' | Should -Not -Exist
        }

        It 'Removes the source record of an existing install before deleting it, so a failed delete leaves it incomplete' {
            $installedPath = Join-Path $targetPath $name $mainFolder
            newInstalledExtension -Path $installedPath -Name $name -Commit 'other-commit' -Ref 'main' -Repository $repo
            Mock _removeFolderWithRetry { throw "Unable to delete '$Path'" }

            { _getExtensionFromGit -Name $name -RepositoryUri $repo -TargetPath $targetPath -GitRef 'main' -LockedCommit 'locked-commit' } |
                Should -Throw "Failed to install extension $name (main) from $repo repository: Unable to delete '$installedPath'"

            Join-Path $installedPath '.zf-source.json' | Should -Not -Exist
            Should -Invoke _removeFolderWithRetry -Times 1 -ParameterFilter { $Path -eq $installedPath }
            Should -Invoke _copyFolderFromGitRepo -Times 0
            Should -Invoke Write-Host -Times 0 -ParameterFilter { "$Object" -like 'INSTALLED MODULE*' }
        }
    }
}