From 284b985c3ad06f724351a66ccf88468f732df322 Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 09:29:18 -0800 Subject: [PATCH 01/10] Fix Add-PlaylistItem URI handling and add Get-RecentlyPlayedTracks context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #56: Add-PlaylistItem now accepts plain track/episode IDs - Auto-converts plain IDs to spotify:track: or spotify:episode: URIs - Added -ItemType parameter (track/episode, defaults to track) - Fixed single-item array bug that split strings into characters Issue #54: Get-RecentlyPlayedTracks now exposes played_at timestamp - Added -IncludePlayContext switch to return full play history items - Default behavior unchanged for backwards compatibility Also adds: - Unit tests with mocked Send-SpotifyCall for behavior testing - Integration test file for real API testing - VS Code debug configuration - .env support for local credential storage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 6 + .gitignore | 10 +- .vscode/launch.json | 27 +++ .../Player/Get-RecentlyPlayedTracks.ps1 | 29 ++- .../Public/Playlists/Add-PlaylistItem.ps1 | 47 +++-- Tests/Spotishell.Integration.Tests.ps1 | 158 ++++++++++++++++ Tests/Spotishell.Tests.ps1 | 170 ++++++++++++++++++ 7 files changed, 430 insertions(+), 17 deletions(-) create mode 100644 .env.example create mode 100644 .vscode/launch.json create mode 100644 Tests/Spotishell.Integration.Tests.ps1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f56098b --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Spotify API Credentials +# Copy this file to .env and fill in your values +# Get these from https://developer.spotify.com/dashboard + +SPOTIFY_CLIENT_ID=your_client_id_here +SPOTIFY_CLIENT_SECRET=your_client_secret_here diff --git a/.gitignore b/.gitignore index e5cb1b7..eb75239 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ -\.vscode/ +# VS Code - ignore user-specific settings but allow launch.json +.vscode/* +!.vscode/launch.json +!.vscode/tasks.json \.DS_Store dev/publish-psgallery\.ps1 @@ -11,3 +14,8 @@ Whiteboard\.ps1 publish-psgallery\.ps1 dev/ + +# Local environment files (credentials) +.env +.env.local +Scripts/*.local.ps1 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b6780f6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "PowerShell: Launch Current File", + "type": "PowerShell", + "request": "launch", + "script": "${file}", + "cwd": "${workspaceFolder}" + }, + { + "name": "PowerShell: Interactive Session", + "type": "PowerShell", + "request": "launch", + "script": "", + "cwd": "${workspaceFolder}", + "createTemporaryIntegratedConsole": true + }, + { + "name": "PowerShell: Launch Test Script", + "type": "PowerShell", + "request": "launch", + "script": "${workspaceFolder}/Scripts/Test-ManualFixes.ps1", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/Spotishell/Public/Player/Get-RecentlyPlayedTracks.ps1 b/Spotishell/Public/Player/Get-RecentlyPlayedTracks.ps1 index b80ba68..786c4c4 100644 --- a/Spotishell/Public/Player/Get-RecentlyPlayedTracks.ps1 +++ b/Spotishell/Public/Player/Get-RecentlyPlayedTracks.ps1 @@ -2,17 +2,26 @@ .SYNOPSIS Get tracks from the current user's recently played tracks. .EXAMPLE - PS C:\> Get-RecentlyPlayed + PS C:\> Get-RecentlyPlayedTracks Retrieves the recently played tracks + .EXAMPLE + PS C:\> Get-RecentlyPlayedTracks -IncludePlayContext + Retrieves recently played items with played_at timestamp and context information + .EXAMPLE + PS C:\> Get-RecentlyPlayedTracks -IncludePlayContext | Select-Object played_at, @{N='TrackName';E={$_.track.name}} + Get recently played tracks with their play timestamps .PARAMETER ApplicationName Specifies the Spotify Application Name (otherwise default is used) .PARAMETER Limit - Specifies how many entries to fetch. + Specifies how many entries to fetch. Allowed range is 1 through 50. .PARAMETER BeforeTimestamp Returns all items before (but not including) this cursor position. If before is specified, after must not be specified. .PARAMETER AfterTimestamp Returns all items after (but not including) this cursor position. If after is specified, before must not be specified. + .PARAMETER IncludePlayContext + When specified, returns full play history items including played_at timestamp and context. + Without this switch, only the track objects are returned (default behavior for backwards compatibility). .NOTES Returns the most recent 50 tracks played by a user. Note that a track currently playing will not be visible in play history until it has completed. @@ -40,7 +49,13 @@ function Get-RecentlyPlayedTracks { [nullable[datetime]] [Parameter(ParameterSetName = 'AfterTimestamp')] - $AfterTimestamp = $null + $AfterTimestamp = $null, + + [Parameter(ParameterSetName = 'Default')] + [Parameter(ParameterSetName = 'BeforeTimestamp')] + [Parameter(ParameterSetName = 'AfterTimestamp')] + [switch] + $IncludePlayContext ) $Method = 'Get' @@ -55,5 +70,11 @@ function Get-RecentlyPlayedTracks { } $Response = Send-SpotifyCall -Method $Method -Uri $Uri -ApplicationName $ApplicationName - $Response.items.track + + if ($IncludePlayContext) { + $Response.items # Returns full objects: played_at, context, track + } + else { + $Response.items.track # Backwards compatible - track objects only + } } \ No newline at end of file diff --git a/Spotishell/Public/Playlists/Add-PlaylistItem.ps1 b/Spotishell/Public/Playlists/Add-PlaylistItem.ps1 index 1106354..1a60285 100644 --- a/Spotishell/Public/Playlists/Add-PlaylistItem.ps1 +++ b/Spotishell/Public/Playlists/Add-PlaylistItem.ps1 @@ -2,18 +2,26 @@ .SYNOPSIS Add one or more items to a user's playlist. .EXAMPLE - PS C:\> Add-PlaylistItem -Id 'myPlaylistId' -ItemId 'blahblahblah' - Add the Item with the Id of 'blahblahblah' to the playlist with Id 'myPlaylistId' + PS C:\> Add-PlaylistItem -Id 'myPlaylistId' -ItemId '4iV5W9uYEdYUVa79Axb7Rh' + Add the track with the Id of '4iV5W9uYEdYUVa79Axb7Rh' to the playlist with Id 'myPlaylistId' .EXAMPLE - PS C:\> Add-PlaylistItem -Id 'myPlaylistId' -ItemId 'blahblahblah','blahblahblah2' - Add both items with the Id of 'blahblahblah' to the playlist with Id 'myPlaylistId' + PS C:\> Add-PlaylistItem -Id 'myPlaylistId' -ItemId 'spotify:track:4iV5W9uYEdYUVa79Axb7Rh' + Add the track using a full Spotify URI (also supported) .EXAMPLE - PS C:\> @('blahblahblah','blahblahblah2') | Add-PlaylistItem -Id - Add both items with the Id of 'blahblahblah' to the playlist with Id 'myPlaylistId' + PS C:\> Add-PlaylistItem -Id 'myPlaylistId' -ItemId '4iV5W9uYEdYUVa79Axb7Rh','1301WleyT98MSxVHPZCA6M' + Add multiple tracks to the playlist + .EXAMPLE + PS C:\> Add-PlaylistItem -Id 'myPlaylistId' -ItemId '5CfCWKI5pZ28U0uOzXkDHe' -ItemType episode + Add a podcast episode to the playlist .PARAMETER Id The Spotify ID for the playlist. .PARAMETER ItemId - Specifies the list of Spotify URIs to add, can be track or episode URIs. + Specifies the Spotify IDs or URIs to add. Can be track or episode IDs/URIs. + Plain IDs will be automatically converted to URIs using the ItemType parameter. + Full URIs (e.g., 'spotify:track:xxx') are passed through unchanged. + .PARAMETER ItemType + Specifies the type of item when using plain IDs. Valid values are 'track' or 'episode'. + Defaults to 'track'. Ignored when passing full Spotify URIs. .PARAMETER Position Specifies the position to insert the items, a zero-based index. If omitted, the items will be appended to the playlist. @@ -25,12 +33,16 @@ function Add-PlaylistItem { [Parameter(Mandatory)] [string] $Id, - + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] [array] $ItemId, + [ValidateSet('track', 'episode')] + [string] + $ItemType = 'track', + [int] $Position, @@ -41,13 +53,24 @@ function Add-PlaylistItem { $Method = 'Post' $Uri = "https://api.spotify.com/v1/playlists/$Id/tracks" - for ($i = 0; $i -lt $ItemId.Count; $i += 100) { + # Convert plain IDs to URIs, pass through existing URIs unchanged + # Use @() to ensure result is always an array (single items would otherwise be strings) + $ProcessedItems = @($ItemId | ForEach-Object { + if ($_ -match '^spotify:(track|episode):') { + $_ # Already a URI, use as-is + } + else { + "spotify:${ItemType}:$_" # Convert ID to URI + } + }) - $BodyHashtable = @{uris = $ItemId[$i..($i + 99)] } + for ($i = 0; $i -lt $ProcessedItems.Count; $i += 100) { + + $BodyHashtable = @{uris = $ProcessedItems[$i..($i + 99)] } if ($Position) { $BodyHashtable.position = ($Position + $i + 1) } - + $Body = ConvertTo-Json $BodyHashtable -Compress - + Send-SpotifyCall -Method $Method -Uri $Uri -Body $Body -ApplicationName $ApplicationName } } \ No newline at end of file diff --git a/Tests/Spotishell.Integration.Tests.ps1 b/Tests/Spotishell.Integration.Tests.ps1 new file mode 100644 index 0000000..5e6a551 --- /dev/null +++ b/Tests/Spotishell.Integration.Tests.ps1 @@ -0,0 +1,158 @@ +<# +.SYNOPSIS + Integration tests for Spotishell module. + These tests require valid Spotify API credentials and make real API calls. + +.DESCRIPTION + Run these tests manually when you want to verify the module works with the real Spotify API. + + Prerequisites: + 1. Copy .env.example to .env and fill in your credentials + 2. Run: Invoke-Pester -Path ./Tests/Spotishell.Integration.Tests.ps1 -Output Detailed + +.NOTES + These tests are NOT run as part of CI/CD pipelines. + They require: + - Valid Spotify API credentials (SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET) + - A test playlist that can be modified + - Active Spotify account with play history +#> + +BeforeAll { + $ModulePath = Join-Path $PSScriptRoot '..' 'Spotishell' 'Spotishell.psd1' + Import-Module $ModulePath -Force + + # Load .env file if it exists + $EnvFile = Join-Path $PSScriptRoot '..' '.env' + if (Test-Path $EnvFile) { + Get-Content $EnvFile | ForEach-Object { + if ($_ -match '^\s*([^#][^=]+)=(.*)$') { + $name = $matches[1].Trim() + $value = $matches[2].Trim() + Set-Item -Path "env:$name" -Value $value + } + } + } + + # Check for required credentials + $script:SkipTests = $false + if (-not $env:SPOTIFY_CLIENT_ID -or -not $env:SPOTIFY_CLIENT_SECRET) { + Write-Warning "SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables are required for integration tests." + Write-Warning "Copy .env.example to .env and fill in your credentials, or set environment variables." + $script:SkipTests = $true + } + + # Setup application if credentials exist + if (-not $script:SkipTests) { + try { + $app = Get-SpotifyApplication -Name 'integration-test' -ErrorAction SilentlyContinue + } + catch { + New-SpotifyApplication -Name 'integration-test' -ClientId $env:SPOTIFY_CLIENT_ID -ClientSecret $env:SPOTIFY_CLIENT_SECRET + } + } +} + +Describe 'Integration: Authentication' -Skip:$script:SkipTests { + It 'Should authenticate and get current user profile' { + $profile = Get-CurrentUserProfile -ApplicationName 'integration-test' + + $profile | Should -Not -BeNullOrEmpty + $profile.id | Should -Not -BeNullOrEmpty + $profile.type | Should -Be 'user' + } +} + +Describe 'Integration: Get-RecentlyPlayedTracks' -Skip:$script:SkipTests { + Context 'Basic Functionality' { + It 'Should retrieve recently played tracks' { + $tracks = Get-RecentlyPlayedTracks -Limit 5 -ApplicationName 'integration-test' + + # User might not have play history, so just check it doesn't error + # If there are results, verify structure + if ($tracks) { + $tracks[0].name | Should -Not -BeNullOrEmpty + $tracks[0].artists | Should -Not -BeNullOrEmpty + } + } + + It 'Should include played_at when IncludePlayContext is set' { + $items = Get-RecentlyPlayedTracks -Limit 5 -IncludePlayContext -ApplicationName 'integration-test' + + if ($items) { + $items[0].played_at | Should -Not -BeNullOrEmpty + $items[0].track | Should -Not -BeNullOrEmpty + $items[0].track.name | Should -Not -BeNullOrEmpty + } + } + + It 'Should respect Limit parameter' { + $items = Get-RecentlyPlayedTracks -Limit 3 -ApplicationName 'integration-test' + + if ($items) { + $items.Count | Should -BeLessOrEqual 3 + } + } + } +} + +Describe 'Integration: Add-PlaylistItem' -Skip:$script:SkipTests { + BeforeAll { + # Get user's playlists to find a test playlist + $script:TestPlaylistId = $env:SPOTIFY_TEST_PLAYLIST_ID + + if (-not $script:TestPlaylistId) { + Write-Warning "Set SPOTIFY_TEST_PLAYLIST_ID environment variable to run Add-PlaylistItem tests" + Write-Warning "Skipping Add-PlaylistItem integration tests" + } + } + + Context 'Adding tracks with plain IDs' -Skip:(-not $script:TestPlaylistId) { + It 'Should add a track using plain ID (not full URI)' { + # Use a well-known track ID (Rick Astley - Never Gonna Give You Up) + $trackId = '4PTG3Z6ehGkBFwjybzWkR8' + + # This should not throw - the fix converts plain ID to URI + { Add-PlaylistItem -Id $script:TestPlaylistId -ItemId $trackId -ApplicationName 'integration-test' } | Should -Not -Throw + } + + It 'Should add a track using full URI' { + $trackUri = 'spotify:track:4PTG3Z6ehGkBFwjybzWkR8' + + { Add-PlaylistItem -Id $script:TestPlaylistId -ItemId $trackUri -ApplicationName 'integration-test' } | Should -Not -Throw + } + + It 'Should add multiple tracks at once' { + $trackIds = @('4PTG3Z6ehGkBFwjybzWkR8', '7GhIk7Il098yCjg4BQjzvb') + + { Add-PlaylistItem -Id $script:TestPlaylistId -ItemId $trackIds -ApplicationName 'integration-test' } | Should -Not -Throw + } + } +} + +Describe 'Integration: Search' -Skip:$script:SkipTests { + It 'Should search for tracks' { + $results = Search-Item -Query 'Never Gonna Give You Up' -Type Track -ApplicationName 'integration-test' + + $results | Should -Not -BeNullOrEmpty + $results.tracks.items | Should -Not -BeNullOrEmpty + $results.tracks.items[0].name | Should -Not -BeNullOrEmpty + } + + It 'Should search for artists' { + $results = Search-Item -Query 'Rick Astley' -Type Artist -ApplicationName 'integration-test' + + $results | Should -Not -BeNullOrEmpty + $results.artists.items | Should -Not -BeNullOrEmpty + } +} + +AfterAll { + # Cleanup: Remove test application + try { + Remove-SpotifyApplication -Name 'integration-test' -ErrorAction SilentlyContinue + } + catch { + # Ignore cleanup errors + } +} diff --git a/Tests/Spotishell.Tests.ps1 b/Tests/Spotishell.Tests.ps1 index aa7c3f7..44a231f 100644 --- a/Tests/Spotishell.Tests.ps1 +++ b/Tests/Spotishell.Tests.ps1 @@ -269,4 +269,174 @@ Describe 'Parameter Validation' { { Get-Album } | Should -Throw } } + + Context 'Add-PlaylistItem' { + It 'Should have ItemType parameter with valid values' { + $cmd = Get-Command Add-PlaylistItem + $validateSet = $cmd.Parameters['ItemType'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet | Should -Not -BeNullOrEmpty + $validateSet.ValidValues | Should -Contain 'track' + $validateSet.ValidValues | Should -Contain 'episode' + } + + It 'Should have ItemType default to track' { + $cmd = Get-Command Add-PlaylistItem + $cmd.Parameters['ItemType'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } + # Check that the default value is 'track' by examining the function definition + $functionDef = (Get-Command Add-PlaylistItem).ScriptBlock.ToString() + $functionDef | Should -Match '\$ItemType\s*=\s*[''"]track[''"]' + } + + It 'Should require Id parameter' { + { Add-PlaylistItem -ItemId 'test-id' } | Should -Throw + } + + It 'Should require ItemId parameter' { + { Add-PlaylistItem -Id 'test-playlist' } | Should -Throw + } + } + + Context 'Get-RecentlyPlayedTracks' { + It 'Should have IncludePlayContext switch parameter' { + $cmd = Get-Command Get-RecentlyPlayedTracks + $cmd.Parameters['IncludePlayContext'] | Should -Not -BeNullOrEmpty + $cmd.Parameters['IncludePlayContext'].SwitchParameter | Should -Be $true + } + + It 'Should have Limit parameter with range validation' { + $cmd = Get-Command Get-RecentlyPlayedTracks + $validateRange = $cmd.Parameters['Limit'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $validateRange | Should -Not -BeNullOrEmpty + $validateRange.MinRange | Should -Be 1 + $validateRange.MaxRange | Should -Be 50 + } + } +} + +Describe 'Add-PlaylistItem Behavior' { + BeforeAll { + Mock Send-SpotifyCall { return @{ snapshot_id = 'mock-snapshot' } } -ModuleName Spotishell + } + + Context 'URI Conversion' { + It 'Should convert a single plain ID to spotify:track URI' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'track456' + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match '"uris":\["spotify:track:track456"\]' + } + } + + It 'Should convert multiple plain IDs to spotify:track URIs' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'track1', 'track2', 'track3' + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match 'spotify:track:track1' -and + $Body -match 'spotify:track:track2' -and + $Body -match 'spotify:track:track3' + } + } + + It 'Should pass through existing spotify:track URIs unchanged' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'spotify:track:existing123' + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match '"uris":\["spotify:track:existing123"\]' + } + } + + It 'Should pass through existing spotify:episode URIs unchanged' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'spotify:episode:episode123' + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match '"uris":\["spotify:episode:episode123"\]' + } + } + + It 'Should use episode prefix when ItemType is episode' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'episode456' -ItemType 'episode' + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match '"uris":\["spotify:episode:episode456"\]' + } + } + + It 'Should handle mixed URIs and plain IDs' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'plain123', 'spotify:track:existing456' + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match 'spotify:track:plain123' -and + $Body -match 'spotify:track:existing456' + } + } + + It 'Should not split single item into characters (array handling)' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'singletrack' + + # This would fail if the bug existed - body would contain individual characters + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -notmatch '"s","i","n","g","l","e"' -and + $Body -match 'spotify:track:singletrack' + } + } + } + + Context 'API Call' { + It 'Should call correct playlist endpoint' { + Add-PlaylistItem -Id 'myplaylist789' -ItemId 'track123' + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Uri -eq 'https://api.spotify.com/v1/playlists/myplaylist789/tracks' -and + $Method -eq 'Post' + } + } + } +} + +Describe 'Get-RecentlyPlayedTracks Behavior' { + Context 'Response Handling' { + BeforeAll { + # Mock response matching Spotify API structure + $mockResponse = @{ + items = @( + @{ + played_at = '2025-12-01T17:21:53.000Z' + context = @{ type = 'playlist'; uri = 'spotify:playlist:abc' } + track = @{ id = 'track1'; name = 'Song 1'; artists = @(@{ name = 'Artist 1' }) } + }, + @{ + played_at = '2025-12-01T16:00:00.000Z' + context = @{ type = 'album'; uri = 'spotify:album:xyz' } + track = @{ id = 'track2'; name = 'Song 2'; artists = @(@{ name = 'Artist 2' }) } + } + ) + } + Mock Send-SpotifyCall { return $mockResponse } -ModuleName Spotishell + } + + It 'Should return only track objects by default' { + $result = Get-RecentlyPlayedTracks + + $result.Count | Should -Be 2 + $result[0].name | Should -Be 'Song 1' + $result[0].PSObject.Properties.Name | Should -Not -Contain 'played_at' + } + + It 'Should return full items with played_at when IncludePlayContext is set' { + $result = Get-RecentlyPlayedTracks -IncludePlayContext + + $result.Count | Should -Be 2 + $result[0].played_at | Should -Be '2025-12-01T17:21:53.000Z' + $result[0].track.name | Should -Be 'Song 1' + $result[0].context.type | Should -Be 'playlist' + } + + It 'Should call API with correct limit parameter' { + Get-RecentlyPlayedTracks -Limit 10 + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Uri -match 'limit=10' + } + } + } } From 93715fe8fbb2a58b041b4b02305cd255a678a565 Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 09:34:40 -0800 Subject: [PATCH 02/10] Exclude integration tests from CI pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests require Spotify API credentials which aren't available in CI. Only run unit tests (Spotishell.Tests.ps1) in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bab328..ad6e183 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,8 @@ jobs: Import-Module Pester $config = New-PesterConfiguration - $config.Run.Path = './Tests' + # Only run unit tests, exclude integration tests (require API credentials) + $config.Run.Path = './Tests/Spotishell.Tests.ps1' $config.Run.Exit = $true $config.TestResult.Enabled = $true $config.TestResult.OutputPath = 'testResults.xml' From d776889f813b97fe69efc2664f824312a35e3907 Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 09:35:47 -0800 Subject: [PATCH 03/10] Add integration tests to CI pipeline for main branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests run after unit tests pass, only on pushes to main (not on PRs to protect secrets from forks). Requires GitHub Secrets: - SPOTIFY_CLIENT_ID - SPOTIFY_CLIENT_SECRET - SPOTIFY_TEST_PLAYLIST_ID 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 35 ++++++++++++++++ Tests/Spotishell.Integration.Tests.ps1 | 56 ++++++++++++++++---------- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad6e183..f189979 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,38 @@ jobs: Write-Error "No commands exported from module" exit 1 } + + integration-test: + name: Integration Tests + runs-on: ubuntu-latest + # Only run on main branch, not PRs (protects secrets from forks) + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: [test, module-validation] + + steps: + - uses: actions/checkout@v4 + + - name: Run Integration Tests + shell: pwsh + env: + SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} + SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} + run: | + Install-Module -Name Pester -Force -Scope CurrentUser -MinimumVersion 5.0.0 + Import-Module Pester + + $config = New-PesterConfiguration + $config.Run.Path = './Tests/Spotishell.Integration.Tests.ps1' + $config.Run.Exit = $true + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = 'integrationTestResults.xml' + $config.Output.Verbosity = 'Detailed' + + Invoke-Pester -Configuration $config + + - name: Upload Integration Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: integration-test-results + path: integrationTestResults.xml diff --git a/Tests/Spotishell.Integration.Tests.ps1 b/Tests/Spotishell.Integration.Tests.ps1 index 5e6a551..e898495 100644 --- a/Tests/Spotishell.Integration.Tests.ps1 +++ b/Tests/Spotishell.Integration.Tests.ps1 @@ -11,18 +11,17 @@ 2. Run: Invoke-Pester -Path ./Tests/Spotishell.Integration.Tests.ps1 -Output Detailed .NOTES - These tests are NOT run as part of CI/CD pipelines. - They require: - - Valid Spotify API credentials (SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET) - - A test playlist that can be modified - - Active Spotify account with play history + These tests run in CI on main branch only (after PR merge). + They require GitHub Secrets: + - SPOTIFY_CLIENT_ID + - SPOTIFY_CLIENT_SECRET #> BeforeAll { $ModulePath = Join-Path $PSScriptRoot '..' 'Spotishell' 'Spotishell.psd1' Import-Module $ModulePath -Force - # Load .env file if it exists + # Load .env file if it exists (for local development) $EnvFile = Join-Path $PSScriptRoot '..' '.env' if (Test-Path $EnvFile) { Get-Content $EnvFile | ForEach-Object { @@ -42,7 +41,7 @@ BeforeAll { $script:SkipTests = $true } - # Setup application if credentials exist + # Setup application and test playlist if credentials exist if (-not $script:SkipTests) { try { $app = Get-SpotifyApplication -Name 'integration-test' -ErrorAction SilentlyContinue @@ -50,6 +49,19 @@ BeforeAll { catch { New-SpotifyApplication -Name 'integration-test' -ClientId $env:SPOTIFY_CLIENT_ID -ClientSecret $env:SPOTIFY_CLIENT_SECRET } + + # Get current user for playlist creation + $script:CurrentUser = Get-CurrentUserProfile -ApplicationName 'integration-test' + + # Create a temporary test playlist + $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $script:TestPlaylist = New-Playlist -UserId $script:CurrentUser.id ` + -Name "Spotishell-Test-$timestamp" ` + -Description 'Temporary playlist for integration testing. Safe to delete.' ` + -Public $false ` + -ApplicationName 'integration-test' + + Write-Host "Created test playlist: $($script:TestPlaylist.name) ($($script:TestPlaylist.id))" } } @@ -97,35 +109,25 @@ Describe 'Integration: Get-RecentlyPlayedTracks' -Skip:$script:SkipTests { } Describe 'Integration: Add-PlaylistItem' -Skip:$script:SkipTests { - BeforeAll { - # Get user's playlists to find a test playlist - $script:TestPlaylistId = $env:SPOTIFY_TEST_PLAYLIST_ID - - if (-not $script:TestPlaylistId) { - Write-Warning "Set SPOTIFY_TEST_PLAYLIST_ID environment variable to run Add-PlaylistItem tests" - Write-Warning "Skipping Add-PlaylistItem integration tests" - } - } - - Context 'Adding tracks with plain IDs' -Skip:(-not $script:TestPlaylistId) { + Context 'Adding tracks with plain IDs' { It 'Should add a track using plain ID (not full URI)' { # Use a well-known track ID (Rick Astley - Never Gonna Give You Up) $trackId = '4PTG3Z6ehGkBFwjybzWkR8' # This should not throw - the fix converts plain ID to URI - { Add-PlaylistItem -Id $script:TestPlaylistId -ItemId $trackId -ApplicationName 'integration-test' } | Should -Not -Throw + { Add-PlaylistItem -Id $script:TestPlaylist.id -ItemId $trackId -ApplicationName 'integration-test' } | Should -Not -Throw } It 'Should add a track using full URI' { $trackUri = 'spotify:track:4PTG3Z6ehGkBFwjybzWkR8' - { Add-PlaylistItem -Id $script:TestPlaylistId -ItemId $trackUri -ApplicationName 'integration-test' } | Should -Not -Throw + { Add-PlaylistItem -Id $script:TestPlaylist.id -ItemId $trackUri -ApplicationName 'integration-test' } | Should -Not -Throw } It 'Should add multiple tracks at once' { $trackIds = @('4PTG3Z6ehGkBFwjybzWkR8', '7GhIk7Il098yCjg4BQjzvb') - { Add-PlaylistItem -Id $script:TestPlaylistId -ItemId $trackIds -ApplicationName 'integration-test' } | Should -Not -Throw + { Add-PlaylistItem -Id $script:TestPlaylist.id -ItemId $trackIds -ApplicationName 'integration-test' } | Should -Not -Throw } } } @@ -148,7 +150,17 @@ Describe 'Integration: Search' -Skip:$script:SkipTests { } AfterAll { - # Cleanup: Remove test application + # Cleanup: Remove test playlist and application + if ($script:TestPlaylist) { + try { + Write-Host "Cleaning up test playlist: $($script:TestPlaylist.id)" + Remove-FollowedPlaylist -Id $script:TestPlaylist.id -ApplicationName 'integration-test' -ErrorAction SilentlyContinue + } + catch { + Write-Warning "Could not remove test playlist: $_" + } + } + try { Remove-SpotifyApplication -Name 'integration-test' -ErrorAction SilentlyContinue } From a92492739fc81d0f80bc00850c50149e7cd7e988 Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 09:39:56 -0800 Subject: [PATCH 04/10] Fix launch.json and Add-PlaylistItem position bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove non-existent test script from launch.json - Fix off-by-one error in Position parameter handling - Use $PSBoundParameters.ContainsKey to detect if Position was supplied - Remove erroneous +1 that caused Position 0 to insert at index 1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .vscode/launch.json | 7 ------- Spotishell/Public/Playlists/Add-PlaylistItem.ps1 | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b6780f6..a09f6c5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,13 +15,6 @@ "script": "", "cwd": "${workspaceFolder}", "createTemporaryIntegratedConsole": true - }, - { - "name": "PowerShell: Launch Test Script", - "type": "PowerShell", - "request": "launch", - "script": "${workspaceFolder}/Scripts/Test-ManualFixes.ps1", - "cwd": "${workspaceFolder}" } ] } diff --git a/Spotishell/Public/Playlists/Add-PlaylistItem.ps1 b/Spotishell/Public/Playlists/Add-PlaylistItem.ps1 index 1a60285..aa49e4b 100644 --- a/Spotishell/Public/Playlists/Add-PlaylistItem.ps1 +++ b/Spotishell/Public/Playlists/Add-PlaylistItem.ps1 @@ -67,7 +67,7 @@ function Add-PlaylistItem { for ($i = 0; $i -lt $ProcessedItems.Count; $i += 100) { $BodyHashtable = @{uris = $ProcessedItems[$i..($i + 99)] } - if ($Position) { $BodyHashtable.position = ($Position + $i + 1) } + if ($PSBoundParameters.ContainsKey('Position')) { $BodyHashtable.position = ($Position + $i) } $Body = ConvertTo-Json $BodyHashtable -Compress From 9c5890b5f62627b8872726a5ff3482253ea03ef0 Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 09:40:19 -0800 Subject: [PATCH 05/10] Fix integration test app creation logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle both cases where Get-SpotifyApplication throws or returns $null by explicitly checking if app exists before creating. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Tests/Spotishell.Integration.Tests.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/Spotishell.Integration.Tests.ps1 b/Tests/Spotishell.Integration.Tests.ps1 index e898495..b378aad 100644 --- a/Tests/Spotishell.Integration.Tests.ps1 +++ b/Tests/Spotishell.Integration.Tests.ps1 @@ -43,10 +43,16 @@ BeforeAll { # Setup application and test playlist if credentials exist if (-not $script:SkipTests) { + # Check if application exists, create if not + $app = $null try { $app = Get-SpotifyApplication -Name 'integration-test' -ErrorAction SilentlyContinue } catch { + # Application doesn't exist, will create below + } + + if (-not $app) { New-SpotifyApplication -Name 'integration-test' -ClientId $env:SPOTIFY_CLIENT_ID -ClientSecret $env:SPOTIFY_CLIENT_SECRET } From aa4bb5a238caf3d134137b9c9248969c7efbdd8f Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 09:45:18 -0800 Subject: [PATCH 06/10] Run integration tests on PRs from same repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests now run on: - Pushes to main - PRs from the same repo (not forks, to protect secrets) This catches integration failures before merging to main. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f189979..cf93fdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,8 +86,10 @@ jobs: integration-test: name: Integration Tests runs-on: ubuntu-latest - # Only run on main branch, not PRs (protects secrets from forks) - if: github.ref == 'refs/heads/main' && github.event_name == 'push' + # Run on main pushes and PRs from same repo (not forks - protects secrets) + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) needs: [test, module-validation] steps: From f5ff7ebfc1f06317a670caaaab90f99c193ff19c Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 09:49:53 -0800 Subject: [PATCH 07/10] Support non-interactive auth in CI using refresh token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests now use SPOTIFY_REFRESH_TOKEN secret to pre-populate the credential file, allowing token refresh without browser auth. Secrets required: - SPOTIFY_CLIENT_ID - SPOTIFY_CLIENT_SECRET - SPOTIFY_REFRESH_TOKEN (from local auth) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 1 + Tests/Spotishell.Integration.Tests.ps1 | 47 +++++++++++++++++++++----- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf93fdb..7dcb7e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,7 @@ jobs: env: SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} + SPOTIFY_REFRESH_TOKEN: ${{ secrets.SPOTIFY_REFRESH_TOKEN }} run: | Install-Module -Name Pester -Force -Scope CurrentUser -MinimumVersion 5.0.0 Import-Module Pester diff --git a/Tests/Spotishell.Integration.Tests.ps1 b/Tests/Spotishell.Integration.Tests.ps1 index b378aad..177d756 100644 --- a/Tests/Spotishell.Integration.Tests.ps1 +++ b/Tests/Spotishell.Integration.Tests.ps1 @@ -43,17 +43,46 @@ BeforeAll { # Setup application and test playlist if credentials exist if (-not $script:SkipTests) { - # Check if application exists, create if not - $app = $null - try { - $app = Get-SpotifyApplication -Name 'integration-test' -ErrorAction SilentlyContinue - } - catch { - # Application doesn't exist, will create below + # For CI: if refresh token is provided, create the credential file directly + # This allows non-interactive token refresh + if ($env:SPOTIFY_REFRESH_TOKEN) { + # Ensure store directory exists + $StorePath = if ($env:SPOTISHELL_STORE_PATH) { $env:SPOTISHELL_STORE_PATH } + elseif ($IsWindows -or $PSVersionTable.PSVersion.Major -lt 6) { Join-Path $env:LOCALAPPDATA 'spotishell' } + else { Join-Path $HOME '.spotishell' } + + if (-not (Test-Path $StorePath)) { + New-Item -Path $StorePath -ItemType Directory -Force | Out-Null + } + + # Create application with pre-authorized refresh token + $AppFile = Join-Path $StorePath 'integration-test.json' + @{ + Name = 'integration-test' + ClientId = $env:SPOTIFY_CLIENT_ID + ClientSecret = $env:SPOTIFY_CLIENT_SECRET + RedirectUri = 'http://127.0.0.1:8080/spotishell' + Token = @{ + refresh_token = $env:SPOTIFY_REFRESH_TOKEN + expires = '1970-01-01 00:00:00Z' # Force refresh on first use + } + } | ConvertTo-Json -Depth 10 | Set-Content -Path $AppFile -Encoding UTF8 + + Write-Host "Created integration-test application with pre-authorized refresh token" } + else { + # Local development: check if application exists, create if not + $app = $null + try { + $app = Get-SpotifyApplication -Name 'integration-test' -ErrorAction SilentlyContinue + } + catch { + # Application doesn't exist, will create below + } - if (-not $app) { - New-SpotifyApplication -Name 'integration-test' -ClientId $env:SPOTIFY_CLIENT_ID -ClientSecret $env:SPOTIFY_CLIENT_SECRET + if (-not $app) { + New-SpotifyApplication -Name 'integration-test' -ClientId $env:SPOTIFY_CLIENT_ID -ClientSecret $env:SPOTIFY_CLIENT_SECRET + } } # Get current user for playlist creation From 53a8e7a7bb74df05e0ee987c6cb3ec5fed797828 Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 09:55:24 -0800 Subject: [PATCH 08/10] Add tests for Position parameter and episode support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests: - Position parameter included in API call - Position 0 handled correctly (not falsy) Integration tests: - Episode added via -ItemType parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Tests/Spotishell.Integration.Tests.ps1 | 7 +++++++ Tests/Spotishell.Tests.ps1 | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/Tests/Spotishell.Integration.Tests.ps1 b/Tests/Spotishell.Integration.Tests.ps1 index 177d756..af9ace0 100644 --- a/Tests/Spotishell.Integration.Tests.ps1 +++ b/Tests/Spotishell.Integration.Tests.ps1 @@ -164,6 +164,13 @@ Describe 'Integration: Add-PlaylistItem' -Skip:$script:SkipTests { { Add-PlaylistItem -Id $script:TestPlaylist.id -ItemId $trackIds -ApplicationName 'integration-test' } | Should -Not -Throw } + + It 'Should add an episode using ItemType parameter' { + # The Joe Rogan Experience episode (well-known podcast) + $episodeId = '512ojhOuo1ktJprKbVcKyQ' + + { Add-PlaylistItem -Id $script:TestPlaylist.id -ItemId $episodeId -ItemType 'episode' -ApplicationName 'integration-test' } | Should -Not -Throw + } } } diff --git a/Tests/Spotishell.Tests.ps1 b/Tests/Spotishell.Tests.ps1 index 44a231f..c2ab2f6 100644 --- a/Tests/Spotishell.Tests.ps1 +++ b/Tests/Spotishell.Tests.ps1 @@ -390,6 +390,22 @@ Describe 'Add-PlaylistItem Behavior' { $Method -eq 'Post' } } + + It 'Should include position in API call when specified' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'track456' -Position 5 + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match '"position":5' + } + } + + It 'Should include position 0 in API call' { + Add-PlaylistItem -Id 'playlist123' -ItemId 'track456' -Position 0 + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match '"position":0' + } + } } } From cac8a1756f3f7bba1f0c79c067d9b98a1539dca8 Mon Sep 17 00:00:00 2001 From: wardbox Date: Mon, 1 Dec 2025 10:00:52 -0800 Subject: [PATCH 09/10] Remove integration tests from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests require interactive OAuth browser flow which doesn't work in CI environments. Keep them available for local manual testing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dcb7e1..ad6e183 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,41 +82,3 @@ jobs: Write-Error "No commands exported from module" exit 1 } - - integration-test: - name: Integration Tests - runs-on: ubuntu-latest - # Run on main pushes and PRs from same repo (not forks - protects secrets) - if: | - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) - needs: [test, module-validation] - - steps: - - uses: actions/checkout@v4 - - - name: Run Integration Tests - shell: pwsh - env: - SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} - SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} - SPOTIFY_REFRESH_TOKEN: ${{ secrets.SPOTIFY_REFRESH_TOKEN }} - run: | - Install-Module -Name Pester -Force -Scope CurrentUser -MinimumVersion 5.0.0 - Import-Module Pester - - $config = New-PesterConfiguration - $config.Run.Path = './Tests/Spotishell.Integration.Tests.ps1' - $config.Run.Exit = $true - $config.TestResult.Enabled = $true - $config.TestResult.OutputPath = 'integrationTestResults.xml' - $config.Output.Verbosity = 'Detailed' - - Invoke-Pester -Configuration $config - - - name: Upload Integration Test Results - uses: actions/upload-artifact@v4 - if: always() - with: - name: integration-test-results - path: integrationTestResults.xml From eb3d2fd8c7d41fa1509110274c17785ab654b311 Mon Sep 17 00:00:00 2001 From: wardbox Date: Tue, 2 Dec 2025 10:51:33 -0800 Subject: [PATCH 10/10] Adds batching tests for playlist items Adds tests to verify the correct batching behavior of adding items to a playlist. These tests ensure that large numbers of items are split into multiple API calls, and that the `position` parameter is correctly handled in each batch. --- Tests/Spotishell.Tests.ps1 | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Tests/Spotishell.Tests.ps1 b/Tests/Spotishell.Tests.ps1 index c2ab2f6..bfa78cd 100644 --- a/Tests/Spotishell.Tests.ps1 +++ b/Tests/Spotishell.Tests.ps1 @@ -407,6 +407,41 @@ Describe 'Add-PlaylistItem Behavior' { } } } + + Context 'Batching Behavior' { + BeforeEach { + # Reset mock call history before each test + Mock Send-SpotifyCall { return @{ snapshot_id = 'mock-snapshot' } } -ModuleName Spotishell + } + + It 'Should batch 150 items into two API calls (100 + 50)' { + $trackIds = 1..150 | ForEach-Object { "track$_" } + + Add-PlaylistItem -Id 'playlist123' -ItemId $trackIds + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -Times 2 -Exactly + } + + It 'Should use position 10 in first batch when Position is 10' { + $trackIds = 1..150 | ForEach-Object { "track$_" } + + Add-PlaylistItem -Id 'playlist123' -ItemId $trackIds -Position 10 + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match '"position":10' + } + } + + It 'Should use position 110 in second batch when Position is 10' { + $trackIds = 1..150 | ForEach-Object { "track$_" } + + Add-PlaylistItem -Id 'playlist123' -ItemId $trackIds -Position 10 + + Should -Invoke Send-SpotifyCall -ModuleName Spotishell -ParameterFilter { + $Body -match '"position":110' + } + } + } } Describe 'Get-RecentlyPlayedTracks Behavior' {