Skip to content

Commit 7cdcf49

Browse files
committed
Prepare 5.0 release: unit tests, bug fixes, release notes, live smoke test
- Add Pester unit tests for all previously-untested public functions - Fix New-ServiceNowChangeTask assigned_to/AssignedTo bug - Fix New-ServiceNowCartItem mandatory-variable detection for display-value style objects returned by some instances - Fix Unit.GenericModule.Tests.ps1 Pester Discovery/Run scoping bugs (BeforeAll, -ForEach instead of hand-rolled foreach, and full FunctionsToExport parsing via Import-PowerShellDataFile instead of fragile line-based regex) - Add Tests/Invoke-ServiceNowLiveSmokeTest.ps1 for manual validation against a live instance, covering all 20 public functions; validated against dev409606 - Enable Pester in CI (ci.yml), excluding legacy tests that require live credentials - Update RELEASE.md with all changes since v4.1.0 and contributor credits
1 parent 3311751 commit 7cdcf49

21 files changed

Lines changed: 1357 additions & 21 deletions

.github/workflows/ci.yml

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,42 @@ jobs:
3131
with:
3232
name: pssa-results
3333
path: ${{ github.workspace }}/pssa.json
34-
# - name: Test with Pester
35-
# shell: pwsh
36-
# run: Invoke-Pester Unit.Tests.ps1 -Passthru | Export-CliXml -Path Unit.Tests.xml
34+
- name: Install Pester module
35+
shell: pwsh
36+
run: |
37+
Set-PSRepository PSGallery -InstallationPolicy Trusted
38+
Install-Module Pester -MinimumVersion 5.0 -SkipPublisherCheck -Force -ErrorAction Stop
39+
- name: Test with Pester
40+
shell: pwsh
41+
run: |
42+
# legacy tests require a live ServiceNow instance and mandatory credentials -
43+
# they aren't runnable non-interactively in CI, so exclude them here
44+
$excluded = @(
45+
'AddServiceNowAttachment.Tests.ps1',
46+
'GetServiceNowAttachment.Tests.ps1',
47+
'GetServiceNowAttachmentDetail.Tests.ps1',
48+
'RemoveServiceNowAttachment.Tests.ps1',
49+
'ServiceNow.Tests.ps1'
50+
)
51+
$testFiles = Get-ChildItem -Path './Tests' -Filter '*.Tests.ps1' |
52+
Where-Object { $_.Name -notin $excluded } |
53+
Select-Object -ExpandProperty FullName
54+
55+
$config = New-PesterConfiguration
56+
$config.Run.Path = $testFiles
57+
$config.Run.PassThru = $true
58+
$config.Run.Exit = $false
59+
$config.TestResult.Enabled = $true
60+
$config.TestResult.OutputPath = '${{ github.workspace }}/pester-results.xml'
61+
$config.Output.Verbosity = 'Detailed'
62+
63+
$result = Invoke-Pester -Configuration $config
64+
if ($result.FailedCount -gt 0) {
65+
Write-Error "$($result.FailedCount) Pester test(s) failed." -ErrorAction Stop
66+
}
67+
- name: Upload Pester results
68+
uses: actions/upload-artifact@v4
69+
if: always()
70+
with:
71+
name: pester-results
72+
path: ${{ github.workspace }}/pester-results.xml

