From 35c0eda1cdbd657448c5a5787212b0c277666f7b Mon Sep 17 00:00:00 2001 From: James Drew Date: Fri, 21 Aug 2026 00:02:18 +0100 Subject: [PATCH 01/11] Add test coverage for all PushReceiptErrorCode cases See #95 --- tests/Unit/Request/GetReceiptsRequestTest.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/Unit/Request/GetReceiptsRequestTest.php b/tests/Unit/Request/GetReceiptsRequestTest.php index cbe5cb0..9f4a9bf 100644 --- a/tests/Unit/Request/GetReceiptsRequestTest.php +++ b/tests/Unit/Request/GetReceiptsRequestTest.php @@ -10,6 +10,7 @@ use Dru1x\ExpoPush\PushReceipt\PushReceiptIdCollection; use Dru1x\ExpoPush\Request\GetReceiptsRequest; use OverflowException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Saloon\Config; @@ -167,6 +168,52 @@ public function create_dto_from_response_maps_unrecognized_error_code_to_unknown $this->assertEquals(PushReceiptErrorCode::Unknown, $receipt->details->error); } + #[Test] + #[DataProvider('knownErrorCodeProvider')] + public function create_dto_from_response_maps_known_error_code(string $expoErrorString, PushReceiptErrorCode $expectedCode): void + { + $request = new GetReceiptsRequest( + new PushReceiptIdCollection(), + ); + + $this->mockClient->addResponses([ + GetReceiptsRequest::class => MockResponse::make( + body: [ + 'data' => [ + 'ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ' => [ + 'status' => 'error', + 'message' => '"ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]" is not a registered push notification recipient', + 'details' => [ + 'error' => $expoErrorString, + 'expoPushToken' => 'ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]', + ], + ], + ], + ], + headers: ['Content-Type' => 'application/json'], + ), + ]); + + $dto = $request->createDtoFromResponse( + $this->connector->send($request), + ); + + $receipt = $dto->get(0); + $this->assertInstanceOf(FailedPushReceipt::class, $receipt); + $this->assertEquals($expectedCode, $receipt->details->error); + } + + public static function knownErrorCodeProvider(): array + { + return [ + 'DeviceNotRegistered' => ['DeviceNotRegistered', PushReceiptErrorCode::DeviceNotRegistered], + 'InvalidCredentials' => ['InvalidCredentials', PushReceiptErrorCode::InvalidCredentials], + 'MessageRateExceeded' => ['MessageRateExceeded', PushReceiptErrorCode::MessageRateExceeded], + 'MessageTooBig' => ['MessageTooBig', PushReceiptErrorCode::MessageTooBig], + 'MismatchSenderId' => ['MismatchSenderId', PushReceiptErrorCode::MismatchSenderId], + ]; + } + #[Test] public function create_dto_from_response_throws_exception_when_body_cannot_be_decoded(): void { From dcb8cf54556e4558cac0e23c5310b117066ddbac Mon Sep 17 00:00:00 2001 From: James Drew Date: Fri, 21 Aug 2026 00:05:58 +0100 Subject: [PATCH 02/11] Add unit tests for PushTokenCollection::shift() See #96 --- .../PushToken/PushTokenCollectionTest.php | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/Unit/PushToken/PushTokenCollectionTest.php b/tests/Unit/PushToken/PushTokenCollectionTest.php index 43bf364..3d5a91e 100644 --- a/tests/Unit/PushToken/PushTokenCollectionTest.php +++ b/tests/Unit/PushToken/PushTokenCollectionTest.php @@ -5,6 +5,7 @@ use ArrayIterator; use Dru1x\ExpoPush\PushToken\PushToken; use Dru1x\ExpoPush\PushToken\PushTokenCollection; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Traversable; @@ -131,6 +132,102 @@ public function count_returns_correct_push_token_count(): void $this->assertCount(3, $collection); } + #[Test] + public function shift_with_negative_count_throws_exception(): void + { + $collection = new PushTokenCollection( + new PushToken('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]'), + ); + + $this->expectException(InvalidArgumentException::class); + + $collection->shift(-1); + } + + #[Test] + public function shift_with_zero_count_returns_empty_collection_without_mutating_source(): void + { + $token1 = new PushToken('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]'); + $token2 = new PushToken('ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]'); + $token3 = new PushToken('ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]'); + + $collection = new PushTokenCollection($token1, $token2, $token3); + + $shifted = $collection->shift(0); + + $this->assertTrue($shifted->isEmpty()); + + $this->assertCount(3, $collection); + $this->assertEquals($token1, $collection->get(0)); + $this->assertEquals($token2, $collection->get(1)); + $this->assertEquals($token3, $collection->get(2)); + } + + #[Test] + public function shift_from_empty_collection_returns_empty_collection(): void + { + $collection = new PushTokenCollection(); + + $shifted = $collection->shift(); + + $this->assertTrue($shifted->isEmpty()); + } + + #[Test] + public function shift_with_count_greater_than_available_returns_all_items(): void + { + $token1 = new PushToken('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]'); + $token2 = new PushToken('ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]'); + + $collection = new PushTokenCollection($token1, $token2); + + $shifted = $collection->shift(5); + + $this->assertCount(2, $shifted); + $this->assertEquals($token1, $shifted->get(0)); + $this->assertEquals($token2, $shifted->get(1)); + + $this->assertTrue($collection->isEmpty()); + } + + #[Test] + public function shift_removes_and_returns_leading_items_leaving_remainder_in_source(): void + { + $token1 = new PushToken('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]'); + $token2 = new PushToken('ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]'); + $token3 = new PushToken('ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]'); + $token4 = new PushToken('ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]'); + + $collection = new PushTokenCollection($token1, $token2, $token3, $token4); + + $shifted = $collection->shift(2); + + $this->assertCount(2, $shifted); + $this->assertEquals($token1, $shifted->get(0)); + $this->assertEquals($token2, $shifted->get(1)); + + $this->assertCount(2, $collection); + $this->assertEquals($token3, $collection->get(0)); + $this->assertEquals($token4, $collection->get(1)); + } + + #[Test] + public function shift_with_default_count_removes_single_item(): void + { + $token1 = new PushToken('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]'); + $token2 = new PushToken('ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]'); + + $collection = new PushTokenCollection($token1, $token2); + + $shifted = $collection->shift(); + + $this->assertCount(1, $shifted); + $this->assertEquals($token1, $shifted->get(0)); + + $this->assertCount(1, $collection); + $this->assertEquals($token2, $collection->get(0)); + } + #[Test] public function chunk_returns_correctly_sized_chunks(): void { From c16f39eb20033a2a614898334be275a46e686f1d Mon Sep 17 00:00:00 2001 From: James Drew Date: Fri, 21 Aug 2026 00:07:51 +0100 Subject: [PATCH 03/11] Add unit tests for CollectionMethods::doesntContain() See #97 --- tests/Unit/Support/CollectionTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Unit/Support/CollectionTest.php b/tests/Unit/Support/CollectionTest.php index cc7c4e5..deb3a4e 100644 --- a/tests/Unit/Support/CollectionTest.php +++ b/tests/Unit/Support/CollectionTest.php @@ -42,6 +42,16 @@ public function can_check_if_a_collection_contains_a_given_value(mixed $item, bo ); } + #[Test] + #[DataProvider('doesntContainProvider')] + public function can_check_if_a_collection_doesnt_contain_a_given_value(mixed $item, bool $expected): void + { + $this->assertSame( + $expected, + Collection::make(1, 2)->doesntContain($item), + ); + } + #[Test] #[DataProvider('getProvider')] public function can_retrieve_a_collection_value_by_its_key(int|string $key, mixed $value): void @@ -226,6 +236,15 @@ public static function containsProvider(): array ]; } + public static function doesntContainProvider(): array + { + return [ + 'does' => [2, false], + 'doesnt' => [3, true], + 'doesnt loose' => ['2', true], + ]; + } + public static function getProvider(): array { return [ From 5ddd20645fe5351ffd9309b04205713e0ea268d9 Mon Sep 17 00:00:00 2001 From: James Drew Date: Fri, 21 Aug 2026 00:22:03 +0100 Subject: [PATCH 04/11] Add feature test for invalid credentials response See #98 --- tests/Feature/SendNotificationsTest.php | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Feature/SendNotificationsTest.php b/tests/Feature/SendNotificationsTest.php index 90642b0..1e69547 100644 --- a/tests/Feature/SendNotificationsTest.php +++ b/tests/Feature/SendNotificationsTest.php @@ -355,6 +355,38 @@ public function send_notifications_exposes_push_errors_for_each_request_error(): $this->assertEquals(PushErrorCode::PushTooManyExperienceIds, $result->errors->get(0)->code); } + #[Test] + public function send_notifications_exposes_unauthorized_push_error_for_invalid_credentials(): void + { + $this->mockClient->addResponse( + MockResponse::make( + body: [ + 'errors' => [ + [ + 'code' => 'UNAUTHORIZED', + 'message' => 'Invalid credentials', + ], + ], + ], + status: 401, + headers: ['Content-Type' => 'application/json'], + ), + ); + + $messages = new PushMessageCollection( + new PushMessage(to: new PushToken('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]'), title: 'Test Notification'), + ); + + $result = $this->service->sendNotifications($messages); + + $this->mockClient->assertSentCount(1, SendNotificationsRequest::class); + + $this->assertTrue($result->hasErrors()); + $this->assertCount(1, $result->errors); + $this->assertEquals(PushErrorCode::Unauthorized, $result->errors->get(0)->code); + $this->assertCount(0, $result->tickets); + } + // Helpers ---- protected function generatePushMessage(): PushMessage From 8f0118d8d221be7f4f616970443bfdec741b74e5 Mon Sep 17 00:00:00 2001 From: James Drew Date: Fri, 21 Aug 2026 00:36:34 +0100 Subject: [PATCH 05/11] Add missing unit tests for PushMessage::fromArray() See #100 --- tests/Unit/PushMessage/PushMessageTest.php | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/Unit/PushMessage/PushMessageTest.php b/tests/Unit/PushMessage/PushMessageTest.php index 116ecb8..c20b821 100644 --- a/tests/Unit/PushMessage/PushMessageTest.php +++ b/tests/Unit/PushMessage/PushMessageTest.php @@ -8,6 +8,7 @@ use Dru1x\ExpoPush\PushMessage\RichContent; use Dru1x\ExpoPush\PushToken\PushToken; use Dru1x\ExpoPush\PushToken\PushTokenCollection; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use stdClass; @@ -210,6 +211,48 @@ public function from_array_with_dictionary_returns_instance(): void $this->assertSame('test-tag', $message->tag); } + #[Test] + public function from_array_throws_exception_when_to_is_missing(): void + { + $this->expectException(InvalidArgumentException::class); + + PushMessage::fromArray([ + 'title' => 'Test Notification', + ]); + } + + #[Test] + public function from_array_throws_exception_when_to_is_not_array_or_string(): void + { + $this->expectException(InvalidArgumentException::class); + + PushMessage::fromArray([ + 'to' => 123, + ]); + } + + #[Test] + public function from_array_converts_priority_to_enum(): void + { + $message = PushMessage::fromArray([ + 'to' => 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]', + 'priority' => 'high', + ]); + + $this->assertSame(Priority::High, $message->priority); + } + + #[Test] + public function from_array_converts_interruption_level_to_enum(): void + { + $message = PushMessage::fromArray([ + 'to' => 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]', + 'interruptionLevel' => 'critical', + ]); + + $this->assertSame(InterruptionLevel::Critical, $message->interruptionLevel); + } + #[Test] public function from_json_returns_instance(): void { From e74fd618da761e97c012d73a8b21362ffe22a97a Mon Sep 17 00:00:00 2001 From: James Drew Date: Fri, 21 Aug 2026 00:43:59 +0100 Subject: [PATCH 06/11] Correct test-coverage script --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 00d77d2..2df497e 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "@analyse" ], "test": "vendor/bin/phpunit --testdox", - "test-coverage": "@test --coverage-text coverage" + "test-coverage": "@test --coverage-text" }, "autoload": { "psr-4": { From 1bbd292da0b82ab0e231852aee76372ca9960013 Mon Sep 17 00:00:00 2001 From: James Drew Date: Fri, 21 Aug 2026 00:57:54 +0100 Subject: [PATCH 07/11] Add test coverage enforcement to CI See #99 --- .github/workflows/test.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 419ba79..c87db05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,11 @@ jobs: php_extensions: json zip xdebug configuration: phpunit.xml.dist coverage_text: true - env: - XDEBUG_MODE: coverage \ No newline at end of file + coverage_clover: coverage.xml + + - name: Enforce coverage threshold + if: matrix.php-version == '8.2' + uses: ericsizemore/phpunit-coverage-check-action@2.0.0 + with: + clover_file: 'coverage.xml' + threshold: '100' From 51ef297d7baaf6e9db7c810c5d7e6228d2f68f4b Mon Sep 17 00:00:00 2001 From: James Drew Date: Sun, 12 Jul 2026 19:02:38 +0100 Subject: [PATCH 08/11] Correct extensions used when running test workflow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c87db05..ab69c45 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: uses: php-actions/phpunit@v4 with: php_version: ${{matrix.php-version}} - php_extensions: json zip xdebug + php_extensions: json zlib xdebug configuration: phpunit.xml.dist coverage_text: true coverage_clover: coverage.xml From 8b8aa26af47ff31a1edbcca620c7807080ea35a5 Mon Sep 17 00:00:00 2001 From: James Drew Date: Sun, 12 Jul 2026 18:19:23 +0100 Subject: [PATCH 09/11] Move SuccessfulPushReceiptTest into the correct namespace --- .../{PushTicket => PushReceipt}/SuccessfulPushReceiptTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/Unit/{PushTicket => PushReceipt}/SuccessfulPushReceiptTest.php (96%) diff --git a/tests/Unit/PushTicket/SuccessfulPushReceiptTest.php b/tests/Unit/PushReceipt/SuccessfulPushReceiptTest.php similarity index 96% rename from tests/Unit/PushTicket/SuccessfulPushReceiptTest.php rename to tests/Unit/PushReceipt/SuccessfulPushReceiptTest.php index 915b13a..96fec69 100644 --- a/tests/Unit/PushTicket/SuccessfulPushReceiptTest.php +++ b/tests/Unit/PushReceipt/SuccessfulPushReceiptTest.php @@ -1,6 +1,6 @@ Date: Wed, 26 Aug 2026 00:27:51 +0100 Subject: [PATCH 10/11] Set XDEBUG_MODE to "coverage" when running tests --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab69c45..cda4795 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,8 @@ jobs: configuration: phpunit.xml.dist coverage_text: true coverage_clover: coverage.xml + env: + XDEBUG_MODE: coverage - name: Enforce coverage threshold if: matrix.php-version == '8.2' From 55c2312cbb622765a553e9de7be31c7b996ed8a6 Mon Sep 17 00:00:00 2001 From: James Drew Date: Fri, 4 Sep 2026 12:34:07 +0100 Subject: [PATCH 11/11] Add tests to cover invalid priority, interruptionLevel and richContent --- tests/Unit/PushMessage/PushMessageTest.php | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/Unit/PushMessage/PushMessageTest.php b/tests/Unit/PushMessage/PushMessageTest.php index c20b821..b508b2c 100644 --- a/tests/Unit/PushMessage/PushMessageTest.php +++ b/tests/Unit/PushMessage/PushMessageTest.php @@ -231,6 +231,17 @@ public function from_array_throws_exception_when_to_is_not_array_or_string(): vo ]); } + #[Test] + public function from_array_throws_exception_when_rich_content_is_not_array(): void + { + $this->expectException(TypeError::class); + + PushMessage::fromArray([ + 'to' => 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]', + 'richContent' => 'not-an-array', + ]); + } + #[Test] public function from_array_converts_priority_to_enum(): void { @@ -242,6 +253,17 @@ public function from_array_converts_priority_to_enum(): void $this->assertSame(Priority::High, $message->priority); } + #[Test] + public function from_array_throws_exception_when_priority_is_not_valid(): void + { + $this->expectException(TypeError::class); + + PushMessage::fromArray([ + 'to' => 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]', + 'priority' => [], + ]); + } + #[Test] public function from_array_converts_interruption_level_to_enum(): void { @@ -253,6 +275,17 @@ public function from_array_converts_interruption_level_to_enum(): void $this->assertSame(InterruptionLevel::Critical, $message->interruptionLevel); } + #[Test] + public function from_array_throws_exception_when_interruption_level_is_not_valid(): void + { + $this->expectException(TypeError::class); + + PushMessage::fromArray([ + 'to' => 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]', + 'interruptionLevel' => [], + ]); + } + #[Test] public function from_json_returns_instance(): void {