diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..97a8bb6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,187 @@ +name: CI/CD + +on: + push: + branches: + - master + pull_request: + branches: + - master + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (PHP ${{ matrix.php-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: pcov + tools: composer:v2 + extensions: mbstring, openssl, simplexml, json + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: composer-${{ matrix.php-version }}-${{ hashFiles('composer.json') }} + restore-keys: composer-${{ matrix.php-version }}- + + # `composer update` (not install) so each matrix entry resolves deps + # compatible with its PHP version — the lockfile pins packages that + # require PHP 8.2+ and would otherwise fail on 7.4. + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist --with-all-dependencies + + - name: Validate composer.json + run: composer validate --strict --no-check-lock + + - name: Run PHPUnit with coverage + run: ./vendor/bin/phpunit --coverage-clover coverage.xml --coverage-text + + - name: Enforce >= 80% line coverage + run: | + php -r ' + if (!file_exists("coverage.xml")) { + fwrite(STDERR, "coverage.xml not produced\n"); exit(1); + } + $xml = new SimpleXMLElement(file_get_contents("coverage.xml")); + $m = $xml->project->metrics; + $stmts = (int) $m["statements"]; + $covered = (int) $m["coveredstatements"]; + $pct = $stmts > 0 ? ($covered / $stmts) * 100 : 0.0; + printf("Line coverage: %.2f%% (%d / %d)\n", $pct, $covered, $stmts); + if ($pct < 80.0) { + fwrite(STDERR, "FAIL: coverage is below the 80% threshold.\n"); + exit(1); + } + ' + + # Only the canonical PHP version uploads the report for the deploy job + # to consume — and only on master pushes where deploy will actually run. + - name: Upload coverage report + if: matrix.php-version == '8.3' && github.ref == 'refs/heads/master' && github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: coverage-clover + path: coverage.xml + retention-days: 7 + + deploy: + name: Deploy (tag + badges) + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + permissions: + contents: write + steps: + - name: Checkout master with full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + extensions: simplexml, json + + - name: Read library version + id: version + run: | + VERSION=$(php -r 'require "src/Version.php"; echo Dev1\\NotifyCore\\Version::VERSION;') + if [ -z "$VERSION" ]; then + echo "::error::Unable to read Dev1\\NotifyCore\\Version::VERSION" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved version: $VERSION" + + - name: Check whether tag already exists + id: tag_check + run: | + if git rev-parse --verify "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag ${{ steps.version.outputs.tag }} already exists — skipping release." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push version tag + if: steps.tag_check.outputs.exists == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + echo "Pushed ${{ steps.version.outputs.tag }} — Packagist webhook will publish automatically." + + - name: Download coverage report + uses: actions/download-artifact@v4 + with: + name: coverage-clover + path: . + + - name: Compute coverage percentage + id: cov + run: | + PCT=$(php -r ' + $xml = new SimpleXMLElement(file_get_contents("coverage.xml")); + $m = $xml->project->metrics; + $s = (int) $m["statements"]; + $c = (int) $m["coveredstatements"]; + printf("%.1f", $s > 0 ? ($c / $s) * 100 : 0.0); + ') + echo "pct=$PCT" >> "$GITHUB_OUTPUT" + echo "Coverage: $PCT%" + + - name: Build shields.io endpoint JSON + run: | + PCT='${{ steps.cov.outputs.pct }}' + COLOR=$(awk -v p="$PCT" 'BEGIN { + if (p+0 >= 90) print "brightgreen"; + else if (p+0 >= 80) print "green"; + else if (p+0 >= 70) print "yellow"; + else print "red"; + }') + mkdir -p badges-out + printf '{"schemaVersion":1,"label":"coverage","message":"%s%%","color":"%s"}\n' \ + "$PCT" "$COLOR" > badges-out/coverage.json + cat badges-out/coverage.json + + - name: Publish coverage badge to `badges` branch + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./badges-out + publish_branch: badges + keep_files: true + commit_message: "chore(badges): refresh coverage (${{ steps.cov.outputs.pct }}%)" + + - name: Job summary + run: | + { + echo "### Deploy summary" + echo "" + echo "- **Version:** \`${{ steps.version.outputs.version }}\`" + if [ "${{ steps.tag_check.outputs.exists }}" = "false" ]; then + echo "- **Tag published:** \`${{ steps.version.outputs.tag }}\` (Packagist will pick it up via webhook)" + else + echo "- **Tag published:** skipped — \`${{ steps.version.outputs.tag }}\` already exists" + fi + echo "- **Coverage:** \`${{ steps.cov.outputs.pct }}%\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..6e76343 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":{"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTransportExceptionReturnsTransportErrorResult":4},"times":{"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testSuccessDefaults":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testFailurePropagatesErrorFields":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testRejectsEmptyTarget":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testStoresTokenAsString":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testStoresTopic":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testStoresCondition":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testConstructorRequiresProjectId":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testEndpointInterpolatesProjectId":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testSuccessfulSendReturnsId":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testFailureMapsFcmErrorStatus":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testDataOnlyPushOmitsNotificationBlock":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testDataValuesAreStringified":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTargetPrefersToken":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testAndroidOptionsAreSerialized":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTransportExceptionReturnsTransportErrorResult":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testFirstRegisteredBecomesDefault":0.001,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testAsDefaultSwitchesDefault":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testUnknownClientThrows":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testRemoveClearsDefault":0,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testRequiresEmailAndKey":0.008,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testSuccessfulTokenIsCachedInMemory":0.004,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testPsr16CacheIsPopulatedAndReused":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testRetriesOn5xxThenSucceeds":0.002,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testRetriesOnTransportExceptionThenSucceeds":0.002,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testExhaustedRetriesThrowTransient":0.003,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testNon5xxErrorDoesNotRetry":0.001,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testUnregisteredHelper":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testInvalidArgumentHelper":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testQuotaExceededHelper":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testTransientHelper":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testRejectsAmbiguousTarget":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testRejectsAllThreeSet":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testRetriesOn503ThenSucceeds":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testRetriesOnTransportException":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testRetriesOn429":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testExhaustedRetriesReturnsTransientResult":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testClientErrorDoesNotRetry":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testFcmErrorCodeDetailOverridesStatus":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testPushResultTransientHelper":0.001,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testPriorityNormalizes":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testPriorityRejectsUnknownValue":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testChannelIdEmittedExactlyOnce":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testTtlIsSecondsString":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testRemoveUnknownReturnsFalse":0,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testMalformedCacheEntryIsIgnored":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testCacheReadFailureFallsBackToNetwork":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testCacheWriteFailureIsSwallowed":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testScopeArrayIsSpaceJoinedInJwt":0.002,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testHttp200WithoutAccessTokenThrows":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testInvalidPrivateKeyThrowsAtSignTime":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTopicTargetSerializesAsTopic":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testConditionTargetSerializesAsCondition":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testApnsPlatformOptionsAreSerialized":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testRawArrayOverridesArePassedThroughUntouched":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTokenAcquisitionFailureReturnsTokenErrorResult":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testNonJsonErrorBodyFallsBackToHttpStatusCode":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testCustomEndpointInterpolatesProjectId":0,"Dev1\\NotifyCore\\Tests\\Factory\\FcmClientFactoryTest::testCreateReturnsFcmHttpV1Client":0,"Dev1\\NotifyCore\\Tests\\Factory\\FcmClientFactoryTest::testCreatePropagatesConfigValidation":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testNegativeTtlClampsToZero":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testWithNotificationMergesIntoNotificationMap":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testChannelIdCoexistsWithNotificationMap":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testWithDataReplacesPreviousData":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testWithExtraMergesIntoOutputRoot":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testMergePrefersOtherForScalarsAndMergesMaps":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testMergeOtherOverridesChannelAndTtl":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testEmptyOptionsProduceEmptyArray":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testHeadersOnlyEmitsHeadersKey":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testApsIsNestedUnderBody":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testCustomPayloadMergesAtBodyRoot":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testWithHeadersMergesRecursively":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testMergePrefersOtherForScalarsAndMergesMaps":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testCustomWithoutApsStillProducesBody":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testSetDefaultSwitchesDefault":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testSetDefaultUnknownThrows":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testNamesListsRegisteredClients":0}} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a9c6b..70989a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.2] - 2026-04-18 +### Added +- Optional PSR-16 token cache on `GoogleServiceAccountTokenProvider` (new `?CacheInterface $cache` constructor arg + `cache_key` config) so OAuth tokens can be shared across processes/requests. +- Automatic retry with exponential backoff + jitter on transient failures (5xx, 429, PSR-18 transport errors) in both the token provider and `FcmHttpV1Client`. Configurable via `max_retries` and `retry_base_delay_ms`. +- `FcmHttpV1Client` now extracts `error.details[*].errorCode` (e.g. `UNREGISTERED`, `QUOTA_EXCEEDED`) in preference to the generic `error.status`. +- `PushResult` helpers: `isUnregistered()`, `isInvalidArgument()`, `isQuotaExceeded()`, `isTransient()`. +- Data-only (silent) push support: `notification` block is omitted when `PushMessage` has empty title and body. +- `Dev1\NotifyCore\Version` constants + automatic `User-Agent: Dev1-Notify-Core/` header on every FCM and OAuth request. +- PHPUnit configuration (`phpunit.xml.dist`) and initial test suite covering DTOs, registry, FCM driver, token provider, retry behavior, and Android options. +- GitHub Actions CI/CD pipeline (`.github/workflows/ci.yml`): matrix tests on PHP 7.4–8.4 with pcov, enforces ≥80% line coverage on every push/PR, and on merges to `master` auto-publishes the `v` tag (consumed by the Packagist webhook) and refreshes the shields.io coverage badge on the `badges` branch. + +### Changed +- **Breaking:** `PushTarget` now throws `InvalidArgumentException` when more than one of `token`/`topic`/`condition` is provided (previously the driver silently preferred `token`). +- **Breaking:** `AndroidOptions::withPriority()` throws on unknown values instead of silently passing them through. +- `FcmHttpV1Client` no longer catches generic `\Throwable`; only PSR-18 `ClientExceptionInterface` is caught and surfaced as `TRANSPORT_ERROR`. +- Token acquisition errors now return a `PushResult` with `errorCode = TOKEN_ERROR` instead of crashing the send. +- `AndroidOptions::toArray()` emits `channel_id` exactly once. +- Missing return types added to `AndroidOptions::withChannelId()`, `AndroidOptions::merge()`, and `ApnsOptions::merge()`. +- `PushResult::$id` PHPDoc corrected to `string|null`. +- `ClientRegistry::remove()` now returns `bool` (true if a client was removed, false if unknown) while preserving idempotent semantics. +- `AccessTokenProvider::getToken()` PHPDoc now documents the `@throws` contract for failed acquisitions. +- Widened `psr/log` constraint to `^1.1 || ^2.0 || ^3.0` for compatibility with modern logger implementations. + +### Fixed +- `FcmClientFactory::create` no longer uses a PHP 8-only trailing comma; library parses cleanly on PHP 7.4 again. +- `json_encode` failures in `buildAssertionJwt()` and the FCM payload are now surfaced explicitly instead of signing/sending the literal `false`. +- Non-scalar values in `PushMessage::$data` whose `json_encode` fails fall back to an empty string instead of the literal `"false"`. +- Dropped the deprecated `openssl_pkey_free()` call (no-op on PHP 8.x, GC handles both 7.4 and 8.x). +- Removed an unreachable `return ''` in `GoogleServiceAccountTokenProvider::getToken()`. + +### Removed +- Orphan `Dev1\NotifyCore\Message` and `Dev1\NotifyCore\Builders\MessageBuilder` classes (never wired into the driver). +- Undocumented `timeout` config key on `FcmHttpV1Client` (it was never applied). + +--- + ## [Unreleased] - Support for additional providers (Twilio Notify, OneSignal, APNs). -- Adapters for Laravel and Symfony. -- Unit tests and CI/CD pipeline. +- Adapters for Symfony. diff --git a/README.md b/README.md index 3710d1a..2c9ddff 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,10 @@ Driver-agnostic notifications core for PHP (starting with Firebase Cloud Messaging HTTP v1). Built in Mexico by **DEV1 Softworks Labs** -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Version: 1.1](https://img.shields.io/badge/version-1.1-green.svg)](#) +[![CI/CD](https://github.com/DEV1-Softworks/notify-core/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/DEV1-Softworks/notify-core/actions/workflows/ci.yml) +[![Packagist Version](https://img.shields.io/packagist/v/dev1/notify-core.svg)](https://packagist.org/packages/dev1/notify-core) +[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/DEV1-Softworks/notify-core/badges/coverage.json)](https://github.com/DEV1-Softworks/notify-core/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) --- @@ -103,8 +106,10 @@ var_dump($result); - [ ] Add more drivers (Twilio Notify, OneSignal, APNs) - [x] Laravel adapter (`dev1/laravel-notify`) - [ ] Symfony bundle (`dev1/symfony-notify-bundle`) -- [ ] Unit tests & CI +- [x] Unit tests +- [x] CI pipeline - [x] Advanced platform overrides (Android/APNs) +- [x] Optional PSR-16 token cache + automatic retries on transient failures --- diff --git a/composer.json b/composer.json index e5108f8..ed712c4 100644 --- a/composer.json +++ b/composer.json @@ -7,18 +7,30 @@ "php": ">=7.4 <8.5", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", - "psr/log": "^1.1" + "psr/log": "^1.1 || ^2.0 || ^3.0" }, "require-dev": { "nyholm/psr7": "^1.8", "symfony/http-client": "^5.4 || ^6.0 || ^7.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "psr/simple-cache": "^1.0" + }, + "suggest": { + "psr/simple-cache": "Share Google OAuth tokens across requests (PSR-16)." }, "autoload": { "psr-4": { "Dev1\\NotifyCore\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Dev1\\NotifyCore\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + }, "authors": [ { "name": "DEV1 Softworks Labs", diff --git a/composer.lock b/composer.lock index 7700e74..ede0aa0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2d9b6578e5deacda5e42cce7de1518b6", + "content-hash": "e4d9a05413069fbeea29c2d2cd7f3dcb", "packages": [ { "name": "psr/http-client", @@ -168,30 +168,30 @@ }, { "name": "psr/log", - "version": "1.1.4", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -212,9 +212,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2021-05-03T11:20:27+00:00" + "time": "2024-09-11T13:17:53+00:00" } ], "packages-dev": [ @@ -350,16 +350,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -402,9 +402,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "nyholm/psr7", @@ -923,16 +923,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.27", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0a9aa4440b6a9528cf360071502628d717af3e0a" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0a9aa4440b6a9528cf360071502628d717af3e0a", - "reference": "0a9aa4440b6a9528cf360071502628d717af3e0a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { @@ -954,10 +954,10 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.9", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", + "sebastian/exporter": "^4.0.8", "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", @@ -1006,7 +1006,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.27" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { @@ -1030,7 +1030,7 @@ "type": "tidelift" } ], - "time": "2025-09-14T06:18:03+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "psr/container", @@ -1085,6 +1085,57 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/simple-cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/master" + }, + "time": "2017-10-23T01:57:42+00:00" + }, { "name": "sebastian/cli-parser", "version": "1.0.2", @@ -1254,16 +1305,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.9", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -1316,7 +1367,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { @@ -1336,7 +1387,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:51:50+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -1526,16 +1577,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -1591,15 +1642,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", @@ -2153,16 +2216,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.3", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019" + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", - "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", "shasum": "" }, "require": { @@ -2193,12 +2256,13 @@ "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2229,7 +2293,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.3" + "source": "https://github.com/symfony/http-client/tree/v7.4.8" }, "funding": [ { @@ -2249,7 +2313,7 @@ "type": "tidelift" } ], - "time": "2025-08-27T07:45:05+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -2331,16 +2395,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -2387,7 +2451,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" }, "funding": [ { @@ -2407,20 +2471,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -2474,7 +2538,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -2485,25 +2549,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -2532,7 +2600,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -2540,7 +2608,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], @@ -2552,5 +2620,5 @@ "php": ">=7.4 <8.5" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..dd769dd --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + tests + + + + + src + + + diff --git a/src/Auth/GoogleServiceAccountTokenProvider.php b/src/Auth/GoogleServiceAccountTokenProvider.php index 72b79d0..6707b54 100644 --- a/src/Auth/GoogleServiceAccountTokenProvider.php +++ b/src/Auth/GoogleServiceAccountTokenProvider.php @@ -3,22 +3,28 @@ namespace Dev1\NotifyCore\Auth; use Dev1\NotifyCore\Contracts\AccessTokenProvider; +use Dev1\NotifyCore\Version; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface as HttpClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; +use Psr\SimpleCache\CacheInterface; /** * Generates an OAuth 2.0 access token (JWT -> token) for FCM HTTP v1 * using a Google Service Account. - * + * * Expected configurations: * - client_email (string, required) * - private_key (string, required, BEGIN_PRIVATE_KEY ... END_PRIVATE_KEY) * - token_uri (string, optional) default: https://oauth2.googleapis.com/token * - scope (string|string[], optional) default: https://www.googleapis.com/auth/firebase.messaging - * - cache_leeway (int, optional) time remaining for renewal (default 30) + * - cache_leeway (int, optional) seconds of margin before considered expired (default 30) + * - cache_key (string, optional) override for PSR-16 cache key (default derived from client_email) + * - max_retries (int, optional) retry attempts on transient 5xx / transport failures (default 2) + * - retry_base_delay_ms (int, optional) initial backoff in ms; doubles each attempt (default 200) */ class GoogleServiceAccountTokenProvider implements AccessTokenProvider { @@ -34,6 +40,9 @@ class GoogleServiceAccountTokenProvider implements AccessTokenProvider /** @var LoggerInterface|null */ private $logger; + /** @var CacheInterface|null */ + private $cache; + /** @var array */ private $config; @@ -51,7 +60,8 @@ public function __construct( RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, ?LoggerInterface $logger = null, - array $config = [] + array $config = [], + ?CacheInterface $cache = null ) { if (empty($config['client_email']) || empty($config['private_key'])) { throw new \InvalidArgumentException('GoogleServiceAccountTokenProvider requires client_email and private_key'); @@ -61,13 +71,21 @@ public function __construct( $this->requestFactory = $requestFactory; $this->streamFactory = $streamFactory; $this->logger = $logger; + $this->cache = $cache; + + $clientEmail = (string) $config['client_email']; $this->config = [ - 'client_email' => (string) $config['client_email'], + 'client_email' => $clientEmail, 'private_key' => (string) $config['private_key'], 'token_uri' => isset($config['token_uri']) ? (string) $config['token_uri'] : 'https://oauth2.googleapis.com/token', 'scope' => isset($config['scope']) ? $config['scope'] : 'https://www.googleapis.com/auth/firebase.messaging', 'cache_leeway' => isset($config['cache_leeway']) ? (int) $config['cache_leeway'] : 30, + 'cache_key' => isset($config['cache_key']) + ? (string) $config['cache_key'] + : 'dev1_notify_core.google_oauth.' . sha1($clientEmail), + 'max_retries' => isset($config['max_retries']) ? max(0, (int) $config['max_retries']) : 2, + 'retry_base_delay_ms' => isset($config['retry_base_delay_ms']) ? max(0, (int) $config['retry_base_delay_ms']) : 200, ]; $this->cachedToken = null; @@ -82,16 +100,78 @@ public function __construct( public function getToken(): string { $now = time(); + $leeway = (int) $this->config['cache_leeway']; + + if ($this->cachedToken !== null && $this->expiresAt !== null && $now < ($this->expiresAt - $leeway)) { + return $this->cachedToken; + } + + $cached = $this->loadFromCache($now, $leeway); + if ($cached !== null) { + return $cached; + } + + $token = $this->requestNewTokenWithRetry($now); + return $token; + } + + /** + * @return string|null Cached token if still valid, null otherwise. + */ + private function loadFromCache(int $now, int $leeway): ?string + { + if ($this->cache === null) { + return null; + } + + try { + $entry = $this->cache->get($this->config['cache_key']); + } catch (\Throwable $e) { + if ($this->logger) { + $this->logger->warning('Token cache read failed', ['exception' => $e]); + } + return null; + } - if ($this->cachedToken !== null && $this->expiresAt !== null) { - $leeway = (int) $this->config['cache_leeway']; + if (!is_array($entry) || !isset($entry['token'], $entry['expires_at'])) { + return null; + } + + $token = (string) $entry['token']; + $expiresAt = (int) $entry['expires_at']; + + if ($token === '' || $now >= ($expiresAt - $leeway)) { + return null; + } - if ($now < ($this->expiresAt - $leeway)) { - return $this->cachedToken; + $this->cachedToken = $token; + $this->expiresAt = $expiresAt; + return $token; + } + + private function requestNewTokenWithRetry(int $now): string + { + $maxRetries = (int) $this->config['max_retries']; + $baseDelayMs = (int) $this->config['retry_base_delay_ms']; + + $lastException = null; + for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { + try { + return $this->requestNewToken($now); + } catch (TransientAuthException $e) { + $lastException = $e; + if ($attempt === $maxRetries) { + break; + } + $this->sleepBackoff($baseDelayMs, $attempt); } } - // Build JWT + throw $lastException ?: new \RuntimeException('OAuth token request failed'); + } + + private function requestNewToken(int $now): string + { $jwt = $this->buildAssertionJwt($now); $form = http_build_query([ @@ -101,52 +181,93 @@ public function getToken(): string $request = $this->requestFactory ->createRequest('POST', $this->config['token_uri']) - ->withHeader('Content-Type', 'application/x-www-form-urlencoded'); - - $request = $request->withBody($this->streamFactory->createStream($form)); + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withHeader('User-Agent', Version::USER_AGENT) + ->withBody($this->streamFactory->createStream($form)); try { $response = $this->http->sendRequest($request); - $status = $response->getStatusCode(); - $body = (string) $response->getBody(); - $decoded = json_decode($body, true); - - if ($status >= 200 && $status < 300 && is_array($decoded) && isset($decoded['access_token'])) { - $token = (string) $decoded['access_token']; - $expiresIn = isset($decoded['expires_in']) ? (int) $decoded['expires_in'] : 3500; - $this->cachedToken = $token; - $this->expiresAt = $now + $expiresIn; - - if ($this->logger) { - $this->logger->info('Obtained Google OAuth access token', ['expires_in' => $expiresIn]); - } - - return $token; + } catch (ClientExceptionInterface $e) { + if ($this->logger) { + $this->logger->warning('Google OAuth transport error', ['exception' => $e]); } + throw new TransientAuthException('OAuth transport error: ' . $e->getMessage(), 0, $e); + } + + $status = $response->getStatusCode(); + $body = (string) $response->getBody(); + $decoded = json_decode($body, true); - $msg = 'OAuth token error'; - $err = is_array($decoded) && isset($decoded['error']) ? $decoded['error'] : null; - $desc = is_array($decoded) && isset($decoded['error_description']) ? $decoded['error_description'] : null; + if ($status >= 200 && $status < 300 && is_array($decoded) && isset($decoded['access_token'])) { + $token = (string) $decoded['access_token']; + $expiresIn = isset($decoded['expires_in']) ? (int) $decoded['expires_in'] : 3500; + $expiresAt = $now + $expiresIn; + + $this->cachedToken = $token; + $this->expiresAt = $expiresAt; + $this->saveToCache($token, $expiresAt); if ($this->logger) { - $this->logger->warning('Google OAuth token request failed', [ - 'status' => $status, - 'error' => $err, - 'error_description' => $desc, - 'body' => $body, - ]); + $this->logger->info('Obtained Google OAuth access token', ['expires_in' => $expiresIn]); } - throw new \RuntimeException($msg . ' (HTTP ' . $status . '): ' . ($desc ?: $body)); + return $token; + } + + $err = is_array($decoded) && isset($decoded['error']) ? $decoded['error'] : null; + $desc = is_array($decoded) && isset($decoded['error_description']) ? $decoded['error_description'] : null; + + if ($this->logger) { + $this->logger->warning('Google OAuth token request failed', [ + 'status' => $status, + 'error' => $err, + 'error_description' => $desc, + 'body' => $body, + ]); + } + + $msg = 'OAuth token error (HTTP ' . $status . '): ' . ($desc ?: $body); + + if ($status >= 500 || $status === 429) { + throw new TransientAuthException($msg); + } + + throw new \RuntimeException($msg); + } + + private function saveToCache(string $token, int $expiresAt): void + { + if ($this->cache === null) { + return; + } + + $ttl = $expiresAt - time(); + if ($ttl <= 0) { + return; + } + + try { + $this->cache->set( + $this->config['cache_key'], + ['token' => $token, 'expires_at' => $expiresAt], + $ttl + ); } catch (\Throwable $e) { if ($this->logger) { - $this->logger->error('Google OAuth token request exception', ['exception' => $e]); + $this->logger->warning('Token cache write failed', ['exception' => $e]); } - - throw $e; } + } - return ''; + private function sleepBackoff(int $baseDelayMs, int $attempt): void + { + $delayMs = $baseDelayMs * (1 << $attempt); // 200, 400, 800, ... + // Jitter up to 25% to avoid thundering herd. + $jitter = (int) ($delayMs * 0.25 * (mt_rand(0, 1000) / 1000)); + $sleepMicros = ($delayMs + $jitter) * 1000; + if ($sleepMicros > 0) { + usleep($sleepMicros); + } } /** @@ -175,9 +296,17 @@ private function buildAssertionJwt(int $now): string 'exp' => $now + 3600, ]; + $headerJson = json_encode($header); + $claimsJson = json_encode($claims); + + if ($headerJson === false || $claimsJson === false) { + $err = function_exists('json_last_error_msg') ? json_last_error_msg() : 'json_encode failed'; + throw new \RuntimeException('Failed to encode JWT segments: ' . $err); + } + $segments = [ - $this->base64UrlEncode(json_encode($header)), - $this->base64UrlEncode(json_encode($claims)), + $this->base64UrlEncode($headerJson), + $this->base64UrlEncode($claimsJson), ]; $signingInput = implode('.', $segments); @@ -202,8 +331,9 @@ private function rsaSign($data, $privateKeyPem) $signature = ''; + // Private key resource/object is released by the garbage collector; + // we avoid an explicit free call because it is deprecated on PHP 8.x. $ok = openssl_sign($data, $signature, $pKey, OPENSSL_ALGO_SHA256); - openssl_pkey_free($pKey); if (!$ok) { throw new \RuntimeException("OpenSSL failed to sign data."); diff --git a/src/Auth/TransientAuthException.php b/src/Auth/TransientAuthException.php new file mode 100644 index 0000000..be489bc --- /dev/null +++ b/src/Auth/TransientAuthException.php @@ -0,0 +1,11 @@ +message = new Message(); - } - - public function withAndroid(AndroidOptions $android): self - { - $this->message = $this->message->withAndroid($android); - return $this; - } - - public function withApns(ApnsOptions $apns): self - { - $this->message = $this->message->withApns($apns); - return $this; - } - - public function withAndroidChannelId(string $channelId): self - { - $android = $this->message->android() ?: AndroidOptions::make(); - $android = $android->withChannelId($channelId); - return $this->withAndroid($android); - } - - public function withApnsPriority(string $priority): self - { - $apns = $this->message->apns() ?: ApnsOptions::make(); - $apns = $apns->withAps(['priority' => $priority]); - return $this->withApns($apns); - } - - public function build(): Message - { - return $this->message; - } -} diff --git a/src/Contracts/AccessTokenProvider.php b/src/Contracts/AccessTokenProvider.php index a1e71c9..e5ff169 100644 --- a/src/Contracts/AccessTokenProvider.php +++ b/src/Contracts/AccessTokenProvider.php @@ -5,8 +5,16 @@ interface AccessTokenProvider { /** - * Provides an OAuth 2.0 token for calling to Google FCM HTTP v1. - * It must return the bearer token, WITHOUT "Bearer " prefix. + * Provides an OAuth 2.0 bearer token for calling Google FCM HTTP v1. + * The returned value MUST NOT include the "Bearer " prefix. + * + * Implementations SHOULD cache valid tokens until they expire and + * SHOULD retry transient failures (5xx / 429 / network errors) before + * giving up. + * + * @throws \RuntimeException When token acquisition fails and cannot be + * retried (e.g. invalid credentials, 4xx from + * the OAuth endpoint, or exhausted retries). */ public function getToken(): string; } diff --git a/src/DTO/PushResult.php b/src/DTO/PushResult.php index 71de71f..0846d1e 100644 --- a/src/DTO/PushResult.php +++ b/src/DTO/PushResult.php @@ -7,7 +7,7 @@ class PushResult /** @var bool */ public $success; - /** @var string */ + /** @var string|null */ public $id; /** @var string|null */ @@ -30,4 +30,42 @@ public function __construct($success, $id = null, $errorCode = null, $errorMessa $this->errorMessage = $errorMessage !== null ? (string) $errorMessage : null; $this->raw = $raw; } + + /** + * Returns true when the device token should be removed from storage + * (FCM reported it as unknown, unregistered, or no longer valid). + */ + public function isUnregistered(): bool + { + return $this->errorCode === 'UNREGISTERED' + || $this->errorCode === 'NOT_FOUND'; + } + + /** + * FCM rejected the payload (bad token format, bad field shape, etc.). + */ + public function isInvalidArgument(): bool + { + return $this->errorCode === 'INVALID_ARGUMENT'; + } + + /** + * FCM quota exceeded — caller should back off. + */ + public function isQuotaExceeded(): bool + { + return $this->errorCode === 'QUOTA_EXCEEDED' + || $this->errorCode === 'RESOURCE_EXHAUSTED'; + } + + /** + * Transient server-side failure; safe to retry later. + */ + public function isTransient(): bool + { + return $this->errorCode === 'UNAVAILABLE' + || $this->errorCode === 'INTERNAL' + || $this->errorCode === 'DEADLINE_EXCEEDED' + || $this->errorCode === 'TRANSPORT_ERROR'; + } } diff --git a/src/DTO/PushTarget.php b/src/DTO/PushTarget.php index e4c7826..4756b39 100644 --- a/src/DTO/PushTarget.php +++ b/src/DTO/PushTarget.php @@ -18,8 +18,18 @@ class PushTarget public function __construct($token = null, $topic = null, $condition = null) { - if ($token === null && $topic === null && $condition === null) { - throw new \InvalidArgumentException("PushTarget requires token, topic or condition."); + $set = 0; + if ($token !== null) { $set++; } + if ($topic !== null) { $set++; } + if ($condition !== null) { $set++; } + + if ($set === 0) { + throw new \InvalidArgumentException('PushTarget requires token, topic or condition.'); + } + if ($set > 1) { + throw new \InvalidArgumentException( + 'PushTarget accepts exactly one of token, topic or condition — got ' . $set . '.' + ); } $this->token = $token !== null ? (string) $token : null; diff --git a/src/Drivers/FcmHttpV1Client.php b/src/Drivers/FcmHttpV1Client.php index 7f63f13..0a52552 100644 --- a/src/Drivers/FcmHttpV1Client.php +++ b/src/Drivers/FcmHttpV1Client.php @@ -8,6 +8,8 @@ use Dev1\NotifyCore\DTO\PushMessage; use Dev1\NotifyCore\DTO\PushResult; use Dev1\NotifyCore\DTO\PushTarget; +use Dev1\NotifyCore\Version; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface as HttpClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; @@ -34,7 +36,8 @@ class FcmHttpV1Client implements PushClient * @var array * - project_id: string (required) * - endpoint: string (optional, default: https://fcm.googleapis.com/v1/projects/{project_id}/messages:send) - * - timeout: int|float (optional) + * - max_retries: int (optional, default 2) — attempts on 5xx/429/transport errors + * - retry_base_delay_ms: int (optional, default 200) — initial backoff, doubles per attempt */ private $config; @@ -66,7 +69,8 @@ public function __construct( $this->config = [ 'project_id' => $config['project_id'], 'endpoint' => $endpoint, - 'timeout' => isset($config['timeout']) ? $config['timeout'] : null, + 'max_retries' => isset($config['max_retries']) ? max(0, (int) $config['max_retries']) : 2, + 'retry_base_delay_ms' => isset($config['retry_base_delay_ms']) ? max(0, (int) $config['retry_base_delay_ms']) : 200, ]; } @@ -81,62 +85,138 @@ public function send(PushMessage $message, PushTarget $target): PushResult $payload = ['message' => $this->buildMessagePayload($message, $target)]; $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $token = $this->tokenProvider->getToken(); + if ($json === false) { + $err = function_exists('json_last_error_msg') ? json_last_error_msg() : 'json_encode failed'; + if ($this->logger) { + $this->logger->error('FCM v1 payload encode failed', ['error' => $err]); + } + return new PushResult(false, null, 'ENCODE_ERROR', $err, null); + } + + try { + $token = $this->tokenProvider->getToken(); + } catch (\Throwable $e) { + if ($this->logger) { + $this->logger->error('FCM v1 token acquisition failed', ['exception' => $e]); + } + return new PushResult(false, null, 'TOKEN_ERROR', $e->getMessage(), null); + } + + $maxRetries = (int) $this->config['max_retries']; + $baseDelayMs = (int) $this->config['retry_base_delay_ms']; + + $lastTransient = null; + for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { + try { + return $this->doSend($json, $token); + } catch (TransientSendException $e) { + $lastTransient = $e; + if ($attempt === $maxRetries) { + break; + } + $this->sleepBackoff($baseDelayMs, $attempt); + } + } + + // All attempts exhausted on transient failure; surface as a non-success PushResult. + return $lastTransient + ? $lastTransient->result + : new PushResult(false, null, 'TRANSPORT_ERROR', 'Unknown transient failure', null); + } + /** + * @throws TransientSendException On 5xx / 429 / PSR-18 transport errors. + */ + private function doSend(string $json, string $token): PushResult + { $request = $this->requestFactory->createRequest('POST', $this->config['endpoint']) ->withHeader('Authorization', 'Bearer ' . $token) - ->withHeader('Content-Type', 'application/json'); - - $request = $request->withBody($this->streamFactory->createStream($json)); + ->withHeader('Content-Type', 'application/json') + ->withHeader('User-Agent', Version::USER_AGENT) + ->withBody($this->streamFactory->createStream($json)); try { $response = $this->http->sendRequest($request); - $status = $response->getStatusCode(); - $body = (string) $response->getBody(); - - // Success - if ($status >= 200 && $status < 300) { - $decoded = json_decode($body, true); + } catch (ClientExceptionInterface $e) { + if ($this->logger) { + $this->logger->warning('FCM v1 transport exception', ['exception' => $e]); + } + throw new TransientSendException( + new PushResult(false, null, 'TRANSPORT_ERROR', $e->getMessage(), null) + ); + } - $id = is_array($decoded) && isset($decoded['name']) ? (string) $decoded['name'] : null; + $status = $response->getStatusCode(); + $body = (string) $response->getBody(); - if ($this->logger) { - $this->logger->info('FCM v1 send OK', ['id' => $id, 'status' => $status]); - } + if ($status >= 200 && $status < 300) { + $decoded = json_decode($body, true); + $id = is_array($decoded) && isset($decoded['name']) ? (string) $decoded['name'] : null; - return new PushResult(true, $id, null, null, is_array($decoded) ? $decoded : null); + if ($this->logger) { + $this->logger->info('FCM v1 send OK', ['id' => $id, 'status' => $status]); } - // Error - $decoded = json_decode($body, true); - $errorCode = null; - $errorMessage = null; + return new PushResult(true, $id, null, null, is_array($decoded) ? $decoded : null); + } - if (is_array($decoded) && isset($decoded['error']) && is_array($decoded['error'])) { - $error = $decoded['error']; + $decoded = json_decode($body, true); + $errorCode = null; + $errorMessage = null; + + if (is_array($decoded) && isset($decoded['error']) && is_array($decoded['error'])) { + $error = $decoded['error']; + + // Prefer FcmError.errorCode (e.g. UNREGISTERED, QUOTA_EXCEEDED) over the + // generic google.rpc.Status 'status' field (e.g. NOT_FOUND, PERMISSION_DENIED). + $fcmErrorCode = null; + if (isset($error['details']) && is_array($error['details'])) { + foreach ($error['details'] as $detail) { + if (is_array($detail) && isset($detail['errorCode'])) { + $fcmErrorCode = (string) $detail['errorCode']; + break; + } + } + } - $errorCode = isset($error['status']) ? (string) $error['status'] : ('HTTP_' . $status); - $errorMessage = isset($error['message']) ? (string) $error['message'] : 'FCM v1 error'; + if ($fcmErrorCode !== null) { + $errorCode = $fcmErrorCode; + } elseif (isset($error['status'])) { + $errorCode = (string) $error['status']; } else { $errorCode = 'HTTP_' . $status; - $errorMessage = $body !== '' ? $body : 'HTTP error'; } - if ($this->logger) { - $this->logger->warning('FCM v1 send FAILED', [ - 'status' => $status, - 'error' => $errorCode, - 'message' => $errorMessage, - ]); - } + $errorMessage = isset($error['message']) ? (string) $error['message'] : 'FCM v1 error'; + } else { + $errorCode = 'HTTP_' . $status; + $errorMessage = $body !== '' ? $body : 'HTTP error'; + } - return new PushResult(false, null, $errorCode, $errorMessage, is_array($decoded) ? $decoded : null); - } catch (\Throwable $e) { - if ($this->logger) { - $this->logger->error('FCM V1 exception', ['exception' => $e]); - } + if ($this->logger) { + $this->logger->warning('FCM v1 send FAILED', [ + 'status' => $status, + 'error' => $errorCode, + 'message' => $errorMessage, + ]); + } + + $result = new PushResult(false, null, $errorCode, $errorMessage, is_array($decoded) ? $decoded : null); + + if ($status >= 500 || $status === 429) { + throw new TransientSendException($result); + } + + return $result; + } - return new PushResult(false, null, 'EXCEPTION', $e->getMessage(), null); + private function sleepBackoff(int $baseDelayMs, int $attempt): void + { + $delayMs = $baseDelayMs * (1 << $attempt); + $jitter = (int) ($delayMs * 0.25 * (mt_rand(0, 1000) / 1000)); + $sleepMicros = ($delayMs + $jitter) * 1000; + if ($sleepMicros > 0) { + usleep($sleepMicros); } } @@ -155,20 +235,26 @@ private function buildMessagePayload(PushMessage $message, PushTarget $target) $msg['condition'] = $target->condition; } - /** Notification */ - $notification = [ - 'title' => $message->title, - 'body' => $message->body, - ]; - - $msg['notification'] = $notification; + /** Notification (skip entirely for silent/data-only messages) */ + if ($message->title !== '' || $message->body !== '') { + $msg['notification'] = [ + 'title' => $message->title, + 'body' => $message->body, + ]; + } /** Data */ if (is_array($message->data) && !empty($message->data)) { $msg['data'] = []; foreach ($message->data as $key => $value) { - $msg['data'][(string)$key] = is_scalar($value) ? (string)$value : json_encode($value); + if (is_scalar($value)) { + $msg['data'][(string) $key] = (string) $value; + continue; + } + + $encoded = json_encode($value); + $msg['data'][(string) $key] = $encoded === false ? '' : $encoded; } } diff --git a/src/Drivers/TransientSendException.php b/src/Drivers/TransientSendException.php new file mode 100644 index 0000000..a899305 --- /dev/null +++ b/src/Drivers/TransientSendException.php @@ -0,0 +1,22 @@ +errorMessage ?: 'Transient FCM failure'); + $this->result = $result; + } +} diff --git a/src/Factory/FcmClientFactory.php b/src/Factory/FcmClientFactory.php index d76699e..7f6261f 100644 --- a/src/Factory/FcmClientFactory.php +++ b/src/Factory/FcmClientFactory.php @@ -17,7 +17,7 @@ public static function create( StreamFactoryInterface $streamFactory, AccessTokenProvider $tokenProvider, ?LoggerInterface $logger, - array $config, + array $config ): FcmHttpV1Client { return new FcmHttpV1Client( $http, diff --git a/src/Message.php b/src/Message.php deleted file mode 100644 index b79a85f..0000000 --- a/src/Message.php +++ /dev/null @@ -1,49 +0,0 @@ -androidOptions = $android; - return $clone; - } - - public function withApns(?ApnsOptions $apns): self - { - $clone = clone $this; - $clone->apnsOptions = $apns; - return $clone; - } - - public function android(): ?AndroidOptions - { - return $this->androidOptions; - } - - public function apns(): ?ApnsOptions - { - return $this->apnsOptions; - } - - public function toArray(): array - { - $base = []; - if ($this->androidOptions) { - $base['android'] = $this->androidOptions->toArray(); - } - if ($this->apnsOptions) { - $base['apns'] = $this->apnsOptions->toArray(); - } - return $base; - } -} diff --git a/src/Platform/AndroidOptions.php b/src/Platform/AndroidOptions.php index 006a459..687c24c 100644 --- a/src/Platform/AndroidOptions.php +++ b/src/Platform/AndroidOptions.php @@ -28,10 +28,9 @@ public static function make(): self return new self(); } - public function withChannelId(string $channelId) + public function withChannelId(string $channelId): self { $this->channelId = $channelId; - $this->notification['channel_id'] = $channelId; return $this; } @@ -44,7 +43,9 @@ public function withPriority(string $priority): self } elseif ($p === 'NORMAL' || $p === 'DEFAULT' || $p === 'LOW') { $this->priority = 'NORMAL'; } else { - $this->priority = $p; // allow raw values if already correct + throw new \InvalidArgumentException( + 'AndroidOptions::withPriority expects HIGH|MAX|NORMAL|DEFAULT|LOW, got: ' . $priority + ); } return $this; } @@ -88,7 +89,7 @@ public function withExtra(array $extra): self return $this; } - public function merge(self $other) + public function merge(self $other): self { $merged = new self(); @@ -134,8 +135,7 @@ public function toArray(): array } if ($this->channelId !== null) { - // Ensure channel_id is always set in notification for backward compatibility - if (!isset($output['notification'])) { + if (!isset($output['notification']) || !is_array($output['notification'])) { $output['notification'] = []; } $output['notification']['channel_id'] = $this->channelId; diff --git a/src/Platform/ApnsOptions.php b/src/Platform/ApnsOptions.php index bc1a4a9..9ae2af8 100644 --- a/src/Platform/ApnsOptions.php +++ b/src/Platform/ApnsOptions.php @@ -47,7 +47,7 @@ public function withCustom(array $custom): self return $this; } - public function merge(self $other) + public function merge(self $other): self { $merged = new self(); $merged->headers = array_replace($this->headers, $other->headers); diff --git a/src/Registry/ClientRegistry.php b/src/Registry/ClientRegistry.php index 01abc77..98bca44 100644 --- a/src/Registry/ClientRegistry.php +++ b/src/Registry/ClientRegistry.php @@ -71,17 +71,24 @@ public function names(): array } /** - * Removes a client from registry. If it was default, default is cleared. + * Removes a client from the registry. If it was the default, the default is cleared. + * Idempotent: removing an unknown name is a no-op. + * + * @return bool True if a client was removed, false if no client existed with that name. */ - public function remove(string $name) + public function remove(string $name): bool { - if (isset($this->clients[$name])) { - unset($this->clients[$name]); + if (!isset($this->clients[$name])) { + return false; + } - if ($this->defaultName === $name) { - $this->defaultName = null; - } + unset($this->clients[$name]); + + if ($this->defaultName === $name) { + $this->defaultName = null; } + + return true; } /** diff --git a/src/Version.php b/src/Version.php new file mode 100644 index 0000000..8b2e313 --- /dev/null +++ b/src/Version.php @@ -0,0 +1,14 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + openssl_pkey_export($res, $pem); + self::$privateKey = $pem; + } + + protected function setUp(): void + { + $this->psr17 = new Psr17Factory(); + } + + public function testRequiresEmailAndKey(): void + { + $this->expectException(\InvalidArgumentException::class); + new GoogleServiceAccountTokenProvider( + new FakeHttpClient(new Response(200, [], '{}')), + $this->psr17, + $this->psr17, + null, + [] + ); + } + + public function testSuccessfulTokenIsCachedInMemory(): void + { + $http = new FakeHttpClient(new Response(200, [], json_encode(['access_token' => 'tok-1', 'expires_in' => 3600]))); + $provider = $this->makeProvider($http); + + $this->assertSame('tok-1', $provider->getToken()); + $this->assertSame('tok-1', $provider->getToken()); + $this->assertSame(1, $http->callCount, 'In-memory cache should prevent a second round-trip'); + } + + public function testPsr16CacheIsPopulatedAndReused(): void + { + $cache = new InMemoryCache(); + + $http1 = new FakeHttpClient(new Response(200, [], json_encode(['access_token' => 'tok-cached', 'expires_in' => 3600]))); + $p1 = $this->makeProvider($http1, [], $cache); + $this->assertSame('tok-cached', $p1->getToken()); + $this->assertSame(1, $cache->writes); + + // Fresh instance shares the cache; no HTTP call should be made. + $http2 = new FakeHttpClient(new Response(500, [], 'should-not-be-called')); + $p2 = $this->makeProvider($http2, [], $cache); + $this->assertSame('tok-cached', $p2->getToken()); + $this->assertSame(0, $http2->callCount); + } + + public function testRetriesOn5xxThenSucceeds(): void + { + $http = new FakeHttpClient([ + new Response(503, [], 'unavailable'), + new Response(200, [], json_encode(['access_token' => 'tok-retry', 'expires_in' => 3600])), + ]); + $provider = $this->makeProvider($http, ['max_retries' => 2, 'retry_base_delay_ms' => 0]); + + $this->assertSame('tok-retry', $provider->getToken()); + $this->assertSame(2, $http->callCount); + } + + public function testRetriesOnTransportExceptionThenSucceeds(): void + { + $transient = new class('boom') extends \RuntimeException implements ClientExceptionInterface {}; + + $http = new FakeHttpClient([ + $transient, + new Response(200, [], json_encode(['access_token' => 'tok-after-transport', 'expires_in' => 3600])), + ]); + $provider = $this->makeProvider($http, ['max_retries' => 1, 'retry_base_delay_ms' => 0]); + + $this->assertSame('tok-after-transport', $provider->getToken()); + $this->assertSame(2, $http->callCount); + } + + public function testExhaustedRetriesThrowTransient(): void + { + $http = new FakeHttpClient([ + new Response(500, [], 'fail-1'), + new Response(500, [], 'fail-2'), + new Response(500, [], 'fail-3'), + ]); + $provider = $this->makeProvider($http, ['max_retries' => 2, 'retry_base_delay_ms' => 0]); + + $this->expectException(TransientAuthException::class); + try { + $provider->getToken(); + } finally { + $this->assertSame(3, $http->callCount); + } + } + + public function testNon5xxErrorDoesNotRetry(): void + { + $http = new FakeHttpClient([ + new Response(400, [], json_encode(['error' => 'invalid_grant'])), + new Response(200, [], json_encode(['access_token' => 'never-reached'])), + ]); + $provider = $this->makeProvider($http, ['max_retries' => 3, 'retry_base_delay_ms' => 0]); + + $this->expectException(\RuntimeException::class); + try { + $provider->getToken(); + } finally { + $this->assertSame(1, $http->callCount, 'Client errors must not trigger retries'); + } + } + + public function testMalformedCacheEntryIsIgnored(): void + { + $cache = new InMemoryCache(); + // Seed cache with a bogus entry (missing expires_at). + $cache->set('dev1_notify_core.google_oauth.' . sha1('sa@example.iam.gserviceaccount.com'), ['unexpected' => 'shape']); + + $http = new FakeHttpClient(new Response(200, [], json_encode(['access_token' => 'tok-fresh', 'expires_in' => 3600]))); + $provider = $this->makeProvider($http, [], $cache); + + $this->assertSame('tok-fresh', $provider->getToken()); + $this->assertSame(1, $http->callCount, 'Malformed cache entries must trigger a fresh network request'); + } + + public function testCacheReadFailureFallsBackToNetwork(): void + { + $throwing = new class implements CacheInterface { + public function get($key, $default = null) + { + throw new \RuntimeException('cache read boom'); + } + public function set($key, $value, $ttl = null) + { + return true; + } + public function delete($key) { return true; } + public function clear() { return true; } + public function getMultiple($keys, $default = null) { return []; } + public function setMultiple($values, $ttl = null) { return true; } + public function deleteMultiple($keys) { return true; } + public function has($key) { return false; } + }; + + $http = new FakeHttpClient(new Response(200, [], json_encode(['access_token' => 'tok-after-cache-fail', 'expires_in' => 3600]))); + $provider = $this->makeProvider($http, [], $throwing); + + $this->assertSame('tok-after-cache-fail', $provider->getToken()); + } + + public function testCacheWriteFailureIsSwallowed(): void + { + $throwingWrite = new class implements CacheInterface { + public function get($key, $default = null) { return null; } + public function set($key, $value, $ttl = null) + { + throw new \RuntimeException('cache write boom'); + } + public function delete($key) { return true; } + public function clear() { return true; } + public function getMultiple($keys, $default = null) { return []; } + public function setMultiple($values, $ttl = null) { return true; } + public function deleteMultiple($keys) { return true; } + public function has($key) { return false; } + }; + + $http = new FakeHttpClient(new Response(200, [], json_encode(['access_token' => 'tok-write-fail', 'expires_in' => 3600]))); + $provider = $this->makeProvider($http, [], $throwingWrite); + + // Must still return the token even if the cache write failed. + $this->assertSame('tok-write-fail', $provider->getToken()); + } + + public function testScopeArrayIsSpaceJoinedInJwt(): void + { + $http = new FakeHttpClient(new Response(200, [], json_encode(['access_token' => 'tok-array-scope', 'expires_in' => 3600]))); + $provider = $this->makeProvider($http, [ + 'scope' => [ + 'https://www.googleapis.com/auth/firebase.messaging', + 'https://www.googleapis.com/auth/cloud-platform', + ], + ]); + + $this->assertSame('tok-array-scope', $provider->getToken()); + + // Decode the assertion JWT payload to confirm the joined scope string reached it. + parse_str($http->lastBody, $form); + $this->assertArrayHasKey('assertion', $form); + $parts = explode('.', $form['assertion']); + $this->assertCount(3, $parts); + $claimsJson = base64_decode(strtr($parts[1], '-_', '+/')); + $claims = json_decode($claimsJson, true); + $this->assertSame( + 'https://www.googleapis.com/auth/firebase.messaging https://www.googleapis.com/auth/cloud-platform', + $claims['scope'] + ); + } + + public function testHttp200WithoutAccessTokenThrows(): void + { + $http = new FakeHttpClient(new Response(200, [], json_encode(['something_else' => true]))); + $provider = $this->makeProvider($http, ['max_retries' => 0]); + + $this->expectException(\RuntimeException::class); + $provider->getToken(); + } + + public function testInvalidPrivateKeyThrowsAtSignTime(): void + { + $http = new FakeHttpClient(new Response(200, [], json_encode(['access_token' => 'never']))); + $provider = $this->makeProvider($http, [ + 'private_key' => "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----", + 'max_retries' => 0, + ]); + + $this->expectException(\RuntimeException::class); + $provider->getToken(); + } + + /** + * @param array $extraConfig + */ + private function makeProvider(FakeHttpClient $http, array $extraConfig = [], ?CacheInterface $cache = null): GoogleServiceAccountTokenProvider + { + $config = array_merge([ + 'client_email' => 'sa@example.iam.gserviceaccount.com', + 'private_key' => self::$privateKey, + ], $extraConfig); + + return new GoogleServiceAccountTokenProvider( + $http, + $this->psr17, + $this->psr17, + null, + $config, + $cache + ); + } +} diff --git a/tests/DTO/PushResultTest.php b/tests/DTO/PushResultTest.php new file mode 100644 index 0000000..263042a --- /dev/null +++ b/tests/DTO/PushResultTest.php @@ -0,0 +1,60 @@ +assertTrue($result->success); + $this->assertSame('projects/p/messages/m1', $result->id); + $this->assertNull($result->errorCode); + $this->assertNull($result->errorMessage); + $this->assertNull($result->raw); + } + + public function testFailurePropagatesErrorFields(): void + { + $raw = ['error' => ['status' => 'NOT_FOUND']]; + $result = new PushResult(false, null, 'NOT_FOUND', 'Token missing', $raw); + + $this->assertFalse($result->success); + $this->assertNull($result->id); + $this->assertSame('NOT_FOUND', $result->errorCode); + $this->assertSame('Token missing', $result->errorMessage); + $this->assertSame($raw, $result->raw); + } + + public function testUnregisteredHelper(): void + { + $this->assertTrue((new PushResult(false, null, 'UNREGISTERED'))->isUnregistered()); + $this->assertTrue((new PushResult(false, null, 'NOT_FOUND'))->isUnregistered()); + $this->assertFalse((new PushResult(false, null, 'INVALID_ARGUMENT'))->isUnregistered()); + } + + public function testInvalidArgumentHelper(): void + { + $this->assertTrue((new PushResult(false, null, 'INVALID_ARGUMENT'))->isInvalidArgument()); + $this->assertFalse((new PushResult(false, null, 'UNREGISTERED'))->isInvalidArgument()); + } + + public function testQuotaExceededHelper(): void + { + $this->assertTrue((new PushResult(false, null, 'QUOTA_EXCEEDED'))->isQuotaExceeded()); + $this->assertTrue((new PushResult(false, null, 'RESOURCE_EXHAUSTED'))->isQuotaExceeded()); + $this->assertFalse((new PushResult(false, null, 'UNREGISTERED'))->isQuotaExceeded()); + } + + public function testTransientHelper(): void + { + $this->assertTrue((new PushResult(false, null, 'UNAVAILABLE'))->isTransient()); + $this->assertTrue((new PushResult(false, null, 'TRANSPORT_ERROR'))->isTransient()); + $this->assertTrue((new PushResult(false, null, 'INTERNAL'))->isTransient()); + $this->assertTrue((new PushResult(false, null, 'DEADLINE_EXCEEDED'))->isTransient()); + $this->assertFalse((new PushResult(false, null, 'INVALID_ARGUMENT'))->isTransient()); + } +} diff --git a/tests/DTO/PushTargetTest.php b/tests/DTO/PushTargetTest.php new file mode 100644 index 0000000..9e35161 --- /dev/null +++ b/tests/DTO/PushTargetTest.php @@ -0,0 +1,48 @@ +expectException(\InvalidArgumentException::class); + new PushTarget(); + } + + public function testStoresTokenAsString(): void + { + $target = new PushTarget('abc'); + $this->assertSame('abc', $target->token); + $this->assertNull($target->topic); + $this->assertNull($target->condition); + } + + public function testStoresTopic(): void + { + $target = new PushTarget(null, 'news'); + $this->assertNull($target->token); + $this->assertSame('news', $target->topic); + } + + public function testStoresCondition(): void + { + $target = new PushTarget(null, null, "'a' in topics"); + $this->assertSame("'a' in topics", $target->condition); + } + + public function testRejectsAmbiguousTarget(): void + { + $this->expectException(\InvalidArgumentException::class); + new PushTarget('tok', 'news'); + } + + public function testRejectsAllThreeSet(): void + { + $this->expectException(\InvalidArgumentException::class); + new PushTarget('tok', 'news', "'a' in topics"); + } +} diff --git a/tests/Drivers/FcmHttpV1ClientRetryTest.php b/tests/Drivers/FcmHttpV1ClientRetryTest.php new file mode 100644 index 0000000..4c6a8c1 --- /dev/null +++ b/tests/Drivers/FcmHttpV1ClientRetryTest.php @@ -0,0 +1,161 @@ +psr17 = new Psr17Factory(); + } + + public function testRetriesOn503ThenSucceeds(): void + { + $http = new FakeHttpClient([ + new Response(503, [], 'unavailable'), + new Response(200, [], '{"name":"projects/p/messages/ok"}'), + ]); + $client = $this->makeClient($http, ['max_retries' => 2, 'retry_base_delay_ms' => 0]); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertTrue($result->success); + $this->assertSame('projects/p/messages/ok', $result->id); + $this->assertSame(2, $http->callCount); + } + + public function testRetriesOnTransportException(): void + { + $transient = new class('down') extends \RuntimeException implements ClientExceptionInterface {}; + + $http = new FakeHttpClient([ + $transient, + new Response(200, [], '{"name":"projects/p/messages/ok"}'), + ]); + $client = $this->makeClient($http, ['max_retries' => 1, 'retry_base_delay_ms' => 0]); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertTrue($result->success); + $this->assertSame(2, $http->callCount); + } + + public function testRetriesOn429(): void + { + $http = new FakeHttpClient([ + new Response(429, [], json_encode([ + 'error' => ['status' => 'RESOURCE_EXHAUSTED', 'message' => 'slow down'], + ])), + new Response(200, [], '{"name":"projects/p/messages/ok"}'), + ]); + $client = $this->makeClient($http, ['max_retries' => 1, 'retry_base_delay_ms' => 0]); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertTrue($result->success); + $this->assertSame(2, $http->callCount); + } + + public function testExhaustedRetriesReturnsTransientResult(): void + { + $http = new FakeHttpClient([ + new Response(500, [], 'boom-1'), + new Response(500, [], 'boom-2'), + new Response(500, [], 'boom-3'), + ]); + $client = $this->makeClient($http, ['max_retries' => 2, 'retry_base_delay_ms' => 0]); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertFalse($result->success); + $this->assertSame('HTTP_500', $result->errorCode); + $this->assertSame(3, $http->callCount); + } + + public function testClientErrorDoesNotRetry(): void + { + $http = new FakeHttpClient([ + new Response(400, [], json_encode([ + 'error' => [ + 'status' => 'INVALID_ARGUMENT', + 'message' => 'bad token', + ], + ])), + new Response(200, [], '{"name":"never"}'), + ]); + $client = $this->makeClient($http, ['max_retries' => 3, 'retry_base_delay_ms' => 0]); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertFalse($result->success); + $this->assertSame('INVALID_ARGUMENT', $result->errorCode); + $this->assertTrue($result->isInvalidArgument()); + $this->assertSame(1, $http->callCount); + } + + public function testFcmErrorCodeDetailOverridesStatus(): void + { + $body = json_encode([ + 'error' => [ + 'status' => 'NOT_FOUND', + 'message' => 'Requested entity was not found.', + 'details' => [ + [ + '@type' => 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode' => 'UNREGISTERED', + ], + ], + ], + ]); + $http = new FakeHttpClient(new Response(404, [], $body)); + $client = $this->makeClient($http); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertSame('UNREGISTERED', $result->errorCode); + $this->assertTrue($result->isUnregistered()); + } + + public function testPushResultTransientHelper(): void + { + $http = new FakeHttpClient([ + new Response(500, [], 'x'), + new Response(500, [], 'x'), + new Response(500, [], 'x'), + ]); + $client = $this->makeClient($http, ['max_retries' => 2, 'retry_base_delay_ms' => 0]); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + $this->assertFalse($result->success); + $this->assertSame('HTTP_500', $result->errorCode); + $this->assertFalse($result->isTransient(), 'HTTP_500 is not mapped to the RPC transient set'); + } + + /** + * @param array $config + */ + private function makeClient(FakeHttpClient $http, array $config = []): FcmHttpV1Client + { + return new FcmHttpV1Client( + $http, + $this->psr17, + $this->psr17, + new StaticTokenProvider('test-token'), + null, + array_merge(['project_id' => 'demo'], $config) + ); + } +} diff --git a/tests/Drivers/FcmHttpV1ClientTest.php b/tests/Drivers/FcmHttpV1ClientTest.php new file mode 100644 index 0000000..ccf9ec9 --- /dev/null +++ b/tests/Drivers/FcmHttpV1ClientTest.php @@ -0,0 +1,300 @@ +psr17 = new Psr17Factory(); + } + + public function testConstructorRequiresProjectId(): void + { + $this->expectException(\InvalidArgumentException::class); + new FcmHttpV1Client( + new FakeHttpClient(new Response(200, [], '{}')), + $this->psr17, + $this->psr17, + new StaticTokenProvider(), + null, + [] + ); + } + + public function testEndpointInterpolatesProjectId(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"projects/p/messages/abc"}')); + $client = $this->makeClient($http); + + $client->send(new PushMessage('t', 'b'), new PushTarget('device-token')); + + $this->assertNotNull($http->lastRequest); + $this->assertSame( + 'https://fcm.googleapis.com/v1/projects/demo/messages:send', + (string) $http->lastRequest->getUri() + ); + $this->assertSame('Bearer test-token', $http->lastRequest->getHeaderLine('Authorization')); + $this->assertStringStartsWith('Dev1-Notify-Core/', $http->lastRequest->getHeaderLine('User-Agent')); + } + + public function testSuccessfulSendReturnsId(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"projects/p/messages/abc"}')); + $client = $this->makeClient($http); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertTrue($result->success); + $this->assertSame('projects/p/messages/abc', $result->id); + $this->assertNull($result->errorCode); + } + + public function testFailureMapsFcmErrorStatus(): void + { + $body = json_encode([ + 'error' => [ + 'status' => 'UNREGISTERED', + 'message' => 'Requested entity was not found.', + ], + ]); + $http = new FakeHttpClient(new Response(404, [], $body)); + $client = $this->makeClient($http); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertFalse($result->success); + $this->assertSame('UNREGISTERED', $result->errorCode); + $this->assertSame('Requested entity was not found.', $result->errorMessage); + } + + public function testDataOnlyPushOmitsNotificationBlock(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = $this->makeClient($http); + + $client->send( + new PushMessage('', '', ['k' => 'v']), + new PushTarget('tok') + ); + + $payload = json_decode($http->lastBody, true); + $this->assertArrayNotHasKey('notification', $payload['message']); + $this->assertSame(['k' => 'v'], $payload['message']['data']); + } + + public function testDataValuesAreStringified(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = $this->makeClient($http); + + $client->send( + new PushMessage('t', 'b', ['n' => 123, 'b' => true, 'arr' => ['x' => 1]]), + new PushTarget('tok') + ); + + $payload = json_decode($http->lastBody, true); + $this->assertSame('123', $payload['message']['data']['n']); + $this->assertSame('1', $payload['message']['data']['b']); + $this->assertSame('{"x":1}', $payload['message']['data']['arr']); + } + + public function testTargetPrefersToken(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = $this->makeClient($http); + + $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $payload = json_decode($http->lastBody, true); + $this->assertSame('tok', $payload['message']['token']); + $this->assertArrayNotHasKey('topic', $payload['message']); + } + + public function testAndroidOptionsAreSerialized(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = $this->makeClient($http); + + $android = AndroidOptions::make() + ->withChannelId('chan-1') + ->withPriority('high') + ->withTtl(120) + ->withCollapseKey('k'); + + $message = new PushMessage('t', 'b', null, ['android' => $android]); + $client->send($message, new PushTarget('tok')); + + $payload = json_decode($http->lastBody, true); + $this->assertSame('HIGH', $payload['message']['android']['priority']); + $this->assertSame('120s', $payload['message']['android']['ttl']); + $this->assertSame('k', $payload['message']['android']['collapse_key']); + $this->assertSame('chan-1', $payload['message']['android']['notification']['channel_id']); + } + + public function testTopicTargetSerializesAsTopic(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = $this->makeClient($http); + + $client->send(new PushMessage('t', 'b'), new PushTarget(null, 'alerts')); + + $payload = json_decode($http->lastBody, true); + $this->assertSame('alerts', $payload['message']['topic']); + $this->assertArrayNotHasKey('token', $payload['message']); + } + + public function testConditionTargetSerializesAsCondition(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = $this->makeClient($http); + + $expr = "'alerts' in topics"; + $client->send(new PushMessage('t', 'b'), new PushTarget(null, null, $expr)); + + $payload = json_decode($http->lastBody, true); + $this->assertSame($expr, $payload['message']['condition']); + $this->assertArrayNotHasKey('topic', $payload['message']); + } + + public function testApnsPlatformOptionsAreSerialized(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = $this->makeClient($http); + + $apns = ApnsOptions::make() + ->withHeaders(['apns-priority' => '10']) + ->withAps(['sound' => 'default']); + + $message = new PushMessage('t', 'b', null, ['apns' => $apns]); + $client->send($message, new PushTarget('tok')); + + $payload = json_decode($http->lastBody, true); + $this->assertSame('10', $payload['message']['apns']['headers']['apns-priority']); + $this->assertSame('default', $payload['message']['apns']['body']['aps']['sound']); + } + + public function testRawArrayOverridesArePassedThroughUntouched(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = $this->makeClient($http); + + $message = new PushMessage('t', 'b', null, [ + 'android' => ['priority' => 'HIGH'], + 'apns' => ['headers' => ['apns-push-type' => 'alert']], + 'webpush' => ['headers' => ['TTL' => '60']], + ]); + $client->send($message, new PushTarget('tok')); + + $payload = json_decode($http->lastBody, true); + $this->assertSame('HIGH', $payload['message']['android']['priority']); + $this->assertSame('alert', $payload['message']['apns']['headers']['apns-push-type']); + $this->assertSame('60', $payload['message']['webpush']['headers']['TTL']); + } + + public function testTokenAcquisitionFailureReturnsTokenErrorResult(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"never"}')); + $provider = new class implements AccessTokenProvider { + public function getToken(): string + { + throw new \RuntimeException('no creds'); + } + }; + + $client = new FcmHttpV1Client( + $http, + $this->psr17, + $this->psr17, + $provider, + null, + ['project_id' => 'demo'] + ); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertFalse($result->success); + $this->assertSame('TOKEN_ERROR', $result->errorCode); + $this->assertSame('no creds', $result->errorMessage); + $this->assertSame(0, $http->callCount, 'HTTP must not be called if token acquisition failed'); + } + + public function testNonJsonErrorBodyFallsBackToHttpStatusCode(): void + { + $http = new FakeHttpClient(new Response(403, [], 'Forbidden (not JSON)')); + $client = $this->makeClient($http); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertFalse($result->success); + $this->assertSame('HTTP_403', $result->errorCode); + $this->assertSame('Forbidden (not JSON)', $result->errorMessage); + } + + public function testCustomEndpointInterpolatesProjectId(): void + { + $http = new FakeHttpClient(new Response(200, [], '{"name":"x"}')); + $client = new FcmHttpV1Client( + $http, + $this->psr17, + $this->psr17, + new StaticTokenProvider('test-token'), + null, + [ + 'project_id' => 'demo', + 'endpoint' => 'https://example.test/v1/projects/{project_id}/messages:send', + ] + ); + + $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertSame( + 'https://example.test/v1/projects/demo/messages:send', + (string) $http->lastRequest->getUri() + ); + } + + public function testTransportExceptionReturnsTransportErrorResult(): void + { + $exception = new class('boom') extends \RuntimeException implements ClientExceptionInterface {}; + $http = new FakeHttpClient($exception); + $client = $this->makeClient($http, ['max_retries' => 0]); + + $result = $client->send(new PushMessage('t', 'b'), new PushTarget('tok')); + + $this->assertFalse($result->success); + $this->assertSame('TRANSPORT_ERROR', $result->errorCode); + $this->assertSame('boom', $result->errorMessage); + } + + /** + * @param array $extraConfig + */ + private function makeClient(FakeHttpClient $http, array $extraConfig = []): FcmHttpV1Client + { + return new FcmHttpV1Client( + $http, + $this->psr17, + $this->psr17, + new StaticTokenProvider('test-token'), + null, + array_merge(['project_id' => 'demo'], $extraConfig) + ); + } +} diff --git a/tests/Factory/FcmClientFactoryTest.php b/tests/Factory/FcmClientFactoryTest.php new file mode 100644 index 0000000..23fed4b --- /dev/null +++ b/tests/Factory/FcmClientFactoryTest.php @@ -0,0 +1,43 @@ + 'demo'] + ); + + $this->assertInstanceOf(FcmHttpV1Client::class, $client); + } + + public function testCreatePropagatesConfigValidation(): void + { + $psr17 = new Psr17Factory(); + $this->expectException(\InvalidArgumentException::class); + FcmClientFactory::create( + new FakeHttpClient(new Response(200, [], '{}')), + $psr17, + $psr17, + new StaticTokenProvider(), + null, + [] + ); + } +} diff --git a/tests/Platform/AndroidOptionsTest.php b/tests/Platform/AndroidOptionsTest.php new file mode 100644 index 0000000..1944a66 --- /dev/null +++ b/tests/Platform/AndroidOptionsTest.php @@ -0,0 +1,131 @@ +assertSame('HIGH', AndroidOptions::make()->withPriority('high')->toArray()['priority']); + $this->assertSame('HIGH', AndroidOptions::make()->withPriority('MAX')->toArray()['priority']); + $this->assertSame('NORMAL', AndroidOptions::make()->withPriority('default')->toArray()['priority']); + $this->assertSame('NORMAL', AndroidOptions::make()->withPriority('low')->toArray()['priority']); + } + + public function testPriorityRejectsUnknownValue(): void + { + $this->expectException(\InvalidArgumentException::class); + AndroidOptions::make()->withPriority('URGENT'); + } + + public function testChannelIdEmittedExactlyOnce(): void + { + $out = AndroidOptions::make()->withChannelId('chan-1')->toArray(); + + $this->assertSame('chan-1', $out['notification']['channel_id']); + $encoded = json_encode($out); + $this->assertSame(1, substr_count($encoded, '"channel_id"')); + } + + public function testTtlIsSecondsString(): void + { + $out = AndroidOptions::make()->withTtl(120)->toArray(); + $this->assertSame('120s', $out['ttl']); + } + + public function testNegativeTtlClampsToZero(): void + { + $out = AndroidOptions::make()->withTtl(-30)->toArray(); + $this->assertSame('0s', $out['ttl']); + } + + public function testWithNotificationMergesIntoNotificationMap(): void + { + $out = AndroidOptions::make() + ->withNotification(['icon' => 'ic_launcher', 'color' => '#FF0000']) + ->withNotification(['color' => '#00FF00', 'sound' => 'chime']) + ->toArray(); + + $this->assertSame('ic_launcher', $out['notification']['icon']); + $this->assertSame('#00FF00', $out['notification']['color']); + $this->assertSame('chime', $out['notification']['sound']); + } + + public function testChannelIdCoexistsWithNotificationMap(): void + { + $out = AndroidOptions::make() + ->withNotification(['icon' => 'ic_launcher']) + ->withChannelId('alerts') + ->toArray(); + + $this->assertSame('ic_launcher', $out['notification']['icon']); + $this->assertSame('alerts', $out['notification']['channel_id']); + } + + public function testWithDataReplacesPreviousData(): void + { + $out = AndroidOptions::make() + ->withData(['a' => '1']) + ->withData(['b' => '2']) + ->toArray(); + + $this->assertArrayNotHasKey('a', $out['data']); + $this->assertSame('2', $out['data']['b']); + } + + public function testWithExtraMergesIntoOutputRoot(): void + { + $out = AndroidOptions::make() + ->withPriority('high') + ->withExtra(['restricted_package_name' => 'com.example.app']) + ->toArray(); + + $this->assertSame('HIGH', $out['priority']); + $this->assertSame('com.example.app', $out['restricted_package_name']); + } + + public function testMergePrefersOtherForScalarsAndMergesMaps(): void + { + $a = AndroidOptions::make() + ->withChannelId('a-chan') + ->withPriority('normal') + ->withTtl(60) + ->withCollapseKey('k-a') + ->withNotification(['icon' => 'a', 'color' => '#111']) + ->withData(['x' => '1', 'y' => '2']) + ->withExtra(['extra_a' => true]); + + $b = AndroidOptions::make() + ->withPriority('high') + ->withNotification(['color' => '#222', 'sound' => 'b']) + ->withData(['y' => '99', 'z' => '3']) + ->withExtra(['extra_b' => true]); + + $merged = $a->merge($b)->toArray(); + + $this->assertSame('HIGH', $merged['priority']); + $this->assertSame('60s', $merged['ttl']); + $this->assertSame('k-a', $merged['collapse_key']); + $this->assertSame('a', $merged['notification']['icon']); + $this->assertSame('#222', $merged['notification']['color']); + $this->assertSame('b', $merged['notification']['sound']); + $this->assertSame('a-chan', $merged['notification']['channel_id']); + $this->assertSame(['x' => '1', 'y' => '99', 'z' => '3'], $merged['data']); + $this->assertTrue($merged['extra_a']); + $this->assertTrue($merged['extra_b']); + } + + public function testMergeOtherOverridesChannelAndTtl(): void + { + $a = AndroidOptions::make()->withChannelId('a')->withTtl(10); + $b = AndroidOptions::make()->withChannelId('b')->withTtl(99); + + $merged = $a->merge($b)->toArray(); + + $this->assertSame('b', $merged['notification']['channel_id']); + $this->assertSame('99s', $merged['ttl']); + } +} diff --git a/tests/Platform/ApnsOptionsTest.php b/tests/Platform/ApnsOptionsTest.php new file mode 100644 index 0000000..c8c80d5 --- /dev/null +++ b/tests/Platform/ApnsOptionsTest.php @@ -0,0 +1,92 @@ +assertSame([], ApnsOptions::make()->toArray()); + } + + public function testHeadersOnlyEmitsHeadersKey(): void + { + $out = ApnsOptions::make() + ->withHeaders(['apns-priority' => '10', 'apns-push-type' => 'alert']) + ->toArray(); + + $this->assertSame([ + 'headers' => ['apns-priority' => '10', 'apns-push-type' => 'alert'], + ], $out); + $this->assertArrayNotHasKey('body', $out); + } + + public function testApsIsNestedUnderBody(): void + { + $out = ApnsOptions::make() + ->withAps(['alert' => ['title' => 'Hi', 'body' => 'There'], 'sound' => 'default']) + ->toArray(); + + $this->assertSame([ + 'alert' => ['title' => 'Hi', 'body' => 'There'], + 'sound' => 'default', + ], $out['body']['aps']); + } + + public function testCustomPayloadMergesAtBodyRoot(): void + { + $out = ApnsOptions::make() + ->withAps(['alert' => ['title' => 't']]) + ->withCustom(['correlation_id' => 'abc123', 'nested' => ['k' => 'v']]) + ->toArray(); + + $this->assertSame('abc123', $out['body']['correlation_id']); + $this->assertSame(['k' => 'v'], $out['body']['nested']); + $this->assertSame(['title' => 't'], $out['body']['aps']['alert']); + } + + public function testWithHeadersMergesRecursively(): void + { + $opts = ApnsOptions::make() + ->withHeaders(['apns-priority' => '10']) + ->withHeaders(['apns-expiration' => '0']); + + $out = $opts->toArray(); + $this->assertSame('10', $out['headers']['apns-priority']); + $this->assertSame('0', $out['headers']['apns-expiration']); + } + + public function testMergePrefersOtherForScalarsAndMergesMaps(): void + { + $a = ApnsOptions::make() + ->withHeaders(['apns-priority' => '5']) + ->withAps(['sound' => 'soft', 'badge' => 1]) + ->withCustom(['trace' => 'A']); + + $b = ApnsOptions::make() + ->withHeaders(['apns-priority' => '10']) + ->withAps(['badge' => 2]) + ->withCustom(['trace' => 'B', 'extra' => true]); + + $merged = $a->merge($b)->toArray(); + + $this->assertSame('10', $merged['headers']['apns-priority']); + $this->assertSame('soft', $merged['body']['aps']['sound']); + $this->assertSame(2, $merged['body']['aps']['badge']); + $this->assertSame('B', $merged['body']['trace']); + $this->assertTrue($merged['body']['extra']); + } + + public function testCustomWithoutApsStillProducesBody(): void + { + $out = ApnsOptions::make() + ->withCustom(['only' => 'custom']) + ->toArray(); + + $this->assertSame(['only' => 'custom'], $out['body']); + $this->assertArrayNotHasKey('aps', $out['body']); + } +} diff --git a/tests/Registry/ClientRegistryTest.php b/tests/Registry/ClientRegistryTest.php new file mode 100644 index 0000000..6be5f2c --- /dev/null +++ b/tests/Registry/ClientRegistryTest.php @@ -0,0 +1,99 @@ +makeClient(); + $b = $this->makeClient(); + + $registry->register('a', $a); + $registry->register('b', $b); + + $this->assertSame('a', $registry->defaultName()); + $this->assertSame($a, $registry->client()); + $this->assertSame($b, $registry->client('b')); + } + + public function testAsDefaultSwitchesDefault(): void + { + $registry = new ClientRegistry(); + $registry->register('a', $this->makeClient()); + $b = $this->makeClient(); + $registry->register('b', $b, true); + + $this->assertSame('b', $registry->defaultName()); + $this->assertSame($b, $registry->client()); + } + + public function testUnknownClientThrows(): void + { + $registry = new ClientRegistry(); + $this->expectException(\RuntimeException::class); + $registry->client('missing'); + } + + public function testRemoveClearsDefault(): void + { + $registry = new ClientRegistry(); + $registry->register('a', $this->makeClient()); + $this->assertTrue($registry->remove('a')); + + $this->assertFalse($registry->has('a')); + $this->assertNull($registry->defaultName()); + } + + public function testRemoveUnknownReturnsFalse(): void + { + $registry = new ClientRegistry(); + $this->assertFalse($registry->remove('missing')); + } + + public function testSetDefaultSwitchesDefault(): void + { + $registry = new ClientRegistry(); + $registry->register('a', $this->makeClient()); + $b = $this->makeClient(); + $registry->register('b', $b); + + $registry->setDefault('b'); + $this->assertSame('b', $registry->defaultName()); + $this->assertSame($b, $registry->client()); + } + + public function testSetDefaultUnknownThrows(): void + { + $registry = new ClientRegistry(); + $this->expectException(\RuntimeException::class); + $registry->setDefault('missing'); + } + + public function testNamesListsRegisteredClients(): void + { + $registry = new ClientRegistry(); + $registry->register('a', $this->makeClient()); + $registry->register('b', $this->makeClient()); + + $this->assertSame(['a', 'b'], $registry->names()); + } + + private function makeClient(): PushClient + { + return new class implements PushClient { + public function send(PushMessage $message, PushTarget $target): PushResult + { + return new PushResult(true); + } + }; + } +} diff --git a/tests/Support/FakeHttpClient.php b/tests/Support/FakeHttpClient.php new file mode 100644 index 0000000..f770721 --- /dev/null +++ b/tests/Support/FakeHttpClient.php @@ -0,0 +1,61 @@ + */ + private $queue; + + /** @var RequestInterface|null */ + public $lastRequest = null; + + /** @var string|null */ + public $lastBody = null; + + /** @var int */ + public $callCount = 0; + + /** @var array */ + public $capturedBodies = []; + + /** + * @param ResponseInterface|\Throwable|array $responses + */ + public function __construct($responses) + { + $this->queue = is_array($responses) ? array_values($responses) : [$responses]; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->callCount++; + $this->lastRequest = $request; + $this->lastBody = (string) $request->getBody(); + $this->capturedBodies[] = $this->lastBody; + + if (empty($this->queue)) { + throw new \RuntimeException('FakeHttpClient: no more responses queued'); + } + + $next = array_shift($this->queue); + + if ($next instanceof \Throwable) { + if ($next instanceof ClientExceptionInterface) { + throw $next; + } + throw $next; + } + + return $next; + } +} diff --git a/tests/Support/InMemoryCache.php b/tests/Support/InMemoryCache.php new file mode 100644 index 0000000..d3a2544 --- /dev/null +++ b/tests/Support/InMemoryCache.php @@ -0,0 +1,90 @@ + */ + private $store = []; + + /** @var int */ + public $writes = 0; + + /** @var int */ + public $reads = 0; + + public function get($key, $default = null) + { + $this->reads++; + if (!isset($this->store[$key])) { + return $default; + } + $entry = $this->store[$key]; + if ($entry['expires_at'] !== null && $entry['expires_at'] <= time()) { + unset($this->store[$key]); + return $default; + } + return $entry['value']; + } + + public function set($key, $value, $ttl = null): bool + { + $this->writes++; + $expiresAt = null; + if ($ttl instanceof DateInterval) { + $expiresAt = (new \DateTimeImmutable())->add($ttl)->getTimestamp(); + } elseif (is_int($ttl)) { + $expiresAt = time() + $ttl; + } + $this->store[$key] = ['value' => $value, 'expires_at' => $expiresAt]; + return true; + } + + public function delete($key): bool + { + unset($this->store[$key]); + return true; + } + + public function clear(): bool + { + $this->store = []; + return true; + } + + public function getMultiple($keys, $default = null): iterable + { + $out = []; + foreach ($keys as $k) { + $out[$k] = $this->get($k, $default); + } + return $out; + } + + public function setMultiple($values, $ttl = null): bool + { + foreach ($values as $k => $v) { + $this->set($k, $v, $ttl); + } + return true; + } + + public function deleteMultiple($keys): bool + { + foreach ($keys as $k) { + $this->delete($k); + } + return true; + } + + public function has($key): bool + { + return $this->get($key, '__miss__') !== '__miss__'; + } +} diff --git a/tests/Support/StaticTokenProvider.php b/tests/Support/StaticTokenProvider.php new file mode 100644 index 0000000..fb5809d --- /dev/null +++ b/tests/Support/StaticTokenProvider.php @@ -0,0 +1,21 @@ +token = $token; + } + + public function getToken(): string + { + return $this->token; + } +}