From cc48c9cf8c53a13b87f310f81d760e06d89f5113 Mon Sep 17 00:00:00 2001 From: calliostro Date: Sun, 20 Sep 2026 10:46:05 +0200 Subject: [PATCH 1/2] Release v1.4.0: Modernize tooling, CI matrix, PHPStan, PHP-CS-Fixer, test suite and documentation --- .gitattributes | 33 +++ .github/workflows/ci.yml | 151 ++++++++--- .gitignore | 18 +- .php-cs-fixer.dist.php | 22 ++ CHANGELOG.md | 79 ++++++ DEVELOPMENT.md | 72 ++++++ README.md | 238 +++++++++--------- codecov.yml | 9 + composer.json | 38 ++- phpstan.neon.dist | 15 ++ phpunit.xml.dist | 28 ++- src/CalliostroSpotifyWebApiBundle.php | 2 + .../CalliostroSpotifyWebApiExtension.php | 4 +- src/DependencyInjection/Configuration.php | 2 + src/SpotifyWebApiFactory.php | 5 + src/TokenProvider.php | 4 +- src/TokenProviderInterface.php | 2 + tests/{ => Integration}/FunctionalTest.php | 19 +- .../CalliostroSpotifyWebApiBundleTest.php | 19 ++ .../CalliostroSpotifyWebApiExtensionTest.php | 81 ++++++ .../DependencyInjection/ConfigurationTest.php | 59 +++++ tests/Unit/SpotifyWebApiFactoryTest.php | 42 ++++ tests/Unit/TokenProviderTest.php | 28 +++ 23 files changed, 781 insertions(+), 189 deletions(-) create mode 100644 .gitattributes create mode 100644 .php-cs-fixer.dist.php create mode 100644 CHANGELOG.md create mode 100644 DEVELOPMENT.md create mode 100644 codecov.yml create mode 100644 phpstan.neon.dist rename tests/{ => Integration}/FunctionalTest.php (76%) create mode 100644 tests/Unit/CalliostroSpotifyWebApiBundleTest.php create mode 100644 tests/Unit/DependencyInjection/CalliostroSpotifyWebApiExtensionTest.php create mode 100644 tests/Unit/DependencyInjection/ConfigurationTest.php create mode 100644 tests/Unit/SpotifyWebApiFactoryTest.php create mode 100644 tests/Unit/TokenProviderTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0646b41 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# Normalize line endings +* text=auto + +# PHP files +*.php text eol=lf diff=php + +# Config files +*.json text eol=lf +*.md text eol=lf +*.neon text eol=lf +*.xml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf + +# Exclude from releases +.editorconfig export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +.markdownlint.json export-ignore +.php-cs-fixer.* export-ignore +.phpstan/ export-ignore +.phpunit.* export-ignore +codecov.yml export-ignore +coverage/ export-ignore +coverage.clover export-ignore +coverage.xml export-ignore +DEVELOPMENT.md export-ignore +phpstan.neon.dist export-ignore +phpunit.xml* export-ignore +tests/ export-ignore +var/ export-ignore +vendor/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb0f241..b1a8ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,20 +4,20 @@ on: push: branches: - 'main' - - 'legacy/*' - - 'feature/*' - - 'hotfix/*' - - 'release/*' + - 'legacy/*' # Legacy branches: legacy/v1.x + - 'feature/*' # Feature branches: feature/new-feature + - 'hotfix/*' # Hotfix branches: hotfix/urgent-fix + - 'release/*' # Release branches: release/v1.4.0 pull_request: branches: - 'main' - - 'legacy/*' - workflow_dispatch: + - 'legacy/*' # PRs to legacy branches + workflow_dispatch: # Allows manual triggering via GitHub UI jobs: test: - runs-on: ubuntu-latest - + runs-on: ubuntu-24.04 + strategy: fail-fast: false matrix: @@ -35,7 +35,7 @@ jobs: - php: '8.4' symfony-version: '6.4.*' allowed-to-fail: false - + # Symfony 7.x current versions - php: '8.2' symfony-version: '7.0.*' @@ -61,30 +61,60 @@ jobs: - php: '8.4' symfony-version: '7.4.*' allowed-to-fail: false - + # Symfony 8.0 - Requires PHP 8.4+ - php: '8.4' symfony-version: '8.0.*' allowed-to-fail: false - - # PHP 8.5 with various Symfony versions - php: '8.5' - symfony-version: '7.3.*' + symfony-version: '8.0.*' + allowed-to-fail: false + + # Symfony 8.1 + - php: '8.4' + symfony-version: '8.1.*' allowed-to-fail: false - php: '8.5' - symfony-version: '7.4.*' + symfony-version: '8.1.*' allowed-to-fail: false + + # Symfony 8.2 (development / pre-release) + - php: '8.4' + symfony-version: '8.2.*' + allowed-to-fail: true - php: '8.5' + symfony-version: '8.2.*' + allowed-to-fail: true + + # PHP 8.6 (beta / experimental) + - php: '8.6' symfony-version: '8.0.*' - allowed-to-fail: false + allowed-to-fail: true + - php: '8.6' + symfony-version: '8.1.*' + allowed-to-fail: true + - php: '8.6' + symfony-version: '8.2.*' + allowed-to-fail: true + + # Development stability tests + - php: '8.4' + stability: 'dev' + allowed-to-fail: true + - php: '8.5' + stability: 'dev' + allowed-to-fail: true + - php: '8.6' + stability: 'dev' + allowed-to-fail: true + + name: "PHP ${{ matrix.php }}${{ matrix.symfony-version && format(' | Symfony {0}', matrix.symfony-version) || '' }}${{ matrix.stability && format(' | {0}', matrix.stability) || '' }}" - name: "PHP ${{ matrix.php }}${{ matrix.symfony-version && format(' | Symfony {0}', matrix.symfony-version) || '' }}" - continue-on-error: ${{ matrix.allowed-to-fail }} - + steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -99,12 +129,18 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer- + - name: Configure stability + if: matrix.stability + run: | + composer config minimum-stability ${{ matrix.stability }} + composer config prefer-stable true + - name: Configure Symfony version if: matrix.symfony-version run: | @@ -120,24 +156,35 @@ jobs: - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-progress - - name: Validate composer.json - run: composer validate --strict --no-check-lock + - name: Validate composer.json and composer.lock + run: | + if [[ "${{ github.ref }}" == *"beta"* || "${{ github.ref }}" == *"alpha"* || "${{ github.ref }}" == *"rc"* ]]; then + composer validate --no-check-version + else + composer validate --strict + fi + + - name: Run code style check + run: composer cs + + - name: Run static analysis + run: composer analyse -- --memory-limit=1G - name: Run tests - run: ./vendor/bin/simple-phpunit -v + run: composer test code-quality: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 name: Code Quality Checks - + steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: '8.4' extensions: json, mbstring, tokenizer coverage: none tools: composer:v2 @@ -147,7 +194,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -157,4 +204,50 @@ jobs: run: composer install --prefer-dist --no-interaction --no-progress - name: Validate composer.json and composer.lock - run: composer validate --strict + run: | + if [[ "${{ github.ref }}" == *"beta"* || "${{ github.ref }}" == *"alpha"* || "${{ github.ref }}" == *"rc"* ]]; then + composer validate --no-check-version + else + composer validate --strict + fi + + coverage: + runs-on: ubuntu-24.04 + name: Code Coverage + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: json, mbstring, tokenizer + coverage: xdebug + tools: composer:v2 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer dependencies + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run tests with coverage + run: composer test-coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v7 + with: + files: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 458f6b8..9d02a12 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,24 @@ composer.lock # Symfony var (cache, logs, sessions) /var/ -###> symfony/phpunit-bridge ### +# PHPUnit .phpunit .phpunit.result.cache .phpunit.cache/ /phpunit.xml /tests/cache/ -###< symfony/phpunit-bridge ### + +# PHPStan +.phpstan/ + +# PHP CS Fixer +.php-cs-fixer.cache +.php-cs-fixer.php + +# Coverage reports +coverage/ +coverage.xml +*.clover + +# Environment files +.env diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..beb43ec --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,22 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') + ->exclude('vendor') + ->exclude('coverage') + ->exclude('.phpunit.cache') + ->name('*.php'); + +$config = new PhpCsFixer\Config(); +$config->setFinder($finder) + ->setRules([ + '@PSR12' => true, + '@PSR12:risky' => true, + ]) + ->setRiskyAllowed(true) + ->setUnsupportedPhpVersionAllowed(true); + +return $config; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..430d58f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,79 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.4.0](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v1.4.0) – 2026-09-20 + +### Added + +- Compatibility testing and CI matrix coverage for PHP 8.1–8.6 and Symfony 6.4 LTS, 7.x, 8.0, 8.1, and 8.2. +- Created root `phpstan.neon.dist` (Level 8) and added PHPStan static analysis. +- Created `.php-cs-fixer.dist.php` for PSR-12 and PSR-12:risky code styling. +- Added comprehensive unit test suite in `tests/Unit/` reaching 100% code coverage across all classes, methods, and lines. +- Added `DEVELOPMENT.md` guide and Keep a Changelog `CHANGELOG.md`. +- Added `.gitattributes` for clean release archives and updated `.gitignore`. + +### Changed + +- Updated GitHub Actions runners to `ubuntu-24.04` and modernized action versions to Node 24 compatible runners (`actions/checkout@v7`, `actions/cache@v6`, `codecov/codecov-action@v7`). +- Added strict type declarations (`declare(strict_types=1);`) and typed properties across the entire codebase. +- Overhauled `README.md` layout, badges, structure, and sister bundle links to align with sister bundles (`discogs-bundle`, `lastfm-bundle`). +- Updated `phpunit.xml.dist` with separated `Unit Tests`, `Integration Tests`, and `All Tests` test suites. + +## [1.3.0](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v1.3.0) – 2026-01-01 + +### Added + +- Added support for `jwilsson/spotify-web-api-php` v7. +- Migrated dependency injection configuration to PHP config (`services.php`). + +## [1.2.1](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v1.2.1) – 2025-08-23 + +### Changed + +- Migrated CI from Travis CI to GitHub Actions. + +## [1.2.0](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v1.2.0) – 2025-08-22 + +### Changed + +- Refactored PHPUnit configuration and enhanced deprecation handling. + +## [1.1.1](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v1.1.1) – 2024-01-03 + +### Changed + +- Updated dependencies and minor maintenance updates. + +## [1.1.0](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v1.1.0) – 2022-07-16 + +### Added + +- Added Symfony 6 support. + +## [1.0.0](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v1.0.0) – 2021-07-01 + +### Changed + +- Upgraded to `jwilsson/spotify-web-api-php` v5. + +## [0.1.2](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v0.1.2) – 2021-04-20 + +### Added + +- Added client options support. + +## [0.1.1](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v0.1.1) – 2021-04-19 + +### Changed + +- Updated usage documentation and examples. + +## [0.1.0](https://github.com/calliostro/spotify-web-api-bundle/releases/tag/v0.1.0) – 2021-04-18 + +### Added + +- Initial release of Spotify Web API Bundle for Symfony. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..6296106 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,72 @@ +# Development Guide + +This guide is for contributors and developers working on `spotify-web-api-bundle` itself. + +## 🧪 Testing + +### Quick Commands + +```bash +# Unit tests (fast, CI-compatible, 100% coverage target) +composer test + +# Integration tests (functional kernel boot & service wiring) +composer test-integration + +# All tests together (unit + integration) +composer test-all + +# Code coverage (HTML + Clover XML reports) +composer test-coverage +``` + +### Static Analysis & Code Quality + +```bash +# Static analysis (PHPStan Level 8) +composer analyse + +# Code style check (PSR-12 standards) +composer cs + +# Auto-fix code style +composer cs-fix +``` + +## 🏗️ Test Strategy + +- **Unit Tests (`tests/Unit/`)**: Fast, reliable, no external dependencies → **CI default** + - Container configuration validation + - Dependency injection extension loading & service wiring + - Factory client instantiation with options and credentials + - Token provider resolution +- **Integration Tests (`tests/Integration/`)**: Full Symfony kernel boot & service container wiring +- **Code Coverage Target**: 100% coverage across all classes, methods, and lines + +## 🛠️ Development Workflow + +1. Fork the repository +2. Create a feature or release branch (`git checkout -b feature/my-feature` or `git checkout -b release/v1.4.0`) +3. Make changes with tests +4. Run test suite (`composer test-all`) +5. Check code quality (`composer analyse && composer cs`) +6. Commit changes (`git commit -m 'Add feature'`) +7. Push to branch (`git push origin feature/my-feature`) +8. Open Pull Request + +## 📋 Code Standards + +- **PHP Version**: `^8.1` (tested on PHP 8.1–8.6) +- **Code Style**: PSR-12 / PSR-12:risky (`friendsofphp/php-cs-fixer`) +- **Static Analysis**: PHPStan Level 8 +- **Strict Types**: `declare(strict_types=1);` in all PHP files +- **Symfony Compatibility**: 6.4 LTS, 7.x, 8.x + +## 🏛️ Bundle Architecture + +The Symfony bundle provides: + +1. **Service Integration**: Seamless `SpotifyWebAPI` autowiring and factory creation +2. **Session Management**: Configurable `SpotifyWebAPI\Session` service +3. **Token Provider Pattern**: Flexible `TokenProviderInterface` enabling custom access token retrieval (Client Credentials, Authorization Code, etc.) +4. **Configuration Options**: Built-in support for `auto_refresh`, `auto_retry`, and `return_assoc` options diff --git a/README.md b/README.md index d1b7f9d..3a6c5d2 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,89 @@ +# Spotify Web API Bundle for Symfony -# 🎵 Spotify Web API Bundle - -[![CI](https://github.com/calliostro/spotify-web-api-bundle/workflows/CI/badge.svg)](https://github.com/calliostro/spotify-web-api-bundle/actions) -[![Version](https://poser.pugx.org/calliostro/spotify-web-api-bundle/version)](https://packagist.org/packages/calliostro/spotify-web-api-bundle) +[![Package Version](https://img.shields.io/packagist/v/calliostro/spotify-web-api-bundle.svg)](https://packagist.org/packages/calliostro/spotify-web-api-bundle) +[![Total Downloads](https://img.shields.io/packagist/dt/calliostro/spotify-web-api-bundle.svg)](https://packagist.org/packages/calliostro/spotify-web-api-bundle) [![License](https://poser.pugx.org/calliostro/spotify-web-api-bundle/license)](https://packagist.org/packages/calliostro/spotify-web-api-bundle) +[![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue.svg)](https://php.net) +[![CI](https://github.com/calliostro/spotify-web-api-bundle/actions/workflows/ci.yml/badge.svg)](https://github.com/calliostro/spotify-web-api-bundle/actions/workflows/ci.yml) +[![Code Coverage](https://codecov.io/gh/calliostro/spotify-web-api-bundle/graph/badge.svg)](https://codecov.io/gh/calliostro/spotify-web-api-bundle) +[![PHPStan Level](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg)](https://phpstan.org/) +[![Code Style](https://img.shields.io/badge/code%20style-PSR12-brightgreen.svg)](https://github.com/FriendsOfPHP/PHP-CS-Fixer) - > 🚀 **Easy integration of [jwilsson/spotify-web-api-php](https://github.com/jwilsson/spotify-web-api-php) into Symfony 6.4, 7 & 8!** - -## ✨ Features - -- Simple integration with Symfony 6.4, 7 & 8 -- Supports [jwilsson/spotify-web-api-php](https://github.com/jwilsson/spotify-web-api-php) v6 & v7 -- Supports Client Credentials & Authorization Code flows -- Autowire Spotify API services -- Customizable token provider -- Easy configuration +A Symfony bundle integrating [`jwilsson/spotify-web-api-php`](https://github.com/jwilsson/spotify-web-api-php) into your Symfony application. Provides dependency injection, autowiring, customizable token management, and support for Client Credentials & Authorization Code flows for PHP 8.1+ and Symfony 6.4, 7.x, and 8.x. ## 📦 Installation -Make sure Composer is installed globally, as explained in the [installation chapter](https://getcomposer.org/doc/00-intro.md) of the Composer documentation. - -### ⚡ Applications that use Symfony Flex +Install via Composer: -Open a command console, enter your project directory and execute: - -```console +```bash composer require calliostro/spotify-web-api-bundle ``` -### 🛠️ Applications that don't use Symfony Flex - -#### Step 1: Download the Bundle - -Open a command console, enter your project directory and execute the following command to download the latest stable version of this bundle: - -```console -composer require calliostro/spotify-web-api-bundle -``` - -#### Step 2: Enable the Bundle - -Then, enable the bundle by adding it to the list of registered bundles in the `config/bundles.php` file of your project: - -```php -// config/bundles.php - -return [ - // ... - Calliostro\SpotifyWebApiBundle\CalliostroSpotifyWebApiBundle::class => ['all' => true], -]; -``` - -> **Supports Symfony 6.4, 7.x and 8.x!** +--- ## ⚙️ Configuration -First, you must register your application at to obtain the `client_id` and `client_secret`. - -If you want to access user-related endpoints, the user must grant access to your application. Spotify provides OAuth 2.0 for this purpose. You need to register the `redirect_uri` in the Spotify dashboard. For the following example, you would add `https://127.0.0.1:8000/callback/` to the allowlist addresses. +Register your application on the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/applications) to obtain your `client_id` and `client_secret`. -For configuration, create a new `config/packages/calliostro_spotify_web_api.yaml` file. Here is an example: +Configure the bundle in `config/packages/calliostro_spotify_web_api.yaml`: ```yaml -# config/packages/calliostro_spotify_web_api.yaml calliostro_spotify_web_api: - - # Your Client ID - client_id: '' # Required + # Your Client ID from the Spotify Developer Dashboard + client_id: '%env(SPOTIFY_CLIENT_ID)%' # Your Client Secret - client_secret: '' # Required + client_secret: '%env(SPOTIFY_CLIENT_SECRET)%' - # Options for SpotifyWebAPI client - # https://github.com/jwilsson/spotify-web-api-php/blob/main/docs/examples/setting-options.md - options: - auto_refresh: false - auto_retry: false - return_assoc: false + # Address to redirect to after authentication success OR failure (required for Authorization Code flow) + redirect_uri: '%env(SPOTIFY_REDIRECT_URI)%' - # Address to redirect to after authentication success OR failure - redirect_uri: '' # Example: 'https://127.0.0.1:8000/callback/' + # Optional: Client options for jwilsson/spotify-web-api-php + options: + auto_refresh: false + auto_retry: false + return_assoc: false - # Service ID of the token provider that provides the user's access token - token_provider: calliostro_spotify_web_api.token_provider + # Optional: Custom token provider service (defaults to built-in Client Credentials TokenProvider) + # token_provider: calliostro_spotify_web_api.token_provider ``` -## 🎬 Usage +> [!NOTE] +> If you are using the Authorization Code flow, make sure to allowlist your `redirect_uri` (e.g. `https://127.0.0.1:8000/callback/`) in your Spotify Developer App settings. -This bundle provides a single service for communication with Spotify Web API, which you can autowire by using the `SpotifyWebAPI` and `Session` type-hint: +--- -### 🔑 Client Credentials +## 🚀 Quick Start -This is the simpler option if no user-related endpoints are required. +### 1. Client Credentials Flow (Machine-to-Machine) + +For public data endpoints (searching tracks, getting artist info, browsing playlists), inject `SpotifyWebAPI` directly into your controllers or services. The built-in token provider automatically requests a Client Credentials token: ```php -// src/Controller/SomeController.php +search('Thriller', 'album'); - - var_dump($search); + $results = $api->search('Billie Eilish', 'artist'); - // ... + return new JsonResponse($results); } } ``` -### 🧑‍💻 Authorization Code +### 2. Authorization Code Flow (User Data) -If you want to access a Spotify user's profile or data, you must first redirect the user to Spotify's approval page. Then you can start the session. +To access private user data (playlists, saved tracks, top artists), inject both `SpotifyWebAPI` and `Session`: ```php -// src/Controller/SpotifyController.php +Spotify Web API Demo -