RELEASE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
- Add support for access token refresh, [#277](https://github.com/Snow-Shell/servicenow-powershell/issues/277)
1+
- Add support for authenticating with OAuth Client Credential Grant, [#292](https://github.com/Snow-Shell/servicenow-powershell/pull/292). Thanks @jmunroBT!
2+
- Add automatic retry logic for retryable HTTP errors (429, 502, 503, 504, 408, 409), [#295](https://github.com/Snow-Shell/servicenow-powershell/pull/295), [#280](https://github.com/Snow-Shell/servicenow-powershell/issues/280)
3+
- Add property tab completion and remove Azure Automation support (breaking change), [#293](https://github.com/Snow-Shell/servicenow-powershell/pull/293)
4+
- Add full service catalog cart lifecycle support with `Get-ServiceNowCart`, `New-ServiceNowCartItem`, `Remove-ServiceNowCartItem`, and `Submit-ServiceNowCart`, [#290](https://github.com/Snow-Shell/servicenow-powershell/pull/290)
5+
- Add additional namespace and catalog request support, [#284](https://github.com/Snow-Shell/servicenow-powershell/pull/284). Thanks @CATgwalker!
6+
- Fix `-endswith` filter operator to use the correct `ENDSWITH` query operator, [#294](https://github.com/Snow-Shell/servicenow-powershell/pull/294). Thanks @TriggerAu!
7+
- Stop forcing field names to lowercase, [#286](https://github.com/Snow-Shell/servicenow-powershell/pull/286), [#281](https://github.com/Snow-Shell/servicenow-powershell/issues/281)

ServiceNow/Public/New-ServiceNowCartItem.ps1

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,15 @@ function New-ServiceNowCartItem {
136136
catch {
137137
if ( ($_.ErrorDetails.Message | ConvertFrom-Json | Select-Object -ExpandProperty error | Select-Object -ExpandProperty message) -match 'Mandatory Variables are required' ) {
138138
$catItem = Invoke-ServiceNowRestMethod -UriLeaf "/servicecatalog/items/$catalogItemID" -Namespace 'sn_sc'
139-
$mandatoryVars = $catItem.variables | Where-Object { $_.mandatory -eq $true } | Select-Object -ExpandProperty name
139+
$mandatoryVars = $catItem.variables | Where-Object {
140+
# the mandatory flag can come back as a plain boolean/string or, for some
141+
# instances/api versions, a display-value style object, eg. @{ value = 'true' }
142+
$mandatoryValue = $_.mandatory
143+
if ( $mandatoryValue -is [System.Management.Automation.PSCustomObject] ) {
144+
$mandatoryValue = $mandatoryValue.value
145+
}
146+
"$mandatoryValue" -eq 'true'
147+
} | Select-Object -ExpandProperty name
140148
throw ('Failed to add item to cart. The following mandatory variables must be provided: {0}' -f ($mandatoryVars -join ', '))
141149
}
142150
else {

ServiceNow/Public/New-ServiceNowChangeTask.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ function New-ServiceNowChangeTask {
9292
}
9393

9494
'AssignedTo' {
95-
$createValues.assignment_to = $AssignmentTo
95+
$createValues.assigned_to = $AssignedTo
9696
}
9797
}
9898

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
$ProjectRoot = Resolve-Path "$PSScriptRoot/.."
2+
$ModulePsd = (Resolve-Path "$ProjectRoot/ServiceNow/ServiceNow.psd1").Path
3+
4+
Get-Module 'ServiceNow' | Remove-Module -Force -ErrorAction SilentlyContinue
5+
Import-Module $ModulePsd -Force
6+
7+
Describe 'Export-ServiceNowAttachment' {
8+
9+
Context 'Saving to a file' {
10+
11+
It 'Saves the attachment using the provided file name' {
12+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{ Uri = 'https://test.service-now.com/api/now' } }
13+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {}
14+
15+
Export-ServiceNowAttachment -ID 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' -FileName 'myfile.txt' -Destination $TestDrive -Confirm:$false
16+
17+
$expectedPath = Join-Path $TestDrive 'myfile.txt'
18+
Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
19+
$Uri -eq 'https://test.service-now.com/api/now/attachment/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6/file' -and
20+
$OutFile -eq $expectedPath
21+
}
22+
}
23+
24+
It 'Appends the sys_id to the file name when AppendNameWithSysId is specified' {
25+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{ Uri = 'https://test.service-now.com/api/now' } }
26+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {}
27+
28+
Export-ServiceNowAttachment -ID 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' -FileName 'myfile.txt' -Destination $TestDrive -AppendNameWithSysId -Confirm:$false
29+
30+
$expectedPath = Join-Path $TestDrive 'myfile_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.txt'
31+
Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
32+
$OutFile -eq $expectedPath
33+
}
34+
}
35+
36+
It 'Throws when the destination file exists and AllowOverwrite is not specified' {
37+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{ Uri = 'https://test.service-now.com/api/now' } }
38+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {}
39+
40+
$existingPath = Join-Path $TestDrive 'existing.txt'
41+
Set-Content -Path $existingPath -Value 'placeholder'
42+
43+
{ Export-ServiceNowAttachment -ID 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' -FileName 'existing.txt' -Destination $TestDrive -Confirm:$false } | Should -Throw
44+
}
45+
46+
It 'Overwrites the destination file when AllowOverwrite is specified' {
47+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{ Uri = 'https://test.service-now.com/api/now' } }
48+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {}
49+
50+
$existingPath = Join-Path $TestDrive 'existing2.txt'
51+
Set-Content -Path $existingPath -Value 'placeholder'
52+
53+
{ Export-ServiceNowAttachment -ID 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' -FileName 'existing2.txt' -Destination $TestDrive -AllowOverwrite -Confirm:$false } | Should -Not -Throw
54+
}
55+
}
56+
57+
Context 'AsValue' {
58+
59+
It 'Returns the attachment content instead of writing a file' {
60+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{ Uri = 'https://test.service-now.com/api/now' } }
61+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' { 'file contents' }
62+
63+
$result = Export-ServiceNowAttachment -ID 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' -AsValue -Confirm:$false
64+
65+
$result | Should -Be 'file contents'
66+
}
67+
}
68+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
$ProjectRoot = Resolve-Path "$PSScriptRoot/.."
2+
$ModulePsd = (Resolve-Path "$ProjectRoot/ServiceNow/ServiceNow.psd1").Path
3+
4+
Get-Module 'ServiceNow' | Remove-Module -Force -ErrorAction SilentlyContinue
5+
Import-Module $ModulePsd -Force
6+
7+
Describe 'Export-ServiceNowRecord' {
8+
9+
Context 'Export by ID' {
10+
11+
It 'Filters by number and uses the correct export format from the file extension' {
12+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{} }
13+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {}
14+
15+
$path = Join-Path $TestDrive 'out.csv'
16+
Export-ServiceNowRecord -ID 'INC0010001' -Path $path -ServiceNowSession @{ Domain = 'test.service-now.com' }
17+
18+
Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
19+
$Uri -eq 'https://test.service-now.com/incident_list.do?CSV' -and
20+
$Body.sysparm_query -eq 'number=INC0010001' -and
21+
$OutFile -eq $path
22+
}
23+
}
24+
}
25+
26+
Context 'Export by table and filter' {
27+
28+
It 'Builds the query string from the filter and uses PDF format' {
29+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{} }
30+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {}
31+
32+
$path = Join-Path $TestDrive 'out.pdf'
33+
Export-ServiceNowRecord -Table 'incident' -Filter @('state', '-eq', '1') -Path $path -ServiceNowSession @{ Domain = 'test.service-now.com' }
34+
35+
Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
36+
$Uri -eq 'https://test.service-now.com/incident_list.do?PDF' -and
37+
$Body.sysparm_query -eq 'state=1'
38+
}
39+
}
40+
41+
It 'Converts XLS extension to the EXCEL export format' {
42+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{} }
43+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {}
44+
45+
$path = Join-Path $TestDrive 'out.xls'
46+
Export-ServiceNowRecord -Table 'incident' -Filter @('state', '-eq', '1') -Path $path -ServiceNowSession @{ Domain = 'test.service-now.com' }
47+
48+
Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
49+
$Uri -eq 'https://test.service-now.com/incident_list.do?EXCEL'
50+
}
51+
}
52+
53+
It 'Passes requested properties as lower case sysparm_fields' {
54+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{} }
55+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {}
56+
57+
$path = Join-Path $TestDrive 'out.csv'
58+
Export-ServiceNowRecord -Table 'incident' -Filter @('state', '-eq', '1') -Property 'Number', 'ShortDescription' -Path $path -ServiceNowSession @{ Domain = 'test.service-now.com' }
59+
60+
Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
61+
$Body.sysparm_fields -eq 'number,shortdescription'
62+
}
63+
}
64+
65+
It 'Throws for an unsupported file extension' {
66+
$path = Join-Path $TestDrive 'out.txt'
67+
{ Export-ServiceNowRecord -Table 'incident' -Filter @('state', '-eq', '1') -Path $path } | Should -Throw
68+
}
69+
}
70+
}

