From fb5677500d71116515d4a08d3da0a255ffe50127 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Wed, 21 Oct 2026 11:15:46 +0200 Subject: [PATCH 1/3] Retry SideBySide SPWebService update conflicts with a bounded policy Retry transient SPWebService.Update() conflicts in Set-SPSSideBySideToken (6 attempts, 15 seconds between attempts), while still failing fast on non-conflict exceptions and surfacing persistent conflicts after the budget. Add Pester coverage for success-after-retry, max-attempts failure, and non-conflict no-retry behavior. Fixes #79 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + .../Public/Set-SPSSideBySideToken.ps1 | 43 ++++++- tests/Set-SPSSideBySideToken.Tests.ps1 | 111 ++++++++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 tests/Set-SPSSideBySideToken.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d24e6..c2f8986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `Set-SPSSideBySideToken` now retries transient SharePoint `SPWebService.Update()` conflicts with a bounded policy (6 attempts, 15 seconds between attempts) instead of failing on the first race with concurrent `PSCONFIG` writes. The retry is conflict-specific: non-conflict errors still fail fast, and a conflict that persists through the budget is surfaced as a failure. ([#79](https://github.com/luigilink/SPSUpdate/issues/79)) + ## [5.3.0] - 2026-10-08 ### Changed diff --git a/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 b/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 index b631c9e..513597f 100644 --- a/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 @@ -11,6 +11,43 @@ $EnableSideBySide ) + $maxUpdateConflictAttempts = 6 + $updateConflictRetryDelaySeconds = 15 + + function Invoke-SPSWebServiceUpdateWithRetry { + param + ( + [Parameter(Mandatory = $true)] + $WebService, + + [Parameter(Mandatory = $true)] + [System.String] + $Operation, + + [Parameter(Mandatory = $true)] + [System.String] + $TargetName + ) + + $attempt = 0 + while ($true) { + $attempt++ + try { + $WebService.Update() + return + } + catch { + $isConflict = $_.Exception.Message -match '(?i)update conflict has occurred' + if (-not $isConflict -or $attempt -ge $maxUpdateConflictAttempts) { + throw + } + + Write-Warning "SPWebService update conflict during '$Operation' on '$TargetName' (attempt $attempt/$maxUpdateConflictAttempts). Retrying in $updateConflictRetryDelaySeconds seconds." + Start-Sleep -Seconds $updateConflictRetryDelaySeconds + } + } + } + $webApps = Get-SPWebApplication -ErrorAction SilentlyContinue if ($null -ne $webApps) { foreach ($webApp in $webApps) { @@ -23,7 +60,7 @@ Write-Output "Enabling EnableSideBySide on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, 'Enable SharePoint side-by-side mode')) { $webApp.WebService.EnableSideBySide = $true - $webApp.WebService.Update() + Invoke-SPSWebServiceUpdateWithRetry -WebService $webApp.WebService -Operation 'EnableSideBySide' -TargetName $spWebAppName } } if ($webApp.WebService.SideBySideToken -eq $BuildVersion) { @@ -33,7 +70,7 @@ Write-Output "Enabling SideBySideToken $BuildVersion on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, "Set SharePoint SideBySideToken to $BuildVersion")) { $webApp.WebService.SideBySideToken = $BuildVersion - $webApp.WebService.Update() + Invoke-SPSWebServiceUpdateWithRetry -WebService $webApp.WebService -Operation 'Set SideBySideToken' -TargetName $spWebAppName } } Write-Output 'Running CmdLet Copy-SPSideBySideFiles' @@ -46,7 +83,7 @@ Write-Output "Disabling EnableSideBySide on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, 'Disable SharePoint side-by-side mode')) { $webApp.WebService.EnableSideBySide = $false - $webApp.WebService.Update() + Invoke-SPSWebServiceUpdateWithRetry -WebService $webApp.WebService -Operation 'DisableSideBySide' -TargetName $spWebAppName } } else { diff --git a/tests/Set-SPSSideBySideToken.Tests.ps1 b/tests/Set-SPSSideBySideToken.Tests.ps1 new file mode 100644 index 0000000..f7c7aa3 --- /dev/null +++ b/tests/Set-SPSSideBySideToken.Tests.ps1 @@ -0,0 +1,111 @@ +# Behavioural tests for Set-SPSSideBySideToken retry semantics on transient +# SPWebService update conflicts. + +BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $modulePath = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1' + Import-Module -name $modulePath -Force + + function new-TestWebApp { + param + ( + [Parameter(Mandatory = $true)] + [System.Boolean] + $EnableSideBySide, + + [Parameter(Mandatory = $true)] + [System.String] + $SideBySideToken, + + [Parameter(Mandatory = $true)] + [System.Int32] + $FailCount, + + [Parameter(Mandatory = $true)] + [System.String] + $ErrorMessage + ) + + $webService = [pscustomobject]@{ + EnableSideBySide = $EnableSideBySide + SideBySideToken = $SideBySideToken + AttemptCount = 0 + FailCount = $FailCount + UpdateErrorMessage = $ErrorMessage + } + Add-Member -InputObject $webService -MemberType ScriptMethod -name Update -Value { + $this.AttemptCount++ + if ($this.AttemptCount -le $this.FailCount) { + throw [System.Exception]::new($this.UpdateErrorMessage) + } + } -Force + + return [pscustomobject]@{ + name = 'http://portal' + WebService = $webService + } + } +} + +AfterAll { + Remove-Module -name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'Set-SPSSideBySideToken - bounded retry on update conflict' { + AfterEach { + Remove-Item -Path Function:\global:Get-SPWebApplication -ErrorAction SilentlyContinue + Remove-Item -Path Function:\global:Copy-SPSideBySideFiles -ErrorAction SilentlyContinue + } + + It 'retries update-conflict exceptions and then succeeds' { + $webApp = new-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 2 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + + $script:TestWebApps = @($webApp) + $script:CopyCalls = 0 + function global:Get-SPWebApplication { return $script:TestWebApps } + function global:Copy-SPSideBySideFiles { $script:CopyCalls++ } + Mock -Commandname Start-Sleep -Modulename SPSUpdate.Common -MockWith {} + + $warnings = $null + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false -WarningVariable warnings -WarningAction Continue } | + Should -not -Throw + + $webApp.WebService.AttemptCount | Should -Be 3 + Should -Invoke -Commandname Start-Sleep -Modulename SPSUpdate.Common -Times 2 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 1 + $webApp.WebService.SideBySideToken | Should -Be '16.0.20326.20190' + } + + It 'fails after 6 attempts when conflict persists' { + $webApp = new-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + + $script:TestWebApps = @($webApp) + $script:CopyCalls = 0 + function global:Get-SPWebApplication { return $script:TestWebApps } + function global:Copy-SPSideBySideFiles { $script:CopyCalls++ } + Mock -Commandname Start-Sleep -Modulename SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Throw -ExpectedMessage '*update conflict has occurred*' + + $webApp.WebService.AttemptCount | Should -Be 6 + Should -Invoke -Commandname Start-Sleep -Modulename SPSUpdate.Common -Times 5 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 0 + } + + It 'does not retry non-conflict exceptions' { + $webApp = new-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 1 -ErrorMessage 'Access denied.' + + $script:TestWebApps = @($webApp) + $script:CopyCalls = 0 + function global:Get-SPWebApplication { return $script:TestWebApps } + function global:Copy-SPSideBySideFiles { $script:CopyCalls++ } + Mock -Commandname Start-Sleep -Modulename SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Throw -ExpectedMessage '*Access denied*' + + $webApp.WebService.AttemptCount | Should -Be 1 + Should -Invoke -Commandname Start-Sleep -Modulename SPSUpdate.Common -Times 0 -Exactly + } +} From 3c0b9d2abbd1399986b8d804ef979c3bfe5db717 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Wed, 21 Oct 2026 11:37:55 +0200 Subject: [PATCH 2/3] Refresh SPWebService between SideBySide conflict retries On each retry attempt, reload the current SPWebApplication by identity, reapply the intended change, then call Update() on the refreshed WebService instance. This avoids exhausting retries on a stale object version after PSCONFIG writes. Extend tests with a stale-instance scenario that only succeeds after refresh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Public/Set-SPSSideBySideToken.ps1 | 44 ++++-- tests/Set-SPSSideBySideToken.Tests.ps1 | 129 +++++++++++++----- 2 files changed, 123 insertions(+), 50 deletions(-) diff --git a/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 b/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 index 513597f..5561082 100644 --- a/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 @@ -18,7 +18,8 @@ param ( [Parameter(Mandatory = $true)] - $WebService, + [System.String] + $WebAppName, [Parameter(Mandatory = $true)] [System.String] @@ -26,15 +27,25 @@ [Parameter(Mandatory = $true)] [System.String] - $TargetName + $TargetName, + + [Parameter(Mandatory = $true)] + [ScriptBlock] + $ApplyChange ) $attempt = 0 while ($true) { $attempt++ try { - $WebService.Update() - return + $currentWebApp = Get-SPWebApplication -Identity $WebAppName -ErrorAction SilentlyContinue + if ($null -eq $currentWebApp -or $null -eq $currentWebApp.WebService) { + throw "Could not refresh SPWebApplication '$WebAppName' before '$Operation'." + } + + & $ApplyChange $currentWebApp.WebService + $currentWebApp.WebService.Update() + return $currentWebApp } catch { $isConflict = $_.Exception.Message -match '(?i)update conflict has occurred' @@ -52,25 +63,30 @@ if ($null -ne $webApps) { foreach ($webApp in $webApps) { $spWebAppName = $webApp.Name + $currentWebApp = $webApp if ($EnableSideBySide) { - if ($webApp.WebService.EnableSideBySide) { + if ($currentWebApp.WebService.EnableSideBySide) { Write-Output "EnableSideBySide is already enabled on $spWebAppName Web Application" } else { Write-Output "Enabling EnableSideBySide on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, 'Enable SharePoint side-by-side mode')) { - $webApp.WebService.EnableSideBySide = $true - Invoke-SPSWebServiceUpdateWithRetry -WebService $webApp.WebService -Operation 'EnableSideBySide' -TargetName $spWebAppName + $currentWebApp = Invoke-SPSWebServiceUpdateWithRetry -WebAppName $spWebAppName -Operation 'EnableSideBySide' -TargetName $spWebAppName -ApplyChange { + param($webService) + $webService.EnableSideBySide = $true + } } } - if ($webApp.WebService.SideBySideToken -eq $BuildVersion) { + if ($currentWebApp.WebService.SideBySideToken -eq $BuildVersion) { Write-Output "SideBySideToken $BuildVersion is already enabled on $spWebAppName Web Application" } else { Write-Output "Enabling SideBySideToken $BuildVersion on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, "Set SharePoint SideBySideToken to $BuildVersion")) { - $webApp.WebService.SideBySideToken = $BuildVersion - Invoke-SPSWebServiceUpdateWithRetry -WebService $webApp.WebService -Operation 'Set SideBySideToken' -TargetName $spWebAppName + $currentWebApp = Invoke-SPSWebServiceUpdateWithRetry -WebAppName $spWebAppName -Operation 'Set SideBySideToken' -TargetName $spWebAppName -ApplyChange { + param($webService) + $webService.SideBySideToken = $BuildVersion + } } } Write-Output 'Running CmdLet Copy-SPSideBySideFiles' @@ -79,11 +95,13 @@ } } else { - if ($webApp.WebService.EnableSideBySide) { + if ($currentWebApp.WebService.EnableSideBySide) { Write-Output "Disabling EnableSideBySide on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, 'Disable SharePoint side-by-side mode')) { - $webApp.WebService.EnableSideBySide = $false - Invoke-SPSWebServiceUpdateWithRetry -WebService $webApp.WebService -Operation 'DisableSideBySide' -TargetName $spWebAppName + $currentWebApp = Invoke-SPSWebServiceUpdateWithRetry -WebAppName $spWebAppName -Operation 'DisableSideBySide' -TargetName $spWebAppName -ApplyChange { + param($webService) + $webService.EnableSideBySide = $false + } } } else { diff --git a/tests/Set-SPSSideBySideToken.Tests.ps1 b/tests/Set-SPSSideBySideToken.Tests.ps1 index f7c7aa3..3e500c0 100644 --- a/tests/Set-SPSSideBySideToken.Tests.ps1 +++ b/tests/Set-SPSSideBySideToken.Tests.ps1 @@ -4,9 +4,9 @@ BeforeAll { $repoRoot = Split-Path -Path $PSScriptRoot -Parent $modulePath = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1' - Import-Module -name $modulePath -Force + Import-Module -Name $modulePath -Force - function new-TestWebApp { + function New-TestWebApp { param ( [Parameter(Mandatory = $true)] @@ -27,13 +27,14 @@ BeforeAll { ) $webService = [pscustomobject]@{ - EnableSideBySide = $EnableSideBySide - SideBySideToken = $SideBySideToken - AttemptCount = 0 - FailCount = $FailCount - UpdateErrorMessage = $ErrorMessage + EnableSideBySide = $EnableSideBySide + SideBySideToken = $SideBySideToken + AttemptCount = 0 + FailCount = $FailCount + UpdateErrorMessage = $ErrorMessage } - Add-Member -InputObject $webService -MemberType ScriptMethod -name Update -Value { + + Add-Member -InputObject $webService -MemberType ScriptMethod -Name Update -Value { $this.AttemptCount++ if ($this.AttemptCount -le $this.FailCount) { throw [System.Exception]::new($this.UpdateErrorMessage) @@ -41,71 +42,125 @@ BeforeAll { } -Force return [pscustomobject]@{ - name = 'http://portal' + Name = 'http://portal' WebService = $webService } } + + function global:Get-SPWebApplication { + [CmdletBinding()] + param( + [Parameter()] + [System.String] + $Identity + ) + + $script:GetSpWebAppCallCount++ + if ($PSBoundParameters.ContainsKey('Identity')) { + $script:GetSpWebAppIdentityCallCount++ + if ($null -ne $script:IdentityResolver) { + return & $script:IdentityResolver $Identity + } + if ($null -ne $script:WebAppsByIdentity -and $script:WebAppsByIdentity.ContainsKey($Identity)) { + return $script:WebAppsByIdentity[$Identity] + } + return $null + } + + return $script:WebApps + } + + function global:Copy-SPSideBySideFiles { + $script:CopyCalls++ + } } AfterAll { - Remove-Module -name SPSUpdate.Common -Force -ErrorAction SilentlyContinue + Remove-Item -Path Function:\global:Get-SPWebApplication -ErrorAction SilentlyContinue + Remove-Item -Path Function:\global:Copy-SPSideBySideFiles -ErrorAction SilentlyContinue + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue } Describe 'Set-SPSSideBySideToken - bounded retry on update conflict' { - AfterEach { - Remove-Item -Path Function:\global:Get-SPWebApplication -ErrorAction SilentlyContinue - Remove-Item -Path Function:\global:Copy-SPSideBySideFiles -ErrorAction SilentlyContinue + BeforeEach { + $script:WebApps = @() + $script:WebAppsByIdentity = @{} + $script:IdentityResolver = $null + $script:StaleWebApp = $null + $script:FreshWebApp = $null + $script:SwappedToFresh = $false + $script:CopyCalls = 0 + $script:GetSpWebAppCallCount = 0 + $script:GetSpWebAppIdentityCallCount = 0 } It 'retries update-conflict exceptions and then succeeds' { - $webApp = new-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 2 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 2 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } - $script:TestWebApps = @($webApp) - $script:CopyCalls = 0 - function global:Get-SPWebApplication { return $script:TestWebApps } - function global:Copy-SPSideBySideFiles { $script:CopyCalls++ } - Mock -Commandname Start-Sleep -Modulename SPSUpdate.Common -MockWith {} + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} - $warnings = $null - { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false -WarningVariable warnings -WarningAction Continue } | - Should -not -Throw + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Not -Throw $webApp.WebService.AttemptCount | Should -Be 3 - Should -Invoke -Commandname Start-Sleep -Modulename SPSUpdate.Common -Times 2 -Exactly -ParameterFilter { $Seconds -eq 15 } + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 2 -Exactly -ParameterFilter { $Seconds -eq 15 } $script:CopyCalls | Should -Be 1 $webApp.WebService.SideBySideToken | Should -Be '16.0.20326.20190' } It 'fails after 6 attempts when conflict persists' { - $webApp = new-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } - $script:TestWebApps = @($webApp) - $script:CopyCalls = 0 - function global:Get-SPWebApplication { return $script:TestWebApps } - function global:Copy-SPSideBySideFiles { $script:CopyCalls++ } - Mock -Commandname Start-Sleep -Modulename SPSUpdate.Common -MockWith {} + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | Should -Throw -ExpectedMessage '*update conflict has occurred*' $webApp.WebService.AttemptCount | Should -Be 6 - Should -Invoke -Commandname Start-Sleep -Modulename SPSUpdate.Common -Times 5 -Exactly -ParameterFilter { $Seconds -eq 15 } + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 5 -Exactly -ParameterFilter { $Seconds -eq 15 } $script:CopyCalls | Should -Be 0 } It 'does not retry non-conflict exceptions' { - $webApp = new-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 1 -ErrorMessage 'Access denied.' + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 1 -ErrorMessage 'Access denied.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } - $script:TestWebApps = @($webApp) - $script:CopyCalls = 0 - function global:Get-SPWebApplication { return $script:TestWebApps } - function global:Copy-SPSideBySideFiles { $script:CopyCalls++ } - Mock -Commandname Start-Sleep -Modulename SPSUpdate.Common -MockWith {} + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | Should -Throw -ExpectedMessage '*Access denied*' $webApp.WebService.AttemptCount | Should -Be 1 - Should -Invoke -Commandname Start-Sleep -Modulename SPSUpdate.Common -Times 0 -Exactly + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 0 -Exactly + } + + It 'refreshes a stale SPWebService instance between retries' { + $stale = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $fresh = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 0 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($stale) + $script:WebAppsByIdentity = @{ 'http://portal' = $stale } + $script:SwappedToFresh = $false + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith { + if (-not $script:SwappedToFresh) { + $script:WebAppsByIdentity['http://portal'] = $fresh + $script:SwappedToFresh = $true + } + } + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Not -Throw + + $stale.WebService.AttemptCount | Should -Be 1 + $fresh.WebService.AttemptCount | Should -Be 1 + $script:GetSpWebAppIdentityCallCount | Should -Be 2 + $script:SwappedToFresh | Should -BeTrue + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 1 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 1 } } From 93687f53e9f86098a788e5462ac09dd2bbf4c41b Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Wed, 21 Oct 2026 11:43:30 +0200 Subject: [PATCH 3/3] Cover SideBySide enable/disable retry callbacks in tests Expand Set-SPSSideBySideToken tests to cover both mode-transition callbacks (enable and disable), each with transient conflict recovery, retry exhaustion, and non-conflict fail-fast behavior. Also keep stale-instance refresh coverage explicit for conflict retries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Set-SPSSideBySideToken.Tests.ps1 | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/Set-SPSSideBySideToken.Tests.ps1 b/tests/Set-SPSSideBySideToken.Tests.ps1 index 3e500c0..a219336 100644 --- a/tests/Set-SPSSideBySideToken.Tests.ps1 +++ b/tests/Set-SPSSideBySideToken.Tests.ps1 @@ -163,4 +163,98 @@ Describe 'Set-SPSSideBySideToken - bounded retry on update conflict' { Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 1 -Exactly -ParameterFilter { $Seconds -eq 15 } $script:CopyCalls | Should -Be 1 } + + Context 'enable/disable mode transition callbacks' { + It 'retries and succeeds when enabling side-by-side mode hits a transient conflict' { + $webApp = New-TestWebApp -EnableSideBySide $false -SideBySideToken '16.0.20326.20190' -FailCount 2 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Not -Throw + + $webApp.WebService.AttemptCount | Should -Be 3 + $webApp.WebService.EnableSideBySide | Should -BeTrue + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 2 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 1 + } + + It 'fails after 6 attempts when enabling side-by-side mode keeps conflicting' { + $webApp = New-TestWebApp -EnableSideBySide $false -SideBySideToken '16.0.20326.20190' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Throw -ExpectedMessage '*update conflict has occurred*' + + $webApp.WebService.AttemptCount | Should -Be 6 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 5 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 0 + } + + It 'does not retry non-conflict errors when enabling side-by-side mode' { + $webApp = New-TestWebApp -EnableSideBySide $false -SideBySideToken '16.0.20326.20190' -FailCount 1 -ErrorMessage 'Access denied.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Throw -ExpectedMessage '*Access denied*' + + $webApp.WebService.AttemptCount | Should -Be 1 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 0 -Exactly + $script:CopyCalls | Should -Be 0 + } + + It 'retries and succeeds when disabling side-by-side mode hits a transient conflict' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.20326.20190' -FailCount 2 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $false -Confirm:$false } | + Should -Not -Throw + + $webApp.WebService.AttemptCount | Should -Be 3 + $webApp.WebService.EnableSideBySide | Should -BeFalse + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 2 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 0 + } + + It 'fails after 6 attempts when disabling side-by-side mode keeps conflicting' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.20326.20190' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $false -Confirm:$false } | + Should -Throw -ExpectedMessage '*update conflict has occurred*' + + $webApp.WebService.AttemptCount | Should -Be 6 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 5 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 0 + } + + It 'does not retry non-conflict errors when disabling side-by-side mode' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.20326.20190' -FailCount 1 -ErrorMessage 'Access denied.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $false -Confirm:$false } | + Should -Throw -ExpectedMessage '*Access denied*' + + $webApp.WebService.AttemptCount | Should -Be 1 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 0 -Exactly + $script:CopyCalls | Should -Be 0 + } + } }