Welcome to the Spotify Web API Bundle demonstration!

-

Click here to authorize with Spotify

- ', 200, ['Content-Type' => 'text/html']); + $options = [ + 'scope' => [ + 'user-read-email', + 'user-read-private', + 'playlist-read-private', + ], + ]; + + return $this->redirect($this->session->getAuthorizeUrl($options)); } - #[Route('/callback')] + #[Route('/callback', name: 'spotify_callback')] public function callback(Request $request): Response { + $code = $request->query->getString('code'); + try { - $this->session->requestAccessToken($request->query->getString('code')); + $this->session->requestAccessToken($code); } catch (SpotifyWebAPIAuthException) { - return $this->redirectToRoute('authorize'); + return $this->redirectToRoute('spotify_authorize'); } $this->api->setAccessToken($this->session->getAccessToken()); - $me = $this->api->me(); - - return new Response(' -

Spotify Authorization Successful!

-

Welcome, ' . htmlspecialchars($me->display_name ?? 'Spotify User') . '!

-
' . htmlspecialchars(var_export($me, true)) . '
-

Back to Home

- ', 200, ['Content-Type' => 'text/html']); - } + $user = $this->api->me(); - #[Route('/authorize', name: 'authorize')] - public function authorize(): Response - { - $options = [ - 'scope' => [ - 'user-read-email', - ], - ]; - - return $this->redirect($this->session->getAuthorizeUrl($options)); + return new Response(sprintf('