Tests/Get-ServiceNowCart.Tests.ps1

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
$ProjectRoot = Resolve-Path "$PSScriptRoot/.."
2+
$ModulePsd = (Resolve-Path "$ProjectRoot/ServiceNow/ServiceNow.psd1").Path
3+
4+
Get-Module 'ServiceNow' | Remove-Module -Force -ErrorAction SilentlyContinue
5+
Import-Module $ModulePsd -Force
6+
7+
Describe 'Get-ServiceNowCart' {
8+
9+
It 'Retrieves the current cart and renames cart_id to sys_id' {
10+
Mock Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' {
11+
[PSCustomObject]@{ cart_id = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'; subtotal = '100.00' }
12+
}
13+
14+
$result = Get-ServiceNowCart
15+
16+
$result.sys_id | Should -Be 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'
17+
$result.subtotal | Should -Be '100.00'
18+
$result.PSObject.Properties.Name | Should -Not -Contain 'cart_id'
19+
20+
Should -Invoke Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
21+
$Method -eq 'Get' -and $UriLeaf -eq '/servicecatalog/cart' -and $Namespace -eq 'sn_sc'
22+
}
23+
}
24+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
$ProjectRoot = Resolve-Path "$PSScriptRoot/.."
2+
$ModulePsd = (Resolve-Path "$ProjectRoot/ServiceNow/ServiceNow.psd1").Path
3+
4+
Get-Module 'ServiceNow' | Remove-Module -Force -ErrorAction SilentlyContinue
5+
Import-Module $ModulePsd -Force
6+
7+
Describe 'Get-ServiceNowRecord' {
8+
9+
Context 'Retrieve by ID' {
10+
11+
It 'Filters by sys_id when a 32 character id is provided along with a table' {
12+
Mock Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' {
13+
[PSCustomObject]@{ sys_id = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'; number = 'INC0010001' }
14+
}
15+
16+
$result = Get-ServiceNowRecord -Table 'incident' -ID 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'
17+
18+
$result.number | Should -Be 'INC0010001'
19+
Should -Invoke Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly
20+
}
21+
22+
It 'Determines the table from a numbered id prefix' {
23+
Mock Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' {
24+
[PSCustomObject]@{ number = 'INC0010001' }
25+
}
26+
27+
Get-ServiceNowRecord -ID 'INC0010001' | Out-Null
28+
29+
Should -Invoke Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
30+
$Table -eq 'incident'
31+
}
32+
}
33+
}
34+
35+
Context 'Retrieve by table and filter' {
36+
37+
It 'Passes the table through to the rest method' {
38+
Mock Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' {
39+
[PSCustomObject]@{ sys_id = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' }
40+
}
41+
42+
Get-ServiceNowRecord -Table 'incident' -Filter @('state', '-eq', '1') | Out-Null
43+
44+
Should -Invoke Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
45+
$Table -eq 'incident'
46+
}
47+
}
48+
}
49+
50+
Context 'Retrieve using ParentID' {
51+
52+
It 'Queries successfully when no table is specified' {
53+
Mock Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' {
54+
[PSCustomObject]@{ sys_id = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' }
55+
}
56+
57+
Get-ServiceNowRecord -ParentID 'RITM0010001' | Out-Null
58+
59+
Should -Invoke Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly
60+
}
61+
}
62+
63+
Context 'AsValue' {
64+
65+
It 'Returns the raw property value instead of a full object' {
66+
Mock Invoke-ServiceNowRestMethod -ModuleName 'ServiceNow' {
67+
[PSCustomObject]@{ sys_id = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' }
68+
}
69+
70+
$result = Get-ServiceNowRecord -Table 'incident' -Property sys_id -AsValue -Filter @('state', '-eq', '1')
71+
72+
$result | Should -Be 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'
73+
}
74+
}
75+
76+
Context 'Alias' {
77+
78+
It 'Is available via the gsnr alias' {
79+
(Get-Alias gsnr).ResolvedCommand.Name | Should -Be 'Get-ServiceNowRecord'
80+
}
81+
}
82+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
$ProjectRoot = Resolve-Path "$PSScriptRoot/.."
2+
$ModulePsd = (Resolve-Path "$ProjectRoot/ServiceNow/ServiceNow.psd1").Path
3+
4+
Get-Module 'ServiceNow' | Remove-Module -Force -ErrorAction SilentlyContinue
5+
Import-Module $ModulePsd -Force
6+
7+
Describe 'Invoke-ServiceNowGraphQL' {
8+
9+
Context 'Standard query' {
10+
11+
It 'Builds the full graphql query and parses the result' {
12+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{ Uri = 'https://test.service-now.com/api/now/graphql' } }
13+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' {
14+
@{ data = @{ myapp = @{ incident = @{ findById = @{ sys_id = @{ value = 'abc123' } } } } } }
15+
}
16+
17+
$result = Invoke-ServiceNowGraphQL -Application 'myapp' -Schema 'incident' -Query 'findById (id: "INC0010001") {sys_id {value}}'
18+
19+
$result.sys_id.value | Should -Be 'abc123'
20+
Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter {
21+
$Body -like '*findById*' -and $Method -eq 'Post'
22+
}
23+
}
24+
}
25+
26+
Context 'Raw' {
27+
28+
It 'Returns the response as is without parsing' {
29+
Mock Get-ServiceNowAuth -ModuleName 'ServiceNow' { @{ Uri = 'https://test.service-now.com/api/now/graphql' } }
30+
Mock Invoke-RestMethod -ModuleName 'ServiceNow' { @{ data = @{ raw = $true } } }
31+
32+
$result = Invoke-ServiceNowGraphQL -Application 'myapp' -Schema 'incident' -Query 'query { app { schema { thing { field } } } }' -Raw
33+
34+
$result.data.raw | Should -Be $true
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)