Hello, %s!

', htmlspecialchars($user->display_name ?? 'Spotify User'))); } } ``` -> ⚠️ **Remember to set `redirect_uri` in the configuration file and allowlist it on Spotify!** +--- + +## ✨ Key Features -## 📚 Documentation +- **Seamless Autowiring** – Type-hint `SpotifyWebAPI` and `Session` directly in your services and controllers. +- **Dual Flow Support** – Ready out of the box for both Client Credentials and Authorization Code flows. +- **Custom Token Providers** – Implement `TokenProviderInterface` to plug in your own token storage (Redis, database, session). +- **Client Options** – Easily toggle `auto_refresh`, `auto_retry`, and `return_assoc` via YAML configuration. +- **Type Safety & IDE Support** – PHP 8.1+ types, strict types, and PHPStan Level 8 static analysis. +- **Symfony Native** – Full compatibility with Symfony 6.4 LTS, 7.x, and 8.x. + +--- -See [jwilsson/spotify-web-api-php](https://github.com/jwilsson/spotify-web-api-php) for documentation of the SpotifyWebAPI service. +## 📋 Requirements -See [Spotify's Web API](https://developer.spotify.com/documentation/) full API documentation. +- **PHP** `^8.1` (tested on PHP 8.1–8.6) +- **Symfony** `^6.4 || ^7.0 || ^8.0` +- **jwilsson/spotify-web-api-php** `^6.0 || ^7.0` --- -### ⚡ Supported Symfony Versions +## 🧪 Development & Testing Guide -- **Symfony 6.4 (LTS)** -- **Symfony 7.x** -- **Symfony 8.x** +See [DEVELOPMENT.md](DEVELOPMENT.md) for detailed setup instructions, test suite commands, static analysis, and contribution guidelines. + +--- ## 🤝 Contributing -Implemented a missing feature? You can request it. And creating a pull request is an even better way to get things done. +Contributions are welcome! Please ensure that all tests pass and coding standards are maintained: + +```bash +composer cs-fix +composer analyse +composer test-all +``` --- -## 🏁 Quick Start +## 📄 License + +This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details. + +--- -1. Install the bundle with Composer -2. Configure your Spotify credentials -3. Autowire the service and start using the API! +## ⚖️ Disclaimer + +Spotify is a registered trademark of Spotify AB. This project is an independent, unofficial open-source bundle and is not affiliated with, endorsed by, or sponsored by Spotify AB. + +--- -## 💬 Support +## 🙏 Acknowledgments -For questions or help, feel free to open an issue or reach out! 😊 +- [jwilsson/spotify-web-api-php](https://github.com/jwilsson/spotify-web-api-php) for the underlying Spotify Web API client. +- [Symfony](https://symfony.com) for the web framework and dependency injection container. +- Sister Symfony bundles: + - [`calliostro/discogs-bundle`](https://github.com/calliostro/discogs-bundle) – Symfony bundle for the Discogs API. + - [`calliostro/lastfm-bundle`](https://github.com/calliostro/last-fm-client-bundle) – Symfony bundle for the Last.fm API. + - [`calliostro/musicbrainz-bundle`](https://github.com/calliostro/musicbrainz-bundle) – Symfony bundle for the MusicBrainz API. + - [`calliostro/spotify-bundle`](https://github.com/calliostro/spotify-bundle) – Lightweight Symfony bundle for [`calliostro/spotify-client`](https://github.com/calliostro/spotify-client). diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..7d7a6c9 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,9 @@ +coverage: + status: + project: + default: + branches: + - main + - legacy/* + - hotfix/* + - release/* diff --git a/composer.json b/composer.json index 5c790e2..7e9aa53 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,9 @@ "api-client", "web-api", "audio", - "streaming" + "streaming", + "php8", + "lightweight" ], "homepage": "https://github.com/calliostro/spotify-web-api-bundle", "license": "MIT", @@ -24,25 +26,41 @@ ], "require": { "php": "^8.1", - "jwilsson/spotify-web-api-php": "^6.0|^7.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0" + "jwilsson/spotify-web-api-php": "^6.0 || ^7.0", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^2.0", + "symfony/phpunit-bridge": "^6.4 || ^7.0 || ^8.0" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true }, "autoload": { "psr-4": { "Calliostro\\SpotifyWebApiBundle\\": "src/" } }, - "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0|^8.0" - }, "autoload-dev": { "psr-4": { "Calliostro\\SpotifyWebApiBundle\\Tests\\": "tests/" } }, "scripts": { - "test": "vendor/bin/simple-phpunit" - } + "test": "vendor/bin/simple-phpunit --testsuite=\"Unit Tests\"", + "test-integration": "vendor/bin/simple-phpunit --testsuite=\"Integration Tests\"", + "test-all": "vendor/bin/simple-phpunit --testsuite=\"All Tests\"", + "test-coverage": "vendor/bin/simple-phpunit --testsuite=\"Unit Tests\" --coverage-html coverage --coverage-clover coverage.xml", + "test-coverage-all": "vendor/bin/simple-phpunit --testsuite=\"All Tests\" --coverage-html coverage --coverage-clover coverage.xml", + "cs": "vendor/bin/php-cs-fixer fix --dry-run --diff --verbose", + "cs-fix": "vendor/bin/php-cs-fixer fix --verbose", + "analyse": "vendor/bin/phpstan analyse" + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..4caae1b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,15 @@ +parameters: + level: 8 + paths: + - src + tmpDir: .phpunit.cache/phpstan + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + ignoreErrors: + - identifier: method.alreadyNarrowedType + - identifier: function.alreadyNarrowedType + - identifier: missingType.generics + - + message: '#^Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition\:\:children\(\)\.$#' + identifier: method.notFound + path: src/DependencyInjection/Configuration.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 263b967..08ac5db 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,21 +6,27 @@ executionOrder="random" resolveDependencies="true" > + + + ./src + + - - - - - + + + + + - + + ./tests/Unit + + + ./tests/Integration + + ./tests - - - ./src - - \ No newline at end of file diff --git a/src/CalliostroSpotifyWebApiBundle.php b/src/CalliostroSpotifyWebApiBundle.php index 4a95348..a581672 100644 --- a/src/CalliostroSpotifyWebApiBundle.php +++ b/src/CalliostroSpotifyWebApiBundle.php @@ -1,5 +1,7 @@ load('services.php'); - $configuration = $this->getConfiguration($configs, $container); + $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); // Set arguments instead of replacing them since services.php doesn't define them diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 73db901..0a78919 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -1,5 +1,7 @@ $options + */ public static function factory(TokenProviderInterface $tokenProvider, array $options = []): SpotifyWebAPI { $api = new SpotifyWebAPI($options); diff --git a/src/TokenProvider.php b/src/TokenProvider.php index 9869336..3ca1cd3 100644 --- a/src/TokenProvider.php +++ b/src/TokenProvider.php @@ -1,12 +1,14 @@ boot(); $container = $kernel->getContainer(); - $SpotifyWebApi = $container->get('calliostro_spotify_web_api'); - $this->assertInstanceOf(SpotifyWebAPI::class, $SpotifyWebApi); + $spotifyWebApi = $container->get('calliostro_spotify_web_api'); + $this->assertInstanceOf(SpotifyWebAPI::class, $spotifyWebApi); } } class CalliostroSpotifyWebApiTestingKernel extends Kernel { - private $calliostroSpotifyWebApiConfig; - - public function __construct(array $calliostroSpotifyWebApiConfig = []) + /** + * @param array $calliostroSpotifyWebApiConfig + */ + public function __construct(private readonly array $calliostroSpotifyWebApiConfig = []) { - $this->calliostroSpotifyWebApiConfig = $calliostroSpotifyWebApiConfig; - parent::__construct('test', true); } @@ -56,7 +57,7 @@ public function registerContainerConfiguration(LoaderInterface $loader): void public function getCacheDir(): string { - return $this->getProjectDir() . '/var/cache/'.$this->environment.'/'.spl_object_hash($this); + return $this->getProjectDir() . '/var/cache/' . $this->environment . '/' . spl_object_hash($this); } } diff --git a/tests/Unit/CalliostroSpotifyWebApiBundleTest.php b/tests/Unit/CalliostroSpotifyWebApiBundleTest.php new file mode 100644 index 0000000..9fa7596 --- /dev/null +++ b/tests/Unit/CalliostroSpotifyWebApiBundleTest.php @@ -0,0 +1,19 @@ +assertInstanceOf(Bundle::class, $bundle); + } +} diff --git a/tests/Unit/DependencyInjection/CalliostroSpotifyWebApiExtensionTest.php b/tests/Unit/DependencyInjection/CalliostroSpotifyWebApiExtensionTest.php new file mode 100644 index 0000000..260ae1b --- /dev/null +++ b/tests/Unit/DependencyInjection/CalliostroSpotifyWebApiExtensionTest.php @@ -0,0 +1,81 @@ +load([], $container); + + $this->assertTrue($container->hasDefinition('calliostro_spotify_web_api.session')); + $this->assertTrue($container->hasDefinition('calliostro_spotify_web_api.token_provider')); + $this->assertTrue($container->hasDefinition('calliostro_spotify_web_api')); + + $this->assertTrue($container->hasAlias(Session::class)); + $this->assertTrue($container->hasAlias(SpotifyWebAPI::class)); + + $sessionDef = $container->getDefinition('calliostro_spotify_web_api.session'); + $this->assertSame(['', '', ''], $sessionDef->getArguments()); + + $tokenProviderDef = $container->getDefinition('calliostro_spotify_web_api.token_provider'); + $this->assertEquals([new Reference('calliostro_spotify_web_api.session')], $tokenProviderDef->getArguments()); + + $apiDef = $container->getDefinition('calliostro_spotify_web_api'); + $this->assertEquals([ + new Reference('calliostro_spotify_web_api.token_provider'), + [ + 'auto_refresh' => false, + 'auto_retry' => false, + 'return_assoc' => false, + ], + ], $apiDef->getArguments()); + } + + public function testLoadWithCustomConfig(): void + { + $container = new ContainerBuilder(); + $extension = new CalliostroSpotifyWebApiExtension(); + + $configs = [ + [ + 'client_id' => 'custom_id', + 'client_secret' => 'custom_secret', + 'redirect_uri' => 'https://example.com/callback', + 'token_provider' => 'my_custom_provider', + 'options' => [ + 'auto_refresh' => true, + 'auto_retry' => true, + 'return_assoc' => true, + ], + ], + ]; + + $extension->load($configs, $container); + + $sessionDef = $container->getDefinition('calliostro_spotify_web_api.session'); + $this->assertSame(['custom_id', 'custom_secret', 'https://example.com/callback'], $sessionDef->getArguments()); + + $apiDef = $container->getDefinition('calliostro_spotify_web_api'); + $this->assertEquals([ + new Reference('my_custom_provider'), + [ + 'auto_refresh' => true, + 'auto_retry' => true, + 'return_assoc' => true, + ], + ], $apiDef->getArguments()); + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..422aedb --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,59 @@ +processor = new Processor(); + $this->configuration = new Configuration(); + } + + public function testDefaultConfig(): void + { + $config = $this->processor->processConfiguration($this->configuration, []); + + $expected = [ + 'client_id' => '', + 'client_secret' => '', + 'options' => [ + 'auto_refresh' => false, + 'auto_retry' => false, + 'return_assoc' => false, + ], + 'redirect_uri' => '', + 'token_provider' => 'calliostro_spotify_web_api.token_provider', + ]; + + $this->assertSame($expected, $config); + } + + public function testCustomConfig(): void + { + $customConfig = [ + 'client_id' => 'my_client_id', + 'client_secret' => 'my_client_secret', + 'options' => [ + 'auto_refresh' => true, + 'auto_retry' => true, + 'return_assoc' => true, + ], + 'redirect_uri' => 'https://127.0.0.1:8000/callback/', + 'token_provider' => 'app.custom_token_provider', + ]; + + $config = $this->processor->processConfiguration($this->configuration, [$customConfig]); + + $this->assertSame($customConfig, $config); + } +} diff --git a/tests/Unit/SpotifyWebApiFactoryTest.php b/tests/Unit/SpotifyWebApiFactoryTest.php new file mode 100644 index 0000000..c0be07a --- /dev/null +++ b/tests/Unit/SpotifyWebApiFactoryTest.php @@ -0,0 +1,42 @@ +createMock(TokenProviderInterface::class); + $tokenProvider->expects($this->once()) + ->method('getAccessToken') + ->willReturn('factory_access_token'); + + $options = [ + 'auto_retry' => true, + 'return_assoc' => true, + ]; + + $client = SpotifyWebApiFactory::factory($tokenProvider, $options); + + $this->assertInstanceOf(SpotifyWebAPI::class, $client); + + $reflection = new \ReflectionClass($client); + $accessTokenProp = $reflection->getProperty('accessToken'); + $accessTokenProp->setAccessible(true); + $this->assertSame('factory_access_token', $accessTokenProp->getValue($client)); + + $optionsProp = $reflection->getProperty('options'); + $optionsProp->setAccessible(true); + /** @var array $actualOptions */ + $actualOptions = $optionsProp->getValue($client); + $this->assertTrue($actualOptions['auto_retry']); + $this->assertTrue($actualOptions['return_assoc']); + } +} diff --git a/tests/Unit/TokenProviderTest.php b/tests/Unit/TokenProviderTest.php new file mode 100644 index 0000000..d2ee7ce --- /dev/null +++ b/tests/Unit/TokenProviderTest.php @@ -0,0 +1,28 @@ +createMock(Session::class); + $session->expects($this->once()) + ->method('requestCredentialsToken'); + $session->expects($this->once()) + ->method('getAccessToken') + ->willReturn('mock_access_token'); + + $tokenProvider = new TokenProvider($session); + + $this->assertInstanceOf(TokenProviderInterface::class, $tokenProvider); + $this->assertSame('mock_access_token', $tokenProvider->getAccessToken()); + } +} From 2fe6e9ce7c37402b0837834dab9c0344386f2867 Mon Sep 17 00:00:00 2001 From: calliostro Date: Sun, 20 Sep 2026 11:16:50 +0200 Subject: [PATCH 2/2] Feature: Add resilient SpotifyClient with automated token refresh for CLI/workers and runtime validation --- CHANGELOG.md | 16 ++- README.md | 46 +++++-- UPGRADE.md | 56 ++++++++ .../CalliostroSpotifyWebApiExtension.php | 7 +- .../SpotifyClientFactory.php | 69 ++++++++++ src/Resources/config/services.php | 43 ++++-- src/SpotifyClient.php | 63 +++++++++ src/SpotifyWebApiFactory.php | 10 +- src/TokenProvider.php | 38 +++++- src/TokenProviderInterface.php | 2 +- tests/Fixtures/TestKernel.php | 61 +++++++++ tests/Integration/FunctionalTest.php | 50 +++---- .../CalliostroSpotifyWebApiExtensionTest.php | 30 +++-- .../SpotifyClientFactoryTest.php | 88 +++++++++++++ tests/Unit/SpotifyClientTest.php | 124 ++++++++++++++++++ tests/Unit/SpotifyWebApiFactoryTest.php | 2 + tests/Unit/TokenProviderTest.php | 65 ++++++++- 17 files changed, 693 insertions(+), 77 deletions(-) create mode 100644 UPGRADE.md create mode 100644 src/DependencyInjection/SpotifyClientFactory.php create mode 100644 src/SpotifyClient.php create mode 100644 tests/Fixtures/TestKernel.php create mode 100644 tests/Unit/DependencyInjection/SpotifyClientFactoryTest.php create mode 100644 tests/Unit/SpotifyClientTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 430d58f..1586de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `Calliostro\SpotifyWebApiBundle\SpotifyClient` subclassing `SpotifyWebAPI\SpotifyWebAPI` with automatic token freshness checking and retry handling on expired token errors (`401`). +- Added in-memory token caching with timestamp tracking (55-minute TTL) and force-refresh capabilities to `TokenProvider`. +- Solved token expiration after 60 minutes for long-running processes (CLI commands and Symfony Messenger background workers). +- Added `SpotifyClientFactory` with runtime credential validation (`client_id`, `client_secret`) and actionable configuration error instructions. +- Added `tests/Fixtures/TestKernel.php` for clean, isolated testing. +- Created `UPGRADE.md` guide. +- Added comprehensive unit test suite in `tests/Unit/` reaching 100% code coverage across all classes, methods, and lines. - Compatibility testing and CI matrix coverage for PHP 8.1–8.6 and Symfony 6.4 LTS, 7.x, 8.0, 8.1, and 8.2. - Created root `phpstan.neon.dist` (Level 8) and added PHPStan static analysis. - Created `.php-cs-fixer.dist.php` for PSR-12 and PSR-12:risky code styling. -- Added comprehensive unit test suite in `tests/Unit/` reaching 100% code coverage across all classes, methods, and lines. -- Added `DEVELOPMENT.md` guide and Keep a Changelog `CHANGELOG.md`. +- Added `DEVELOPMENT.md` guide. - Added `.gitattributes` for clean release archives and updated `.gitignore`. +### Deprecated + +- Deprecated `SpotifyWebApiFactory` in favor of `SpotifyClientFactory`. +- Deprecated `calliostro_spotify_web_api` service alias in favor of `calliostro_spotify_web_api.client`. +- Deprecated `SpotifyWebAPI\SpotifyWebAPI` autowiring alias in favor of `Calliostro\SpotifyWebApiBundle\SpotifyClient`. + ### Changed - Updated GitHub Actions runners to `ubuntu-24.04` and modernized action versions to Node 24 compatible runners (`actions/checkout@v7`, `actions/cache@v6`, `codecov/codecov-action@v7`). diff --git a/README.md b/README.md index 3a6c5d2..7951086 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![PHPStan Level](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg)](https://phpstan.org/) [![Code Style](https://img.shields.io/badge/code%20style-PSR12-brightgreen.svg)](https://github.com/FriendsOfPHP/PHP-CS-Fixer) -A Symfony bundle integrating [`jwilsson/spotify-web-api-php`](https://github.com/jwilsson/spotify-web-api-php) into your Symfony application. Provides dependency injection, autowiring, customizable token management, and support for Client Credentials & Authorization Code flows for PHP 8.1+ and Symfony 6.4, 7.x, and 8.x. +A resilient Symfony bundle integrating [`jwilsson/spotify-web-api-php`](https://github.com/jwilsson/spotify-web-api-php) into your Symfony application. Features automated token management for long-running CLI commands and Symfony Messenger background workers, dependency injection, autowiring, and support for Client Credentials & Authorization Code flows on PHP 8.1+ and Symfony 6.4, 7.x, and 8.x. ## 📦 Installation @@ -57,38 +57,41 @@ calliostro_spotify_web_api: ### 1. Client Credentials Flow (Machine-to-Machine) -For public data endpoints (searching tracks, getting artist info, browsing playlists), inject `SpotifyWebAPI` directly into your controllers or services. The built-in token provider automatically requests a Client Credentials token: +For public data endpoints (searching tracks, getting artist info, browsing playlists), inject `SpotifyClient` directly into your controllers, services, or console commands: ```php search('Billie Eilish', 'artist'); + $results = $spotify->search('Billie Eilish', 'artist'); return new JsonResponse($results); } } ``` +> [!TIP] +> Type-hinting `Calliostro\SpotifyWebApiBundle\SpotifyClient` is recommended. It extends `SpotifyWebAPI\SpotifyWebAPI`, ensuring full backward compatibility while providing automated token freshness checks and retry handling for long-running processes. + ### 2. Authorization Code Flow (User Data) -To access private user data (playlists, saved tracks, top artists), inject both `SpotifyWebAPI` and `Session`: +To access private user data (playlists, saved tracks, top artists), inject both `SpotifyClient` and `Session`: ```php redirectToRoute('spotify_authorize'); } - $this->api->setAccessToken($this->session->getAccessToken()); - $user = $this->api->me(); + $this->spotify->setAccessToken($this->session->getAccessToken()); + $user = $this->spotify->me(); return new Response(sprintf('

Hello, %s!

', htmlspecialchars($user->display_name ?? 'Spotify User'))); } @@ -139,11 +142,26 @@ final class SpotifyController extends AbstractController --- +## ⚡ Long-Running Processes (CLI & Messenger Workers) + +Spotify OAuth access tokens expire strictly after 3,600 seconds (1 hour). In standard setups, long-running CLI commands or Symfony Messenger background workers (`bin/console messenger:consume`) crash with a `401 Expired Token` error after 60 minutes. + +This bundle solves this problem automatically out of the box: +1. **In-Memory Caching:** `TokenProvider` caches the access token in memory with an automatic freshness threshold (55 minutes). +2. **Pre-emptive Refresh:** Before any API call is sent, `SpotifyClient` ensures the token is still valid and refreshes it transparently if needed. +3. **Self-Healing Retries:** If Spotify returns an expired token exception, `SpotifyClient` catches it, forces a token refresh, and retries the request once before failing. + +Your workers and daemon commands can run for days without interruption or manual token management. + +--- + ## ✨ Key Features -- **Seamless Autowiring** – Type-hint `SpotifyWebAPI` and `Session` directly in your services and controllers. -- **Dual Flow Support** – Ready out of the box for both Client Credentials and Authorization Code flows. -- **Custom Token Providers** – Implement `TokenProviderInterface` to plug in your own token storage (Redis, database, session). +- **Resilient `SpotifyClient`** – Extends `SpotifyWebAPI` with transparent token refresh and self-healing retries. +- **Daemon & CLI Ready** – Runs indefinitely in Symfony Messenger workers and console commands without 60-minute token expiration crashes. +- **Runtime Credential Validation** – Clear, actionable error messages pointing to your Spotify dashboard when credentials are missing. +- **Seamless Autowiring** – Type-hint `SpotifyClient` (recommended) or `SpotifyWebAPI` (deprecated alias) and `Session`. +- **Dual Flow Support** – Out-of-the-box support for both Client Credentials and Authorization Code flows. - **Client Options** – Easily toggle `auto_refresh`, `auto_retry`, and `return_assoc` via YAML configuration. - **Type Safety & IDE Support** – PHP 8.1+ types, strict types, and PHPStan Level 8 static analysis. - **Symfony Native** – Full compatibility with Symfony 6.4 LTS, 7.x, and 8.x. @@ -195,5 +213,5 @@ Spotify is a registered trademark of Spotify AB. This project is an independent, - Sister Symfony bundles: - [`calliostro/discogs-bundle`](https://github.com/calliostro/discogs-bundle) – Symfony bundle for the Discogs API. - [`calliostro/lastfm-bundle`](https://github.com/calliostro/last-fm-client-bundle) – Symfony bundle for the Last.fm API. - - [`calliostro/musicbrainz-bundle`](https://github.com/calliostro/musicbrainz-bundle) – Symfony bundle for the MusicBrainz API. - [`calliostro/spotify-bundle`](https://github.com/calliostro/spotify-bundle) – Lightweight Symfony bundle for [`calliostro/spotify-client`](https://github.com/calliostro/spotify-client). + - [`calliostro/musicbrainz-bundle`](https://github.com/calliostro/musicbrainz-bundle) – Symfony bundle for the MusicBrainz API. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..778f831 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,56 @@ +# Upgrade Guide – v1.4.0 + +Version 1.4.0 introduces the resilient `SpotifyClient`, automatic in-memory token caching, and long-running process support (CLI commands & Symfony Messenger workers). + +--- + +## 🌟 Highlights in v1.4.0 + +- **Resilient `SpotifyClient`:** Subclasses `SpotifyWebAPI\SpotifyWebAPI` with pre-emptive token freshness checks and automated single-retry handling when receiving expired token exceptions (`401 Unauthorized`). +- **CLI & Messenger Worker Stability:** Long-running processes can now run indefinitely without crashing after 60 minutes due to expired Spotify OAuth tokens. +- **In-Memory Token Caching:** Token requests are cached in memory (TTL: 55 minutes) to eliminate redundant token requests during web requests. +- **Runtime Credential Validation:** `SpotifyClientFactory` validates `client_id` and `client_secret` at runtime, supporting `%env(...)%` resolution with clear setup instructions when misconfigured. +- **Tooling & Standards:** Full PHPStan Level 8 static analysis, PSR-12 code style, and 100% test coverage. + +--- + +## 🔄 Migration Guide + +### 1. Update Autowiring Type-Hints (Recommended) + +Replace type-hints of `SpotifyWebAPI\SpotifyWebAPI` with `Calliostro\SpotifyWebApiBundle\SpotifyClient`: + +```diff +- use SpotifyWebAPI\SpotifyWebAPI; ++ use Calliostro\SpotifyWebApiBundle\SpotifyClient; + +final class MusicController +{ +- public function search(SpotifyWebAPI $api): JsonResponse ++ public function search(SpotifyClient $api): JsonResponse + { + $results = $api->search('Billie Eilish', 'artist'); + return new JsonResponse($results); + } +} +``` + +> [!NOTE] +> `SpotifyClient` extends `SpotifyWebAPI\SpotifyWebAPI`. Existing code type-hinting `SpotifyWebAPI` will continue to work seamlessly. However, the alias has been deprecated to encourage upgrading to `SpotifyClient`. + +### 2. Service IDs + +- **Recommended:** `calliostro_spotify_web_api.client` +- **Legacy (Deprecated):** `calliostro_spotify_web_api` (kept as alias for full backward compatibility) + +### 3. Factory Service + +- If you manually used `SpotifyWebApiFactory::factory(...)`, switch to `SpotifyClientFactory::createClient(...)` or instantiate `SpotifyClient` directly. + +--- + +## 📋 Compatibility + +- **PHP:** `^8.1` (tested on PHP 8.1–8.6) +- **Symfony:** `^6.4 || ^7.0 || ^8.0` +- **jwilsson/spotify-web-api-php:** `^6.0 || ^7.0` diff --git a/src/DependencyInjection/CalliostroSpotifyWebApiExtension.php b/src/DependencyInjection/CalliostroSpotifyWebApiExtension.php index 9808566..b77a2c3 100644 --- a/src/DependencyInjection/CalliostroSpotifyWebApiExtension.php +++ b/src/DependencyInjection/CalliostroSpotifyWebApiExtension.php @@ -20,7 +20,6 @@ public function load(array $configs, ContainerBuilder $container): void $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); - // Set arguments instead of replacing them since services.php doesn't define them $container->getDefinition('calliostro_spotify_web_api.session') ->setArguments([ $config['client_id'], @@ -33,10 +32,14 @@ public function load(array $configs, ContainerBuilder $container): void new Reference('calliostro_spotify_web_api.session'), ]); - $container->getDefinition('calliostro_spotify_web_api') + $container->getDefinition('calliostro_spotify_web_api.client') ->setArguments([ + $config['client_id'], + $config['client_secret'], + $config['redirect_uri'], new Reference($config['token_provider']), $config['options'], + new Reference('calliostro_spotify_web_api.session'), ]); } } diff --git a/src/DependencyInjection/SpotifyClientFactory.php b/src/DependencyInjection/SpotifyClientFactory.php new file mode 100644 index 0000000..ef3e49d --- /dev/null +++ b/src/DependencyInjection/SpotifyClientFactory.php @@ -0,0 +1,69 @@ + $options + */ + public function createClient( + ?string $clientId, + ?string $clientSecret, + ?string $redirectUri, + TokenProviderInterface $tokenProvider, + array $options = [], + ?Session $session = null, + ): SpotifyClient { + $clientId = $clientId !== null ? trim($clientId) : ''; + $clientSecret = $clientSecret !== null ? trim($clientSecret) : ''; + + $this->validateCredentials($clientId, $clientSecret); + + return new SpotifyClient($tokenProvider, $options, $session); + } + + private function validateCredentials(string $clientId, string $clientSecret): void + { + if ($clientId === '' || $clientSecret === '') { + throw new \InvalidArgumentException( + 'Missing Spotify API credentials. Both client_id and client_secret are required to initialize the Spotify client.' . $this->getSetupInstructions() + ); + } + + if (\strlen($clientId) < 10) { + throw new \InvalidArgumentException(\sprintf( + 'Spotify client_id appears invalid (expected at least 10 characters, got %d). %s', + \strlen($clientId), + $this->getSetupInstructions() + )); + } + + if (\strlen($clientSecret) < 10) { + throw new \InvalidArgumentException(\sprintf( + 'Spotify client_secret appears invalid (expected at least 10 characters, got %d). %s', + \strlen($clientSecret), + $this->getSetupInstructions() + )); + } + } + + private function getSetupInstructions(): string + { + return "\n\nTo configure Spotify API credentials:\n" . + "1. Register an application at: https://developer.spotify.com/dashboard/applications\n" . + "2. Set environment variables in your .env or .env.local file:\n" . + " SPOTIFY_CLIENT_ID=your_client_id_here\n" . + " SPOTIFY_CLIENT_SECRET=your_client_secret_here\n" . + "3. Ensure config/packages/calliostro_spotify_web_api.yaml is configured:\n" . + " calliostro_spotify_web_api:\n" . + " client_id: '%env(SPOTIFY_CLIENT_ID)%'\n" . + " client_secret: '%env(SPOTIFY_CLIENT_SECRET)%'\n"; + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index c065244..c933bd7 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -1,9 +1,13 @@ set('calliostro_spotify_web_api.session', Session::class) ->public(); - // Token provider - arguments will be set by the extension - $services->set('calliostro_spotify_web_api.token_provider', TokenProvider::class); + // Token provider - session argument will be set by the extension + $services->set('calliostro_spotify_web_api.token_provider', TokenProvider::class) + ->public() + ->args([ + service('calliostro_spotify_web_api.session'), + ]); + + // Spotify client factory + $services->set('calliostro_spotify_web_api.client_factory', SpotifyClientFactory::class); + + // Main Spotify Client service - arguments will be set by the extension + $services->set('calliostro_spotify_web_api.client', SpotifyClient::class) + ->public() + ->factory([service('calliostro_spotify_web_api.client_factory'), 'createClient']); - // Main Spotify Web API service - arguments will be set by the extension - $services->set('calliostro_spotify_web_api', SpotifyWebAPI::class) + // Backward compatibility alias for main service + $services->alias('calliostro_spotify_web_api', 'calliostro_spotify_web_api.client') ->public() - ->factory([SpotifyWebApiFactory::class, 'factory']); + ->deprecate( + 'calliostro/spotify-web-api-bundle', + '1.4', + 'The "%alias_id%" service alias is deprecated, use "calliostro_spotify_web_api.client" or type-hint "Calliostro\SpotifyWebApiBundle\SpotifyClient" instead.' + ); - // Aliases for autowiring + // Autowiring aliases + $services->alias(SpotifyClient::class, 'calliostro_spotify_web_api.client'); + $services->alias(SpotifyWebAPI::class, 'calliostro_spotify_web_api.client') + ->deprecate( + 'calliostro/spotify-web-api-bundle', + '1.4', + 'The "%alias_id%" autowiring alias is deprecated, type-hint "Calliostro\SpotifyWebApiBundle\SpotifyClient" instead.' + ); $services->alias(Session::class, 'calliostro_spotify_web_api.session'); - $services->alias(SpotifyWebAPI::class, 'calliostro_spotify_web_api'); + $services->alias(TokenProviderInterface::class, 'calliostro_spotify_web_api.token_provider'); }; diff --git a/src/SpotifyClient.php b/src/SpotifyClient.php new file mode 100644 index 0000000..e0794fa --- /dev/null +++ b/src/SpotifyClient.php @@ -0,0 +1,63 @@ +|object $options + */ + public function __construct( + private readonly TokenProviderInterface $tokenProvider, + array|object $options = [], + ?Session $session = null, + ?Request $request = null, + ) { + parent::__construct($options, $session, $request); + + $this->setAccessToken($this->tokenProvider->getAccessToken()); + } + + public function getTokenProvider(): TokenProviderInterface + { + return $this->tokenProvider; + } + + /** + * {@inheritdoc} + * + * @param string|array $parameters + * @param array $headers + * + * @return array + */ + protected function sendRequest( + string $method, + string $uri, + string|array $parameters = [], + array $headers = [], + ): array { + // Pre-emptively ensure token is fresh before sending request + $this->setAccessToken($this->tokenProvider->getAccessToken()); + + try { + return parent::sendRequest($method, $uri, $parameters, $headers); + } catch (SpotifyWebAPIException $e) { + if ($e->hasExpiredToken()) { + // Force token refresh and retry request once + $this->setAccessToken($this->tokenProvider->getAccessToken(true)); + + return parent::sendRequest($method, $uri, $parameters, $headers); + } + + throw $e; + } + } +} diff --git a/src/SpotifyWebApiFactory.php b/src/SpotifyWebApiFactory.php index 4a65355..5030c84 100644 --- a/src/SpotifyWebApiFactory.php +++ b/src/SpotifyWebApiFactory.php @@ -6,16 +6,20 @@ use SpotifyWebAPI\SpotifyWebAPI; +/** + * @deprecated since calliostro/spotify-web-api-bundle 1.4, use Calliostro\SpotifyWebApiBundle\DependencyInjection\SpotifyClientFactory instead. + */ final class SpotifyWebApiFactory { /** * @param array $options + * + * @deprecated since calliostro/spotify-web-api-bundle 1.4, use Calliostro\SpotifyWebApiBundle\SpotifyClient instead. */ public static function factory(TokenProviderInterface $tokenProvider, array $options = []): SpotifyWebAPI { - $api = new SpotifyWebAPI($options); - $api->setAccessToken($tokenProvider->getAccessToken()); + trigger_deprecation('calliostro/spotify-web-api-bundle', '1.4', 'The "%s" class is deprecated, use "%s" instead.', self::class, SpotifyClient::class); - return $api; + return new SpotifyClient($tokenProvider, $options); } } diff --git a/src/TokenProvider.php b/src/TokenProvider.php index 3ca1cd3..765c92f 100644 --- a/src/TokenProvider.php +++ b/src/TokenProvider.php @@ -8,17 +8,43 @@ final class TokenProvider implements TokenProviderInterface { - private Session $session; + private ?string $cachedToken = null; + private ?int $tokenExpiresAt = null; - public function __construct(Session $session) + public function __construct( + private readonly Session $session, + private readonly int $ttl = 3300, + ) { + } + + public function getAccessToken(bool $forceRefresh = false): string { - $this->session = $session; + if ($forceRefresh || $this->cachedToken === null || $this->isExpired()) { + $this->session->requestCredentialsToken(); + $this->cachedToken = $this->session->getAccessToken(); + $this->tokenExpiresAt = time() + $this->ttl; + } + + return $this->cachedToken; } - public function getAccessToken(): string + public function getTtl(): int { - $this->session->requestCredentialsToken(); + return $this->ttl; + } - return $this->session->getAccessToken(); + public function isExpired(): bool + { + if ($this->tokenExpiresAt === null) { + return true; + } + + return time() >= $this->tokenExpiresAt; + } + + public function reset(): void + { + $this->cachedToken = null; + $this->tokenExpiresAt = null; } } diff --git a/src/TokenProviderInterface.php b/src/TokenProviderInterface.php index c172a99..12b8360 100644 --- a/src/TokenProviderInterface.php +++ b/src/TokenProviderInterface.php @@ -6,5 +6,5 @@ interface TokenProviderInterface { - public function getAccessToken(): string; + public function getAccessToken(bool $forceRefresh = false): string; } diff --git a/tests/Fixtures/TestKernel.php b/tests/Fixtures/TestKernel.php new file mode 100644 index 0000000..0e5cf37 --- /dev/null +++ b/tests/Fixtures/TestKernel.php @@ -0,0 +1,61 @@ + $calliostroSpotifyWebApiConfig + * @param array $extraBundles + */ + public function __construct( + private readonly array $calliostroSpotifyWebApiConfig = [], + string $environment = 'test', + private readonly array $extraBundles = [], + ) { + parent::__construct($environment, true); + } + + /** + * @return array + */ + public function registerBundles(): array + { + $bundles = [ + new CalliostroSpotifyWebApiBundle(), + ]; + + return array_merge($bundles, $this->extraBundles); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(function (ContainerBuilder $container) { + if (!empty($this->calliostroSpotifyWebApiConfig)) { + $container->loadFromExtension('calliostro_spotify_web_api', $this->calliostroSpotifyWebApiConfig); + } + + $container->setParameter('kernel.secret', 'test_secret'); + }); + } + + public function getCacheDir(): string + { + return $this->getProjectDir() . '/var/cache/' . + $this->environment . '/' . + md5(serialize($this->calliostroSpotifyWebApiConfig)) . '/' . + spl_object_hash($this); + } + + public function getLogDir(): string + { + return $this->getProjectDir() . '/var/log/' . $this->environment; + } +} diff --git a/tests/Integration/FunctionalTest.php b/tests/Integration/FunctionalTest.php index e1a12b9..2f64aeb 100644 --- a/tests/Integration/FunctionalTest.php +++ b/tests/Integration/FunctionalTest.php @@ -4,66 +4,54 @@ namespace Calliostro\SpotifyWebApiBundle\Tests\Integration; -use Calliostro\SpotifyWebApiBundle\CalliostroSpotifyWebApiBundle; +use Calliostro\SpotifyWebApiBundle\SpotifyClient; +use Calliostro\SpotifyWebApiBundle\Tests\Fixtures\TestKernel; use Calliostro\SpotifyWebApiBundle\TokenProviderInterface; use PHPUnit\Framework\TestCase; +use SpotifyWebAPI\Session; use SpotifyWebAPI\SpotifyWebAPI; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Kernel; final class FunctionalTest extends TestCase { public function testServiceWiring(): void { - $kernel = new CalliostroSpotifyWebApiTestingKernel([ - 'client_id' => 'some client ID', - 'client_secret' => 'some client secret', + $kernel = new FunctionalTestKernel([ + 'client_id' => 'valid_client_id_123', + 'client_secret' => 'valid_client_secret_123', 'token_provider' => 'fake_token_provider', ]); $kernel->boot(); $container = $kernel->getContainer(); - $spotifyWebApi = $container->get('calliostro_spotify_web_api'); - $this->assertInstanceOf(SpotifyWebAPI::class, $spotifyWebApi); - } -} + $clientByOldId = $container->get('calliostro_spotify_web_api'); + $this->assertInstanceOf(SpotifyClient::class, $clientByOldId); + $this->assertInstanceOf(SpotifyWebAPI::class, $clientByOldId); -class CalliostroSpotifyWebApiTestingKernel extends Kernel -{ - /** - * @param array $calliostroSpotifyWebApiConfig - */ - public function __construct(private readonly array $calliostroSpotifyWebApiConfig = []) - { - parent::__construct('test', true); - } + $clientByNewId = $container->get('calliostro_spotify_web_api.client'); + $this->assertSame($clientByOldId, $clientByNewId); - public function registerBundles(): array - { - return [ - new CalliostroSpotifyWebApiBundle(), - ]; + $session = $container->get('calliostro_spotify_web_api.session'); + $this->assertInstanceOf(Session::class, $session); } +} +class FunctionalTestKernel extends TestKernel +{ public function registerContainerConfiguration(LoaderInterface $loader): void { + parent::registerContainerConfiguration($loader); + $loader->load(function (ContainerBuilder $container) { $container->register('fake_token_provider', FakeTokenProvider::class); - - $container->loadFromExtension('calliostro_spotify_web_api', $this->calliostroSpotifyWebApiConfig); }); } - - public function getCacheDir(): string - { - return $this->getProjectDir() . '/var/cache/' . $this->environment . '/' . spl_object_hash($this); - } } class FakeTokenProvider implements TokenProviderInterface { - public function getAccessToken(): string + public function getAccessToken(bool $forceRefresh = false): string { return 'some access token'; } diff --git a/tests/Unit/DependencyInjection/CalliostroSpotifyWebApiExtensionTest.php b/tests/Unit/DependencyInjection/CalliostroSpotifyWebApiExtensionTest.php index 260ae1b..f5a8123 100644 --- a/tests/Unit/DependencyInjection/CalliostroSpotifyWebApiExtensionTest.php +++ b/tests/Unit/DependencyInjection/CalliostroSpotifyWebApiExtensionTest.php @@ -5,6 +5,8 @@ namespace Calliostro\SpotifyWebApiBundle\Tests\Unit\DependencyInjection; use Calliostro\SpotifyWebApiBundle\DependencyInjection\CalliostroSpotifyWebApiExtension; +use Calliostro\SpotifyWebApiBundle\SpotifyClient; +use Calliostro\SpotifyWebApiBundle\TokenProviderInterface; use PHPUnit\Framework\TestCase; use SpotifyWebAPI\Session; use SpotifyWebAPI\SpotifyWebAPI; @@ -22,10 +24,14 @@ public function testLoadWithDefaults(): void $this->assertTrue($container->hasDefinition('calliostro_spotify_web_api.session')); $this->assertTrue($container->hasDefinition('calliostro_spotify_web_api.token_provider')); - $this->assertTrue($container->hasDefinition('calliostro_spotify_web_api')); + $this->assertTrue($container->hasDefinition('calliostro_spotify_web_api.client_factory')); + $this->assertTrue($container->hasDefinition('calliostro_spotify_web_api.client')); $this->assertTrue($container->hasAlias(Session::class)); + $this->assertTrue($container->hasAlias(SpotifyClient::class)); $this->assertTrue($container->hasAlias(SpotifyWebAPI::class)); + $this->assertTrue($container->hasAlias(TokenProviderInterface::class)); + $this->assertTrue($container->hasAlias('calliostro_spotify_web_api')); $sessionDef = $container->getDefinition('calliostro_spotify_web_api.session'); $this->assertSame(['', '', ''], $sessionDef->getArguments()); @@ -33,15 +39,19 @@ public function testLoadWithDefaults(): void $tokenProviderDef = $container->getDefinition('calliostro_spotify_web_api.token_provider'); $this->assertEquals([new Reference('calliostro_spotify_web_api.session')], $tokenProviderDef->getArguments()); - $apiDef = $container->getDefinition('calliostro_spotify_web_api'); + $clientDef = $container->getDefinition('calliostro_spotify_web_api.client'); $this->assertEquals([ + '', + '', + '', new Reference('calliostro_spotify_web_api.token_provider'), [ 'auto_refresh' => false, 'auto_retry' => false, 'return_assoc' => false, ], - ], $apiDef->getArguments()); + new Reference('calliostro_spotify_web_api.session'), + ], $clientDef->getArguments()); } public function testLoadWithCustomConfig(): void @@ -51,8 +61,8 @@ public function testLoadWithCustomConfig(): void $configs = [ [ - 'client_id' => 'custom_id', - 'client_secret' => 'custom_secret', + 'client_id' => 'custom_id_12345', + 'client_secret' => 'custom_secret_12345', 'redirect_uri' => 'https://example.com/callback', 'token_provider' => 'my_custom_provider', 'options' => [ @@ -66,16 +76,20 @@ public function testLoadWithCustomConfig(): void $extension->load($configs, $container); $sessionDef = $container->getDefinition('calliostro_spotify_web_api.session'); - $this->assertSame(['custom_id', 'custom_secret', 'https://example.com/callback'], $sessionDef->getArguments()); + $this->assertSame(['custom_id_12345', 'custom_secret_12345', 'https://example.com/callback'], $sessionDef->getArguments()); - $apiDef = $container->getDefinition('calliostro_spotify_web_api'); + $clientDef = $container->getDefinition('calliostro_spotify_web_api.client'); $this->assertEquals([ + 'custom_id_12345', + 'custom_secret_12345', + 'https://example.com/callback', new Reference('my_custom_provider'), [ 'auto_refresh' => true, 'auto_retry' => true, 'return_assoc' => true, ], - ], $apiDef->getArguments()); + new Reference('calliostro_spotify_web_api.session'), + ], $clientDef->getArguments()); } } diff --git a/tests/Unit/DependencyInjection/SpotifyClientFactoryTest.php b/tests/Unit/DependencyInjection/SpotifyClientFactoryTest.php new file mode 100644 index 0000000..64a0633 --- /dev/null +++ b/tests/Unit/DependencyInjection/SpotifyClientFactoryTest.php @@ -0,0 +1,88 @@ +factory = new SpotifyClientFactory(); + $this->tokenProvider = $this->createMock(TokenProviderInterface::class); + $this->tokenProvider->method('getAccessToken')->willReturn('valid_token'); + } + + public function testCreateClientWithValidCredentials(): void + { + $client = $this->factory->createClient( + 'valid_client_id_123', + 'valid_client_secret_123', + 'https://example.com/callback', + $this->tokenProvider, + ['return_assoc' => true] + ); + + $this->assertInstanceOf(SpotifyClient::class, $client); + } + + public function testCreateClientWithMissingClientId(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing Spotify API credentials'); + + $this->factory->createClient( + '', + 'valid_client_secret_123', + null, + $this->tokenProvider + ); + } + + public function testCreateClientWithMissingClientSecret(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing Spotify API credentials'); + + $this->factory->createClient( + 'valid_client_id_123', + '', + null, + $this->tokenProvider + ); + } + + public function testCreateClientWithShortClientId(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Spotify client_id appears invalid'); + + $this->factory->createClient( + 'short', + 'valid_client_secret_123', + null, + $this->tokenProvider + ); + } + + public function testCreateClientWithShortClientSecret(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Spotify client_secret appears invalid'); + + $this->factory->createClient( + 'valid_client_id_123', + 'short', + null, + $this->tokenProvider + ); + } +} diff --git a/tests/Unit/SpotifyClientTest.php b/tests/Unit/SpotifyClientTest.php new file mode 100644 index 0000000..5e5fb2a --- /dev/null +++ b/tests/Unit/SpotifyClientTest.php @@ -0,0 +1,124 @@ +createMock(TokenProviderInterface::class); + $tokenProvider->expects($this->once()) + ->method('getAccessToken') + ->willReturn('initial_token'); + + $client = new SpotifyClient($tokenProvider); + + $this->assertInstanceOf(SpotifyWebAPI::class, $client); + $this->assertSame($tokenProvider, $client->getTokenProvider()); + + $reflection = new \ReflectionClass($client); + $tokenProp = $reflection->getProperty('accessToken'); + $tokenProp->setAccessible(true); + $this->assertSame('initial_token', $tokenProp->getValue($client)); + } + + public function testSendRequestPreemptivelyRefreshesToken(): void + { + $tokenProvider = $this->createMock(TokenProviderInterface::class); + $tokenProvider->expects($this->exactly(2)) + ->method('getAccessToken') + ->with(false) + ->willReturnOnConsecutiveCalls('token_v1', 'token_v2'); + + $request = $this->createMock(Request::class); + $request->expects($this->once()) + ->method('api') + ->with( + 'GET', + '/v1/me', + [], + $this->callback(function (array $headers) { + return isset($headers['Authorization']) && $headers['Authorization'] === 'Bearer token_v2'; + }) + ) + ->willReturn([ + 'body' => (object) ['id' => 'user123'], + 'headers' => [], + 'status' => 200, + 'url' => 'https://api.spotify.com/v1/me', + ]); + + $client = new SpotifyClient($tokenProvider, [], null, $request); + $result = $client->me(); + + $this->assertSame('user123', $result->id); + } + + public function testSendRequestRetriesOnExpiredTokenException(): void + { + $tokenProvider = $this->createMock(TokenProviderInterface::class); + $tokenProvider->expects($this->exactly(3)) + ->method('getAccessToken') + ->willReturnCallback(function (bool $forceRefresh = false) { + static $calls = 0; + $calls++; + + if ($calls === 1) { + return 'expired_token'; // on constructor + } + if ($calls === 2) { + return 'expired_token'; // pre-emptive check + } + + // $calls === 3: forceRefresh is true + return 'fresh_token'; + }); + + $request = $this->createMock(Request::class); + $request->expects($this->exactly(2)) + ->method('api') + ->willReturnOnConsecutiveCalls( + $this->throwException(new SpotifyWebAPIException(SpotifyWebAPIException::TOKEN_EXPIRED)), + [ + 'body' => (object) ['id' => 'recovered_user'], + 'headers' => [], + 'status' => 200, + 'url' => 'https://api.spotify.com/v1/me', + ] + ); + + $client = new SpotifyClient($tokenProvider, [], null, $request); + $result = $client->me(); + + $this->assertSame('recovered_user', $result->id); + } + + public function testSendRequestRethrowsNonExpiredTokenException(): void + { + $tokenProvider = $this->createMock(TokenProviderInterface::class); + $tokenProvider->expects($this->exactly(2)) + ->method('getAccessToken') + ->willReturn('valid_token'); + + $request = $this->createMock(Request::class); + $request->expects($this->once()) + ->method('api') + ->willThrowException(new SpotifyWebAPIException('Some random API error', 500)); + + $client = new SpotifyClient($tokenProvider, [], null, $request); + + $this->expectException(SpotifyWebAPIException::class); + $this->expectExceptionMessage('Some random API error'); + + $client->me(); + } +} diff --git a/tests/Unit/SpotifyWebApiFactoryTest.php b/tests/Unit/SpotifyWebApiFactoryTest.php index c0be07a..8580785 100644 --- a/tests/Unit/SpotifyWebApiFactoryTest.php +++ b/tests/Unit/SpotifyWebApiFactoryTest.php @@ -4,6 +4,7 @@ namespace Calliostro\SpotifyWebApiBundle\Tests\Unit; +use Calliostro\SpotifyWebApiBundle\SpotifyClient; use Calliostro\SpotifyWebApiBundle\SpotifyWebApiFactory; use Calliostro\SpotifyWebApiBundle\TokenProviderInterface; use PHPUnit\Framework\TestCase; @@ -26,6 +27,7 @@ public function testFactoryCreatesClientWithTokenAndOptions(): void $client = SpotifyWebApiFactory::factory($tokenProvider, $options); $this->assertInstanceOf(SpotifyWebAPI::class, $client); + $this->assertInstanceOf(SpotifyClient::class, $client); $reflection = new \ReflectionClass($client); $accessTokenProp = $reflection->getProperty('accessToken'); diff --git a/tests/Unit/TokenProviderTest.php b/tests/Unit/TokenProviderTest.php index d2ee7ce..6062661 100644 --- a/tests/Unit/TokenProviderTest.php +++ b/tests/Unit/TokenProviderTest.php @@ -11,7 +11,7 @@ final class TokenProviderTest extends TestCase { - public function testGetAccessToken(): void + public function testGetAccessTokenCachesInMemory(): void { $session = $this->createMock(Session::class); $session->expects($this->once()) @@ -20,9 +20,70 @@ public function testGetAccessToken(): void ->method('getAccessToken') ->willReturn('mock_access_token'); - $tokenProvider = new TokenProvider($session); + $tokenProvider = new TokenProvider($session, 3300); $this->assertInstanceOf(TokenProviderInterface::class, $tokenProvider); + $this->assertSame(3300, $tokenProvider->getTtl()); + + // First call should request token + $this->assertSame('mock_access_token', $tokenProvider->getAccessToken()); + $this->assertFalse($tokenProvider->isExpired()); + + // Second call should return cached token without calling session again $this->assertSame('mock_access_token', $tokenProvider->getAccessToken()); } + + public function testForceRefreshBypassesCache(): void + { + $session = $this->createMock(Session::class); + $session->expects($this->exactly(2)) + ->method('requestCredentialsToken'); + $session->expects($this->exactly(2)) + ->method('getAccessToken') + ->willReturnOnConsecutiveCalls('token_1', 'token_2'); + + $tokenProvider = new TokenProvider($session, 3300); + + $this->assertSame('token_1', $tokenProvider->getAccessToken(false)); + $this->assertSame('token_2', $tokenProvider->getAccessToken(true)); + } + + public function testTokenRefreshesWhenExpired(): void + { + $session = $this->createMock(Session::class); + $session->expects($this->exactly(2)) + ->method('requestCredentialsToken'); + $session->expects($this->exactly(2)) + ->method('getAccessToken') + ->willReturnOnConsecutiveCalls('token_initial', 'token_after_expiry'); + + // Set TTL to 0 so it immediately expires + $tokenProvider = new TokenProvider($session, -1); + + $this->assertSame('token_initial', $tokenProvider->getAccessToken()); + $this->assertTrue($tokenProvider->isExpired()); + + $this->assertSame('token_after_expiry', $tokenProvider->getAccessToken()); + } + + public function testResetClearsCache(): void + { + $session = $this->createMock(Session::class); + $session->expects($this->exactly(2)) + ->method('requestCredentialsToken'); + $session->expects($this->exactly(2)) + ->method('getAccessToken') + ->willReturnOnConsecutiveCalls('token_1', 'token_2'); + + $tokenProvider = new TokenProvider($session); + + $this->assertTrue($tokenProvider->isExpired()); + $this->assertSame('token_1', $tokenProvider->getAccessToken()); + $this->assertFalse($tokenProvider->isExpired()); + + $tokenProvider->reset(); + $this->assertTrue($tokenProvider->isExpired()); + + $this->assertSame('token_2', $tokenProvider->getAccessToken()); + } }