diff --git a/composer.json b/composer.json index bd666fc..35a30bc 100644 --- a/composer.json +++ b/composer.json @@ -28,11 +28,13 @@ "require": { "php": "^8.1", "illuminate/database": "^9|^10", - "illuminate/support": "^9|^10" + "illuminate/support": "^9|^10", + "nesbot/carbon": ">=2.62.1" }, "require-dev": { "ciareis/bypass": "^1.0", "dg/bypass-finals": "^1.4", + "guzzlehttp/guzzle": "^7.8", "laravel/pint": "^1.3", "orchestra/testbench": "7.*|8.*", "pestphp/pest": "^1.21", diff --git a/phpunit.xml b/phpunit.xml index a86411a..57cf6e7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,4 +19,9 @@ src/ + + + + + diff --git a/src/Contracts/ExpoNotificationsServiceInterface.php b/src/Contracts/ExpoNotificationsServiceInterface.php new file mode 100644 index 0000000..6751df2 --- /dev/null +++ b/src/Contracts/ExpoNotificationsServiceInterface.php @@ -0,0 +1,24 @@ + 'gzip, deflate', 'content-type' => 'application/json', ])->baseUrl($apiUrl); + + $this->tickets = collect(); } /** * @param ExpoMessage|ExpoMessage[]|Collection $expoMessages * @return Collection - * - * @throws ExpoNotificationsException */ public function notify(ExpoMessage|Collection|array $expoMessages): Collection { /** @var Collection $expoMessages */ - $expoMessages = $expoMessages instanceof Collection ? $expoMessages : collect(Arr::wrap($expoMessages)); - - $shouldBatchFilter = fn (ExpoMessage $message) => $message->shouldBatch; - - // Store notifications to send in the next batch - $expoMessages - ->filter($shouldBatchFilter) - ->each(fn (ExpoMessage $message) => $this->notificationStorage->store($message)); - - // Filter notifications to send now - $toSend = $expoMessages - ->reject($shouldBatchFilter) - ->map(fn (ExpoMessage $message) => $message->toExpoData()) - ->values(); - - if ($toSend->isEmpty()) { - return collect(); - } - - $response = $this->http->post('/send', $toSend->toArray()); - if (! $response->successful()) { - throw new ExpoNotificationsException($response->toPsrResponse()); - } - - $data = json_decode($response->body(), true); - if (! empty($data['errors'])) { - throw new ExpoNotificationsException($response->toPsrResponse()); - } - - $tickets = collect($data['data'])->map(function ($responseItem) { - if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) { - return (new PushTicketResponse()) - ->status($responseItem['status']) - ->message($responseItem['message']) - ->details($responseItem['details']); - } - - return (new PushTicketResponse()) - ->status($responseItem['status']) - ->ticketId($responseItem['id']); - }); + $this->expoMessages = $expoMessages instanceof Collection ? $expoMessages : collect(Arr::wrap($expoMessages)); - $this->checkAndStoreTickets($toSend->pluck('to')->flatten(), $tickets); - - return $tickets; + return $this->storeNotificationsToSendInTheNextBatch() + ->prepareNotificationsToSendNow() + ->sendNotifications(); } /** @@ -130,13 +104,17 @@ public function receipts(Collection|array $tokenIds): Collection }); } + public function getNotificationChunks(): Collection + { + return $this->notificationChunks ?? collect(); + } + /** * @param Collection $tokens - * @param Collection $tickets */ - private function checkAndStoreTickets(Collection $tokens, Collection $tickets): void + private function checkAndStoreTickets(Collection $tokens): void { - $tickets + $this->tickets ->intersectByKeys($tokens) ->each(function (PushTicketResponse $ticket, $index) use ($tokens) { if ($ticket->status === ExpoResponseStatus::ERROR->value) { @@ -152,4 +130,86 @@ private function checkAndStoreTickets(Collection $tokens, Collection $tickets): } }); } + + private function storeNotificationsToSendInTheNextBatch(): ExpoNotificationsService + { + $this->expoMessages + ->filter(fn (ExpoMessage $message) => $message->shouldBatch) + ->each(fn (ExpoMessage $message) => $this->notificationStorage->store($message)); + + return $this; + } + + private function prepareNotificationsToSendNow(): ExpoNotificationsService + { + $this->notificationsToSend = $this->expoMessages + ->reject(fn (ExpoMessage $message) => $message->shouldBatch) + ->map(fn (ExpoMessage $message) => $message->toExpoData()) + ->values(); + + // Splits into multiples chunks of max limitation + $this->notificationChunks = $this->notificationsToSend->chunk(self::PUSH_NOTIFICATIONS_PER_REQUEST_LIMIT); + + return $this; + } + + private function sendNotifications(): Collection + { + if ($this->notificationsToSend->isEmpty()) { + return collect(); + } + + $this->notificationChunks + ->each( + fn ($chunk, $index) => $this->sendNotificationsChunk($chunk->toArray()) + ); + + $this->checkAndStoreTickets($this->notificationsToSend->pluck('to')->flatten()); + + return $this->tickets; + } + + private function handleSendNotificationsResponse(Response $response): void + { + $data = json_decode($response->body(), true, 512, JSON_THROW_ON_ERROR); + if (! empty($data['errors'])) { + throw new ExpoNotificationsException($response->toPsrResponse()); + } + + $this->setTicketsFromData($data); + } + + private function setTicketsFromData(array $data): ExpoNotificationsService + { + collect($data['data']) + ->each(function ($responseItem) { + if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) { + $this->tickets->push( + (new PushTicketResponse()) + ->status($responseItem['status']) + ->message($responseItem['message']) + ->details($responseItem['details']) + ); + } else { + $this->tickets->push( + (new PushTicketResponse()) + ->status($responseItem['status']) + ->ticketId($responseItem['id']) + ); + } + }); + + return $this; + } + + private function sendNotificationsChunk(array $chunk) + { + $response = $this->http->post(self::SEND_NOTIFICATION_ENDPOINT, $chunk); + + if (! $response->successful()) { + throw new ExpoNotificationsException($response->toPsrResponse()); + } + + $this->handleSendNotificationsResponse($response); + } } diff --git a/src/ExpoNotificationsServiceProvider.php b/src/ExpoNotificationsServiceProvider.php index 8549d87..6dd8d0c 100644 --- a/src/ExpoNotificationsServiceProvider.php +++ b/src/ExpoNotificationsServiceProvider.php @@ -8,6 +8,7 @@ use Illuminate\Support\ServiceProvider; use YieldStudio\LaravelExpoNotifier\Commands\CheckTickets; use YieldStudio\LaravelExpoNotifier\Commands\SendPendingNotifications; +use YieldStudio\LaravelExpoNotifier\Contracts\ExpoNotificationsServiceInterface; use YieldStudio\LaravelExpoNotifier\Contracts\ExpoPendingNotificationStorageInterface; use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTicketStorageInterface; use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTokenStorageInterface; @@ -22,7 +23,7 @@ public function register(): void $this->app->bind(ExpoTicketStorageInterface::class, config('expo-notifications.drivers.ticket')); $this->app->bind(ExpoPendingNotificationStorageInterface::class, config('expo-notifications.drivers.notification')); - $this->app->bind(ExpoNotificationsService::class, function ($app) { + $this->app->bind(ExpoNotificationsServiceInterface::class, function ($app) { $apiUrl = config('expo-notifications.service.api_url'); $host = config('expo-notifications.service.host'); diff --git a/src/FakeExpoNotificationsService.php b/src/FakeExpoNotificationsService.php new file mode 100644 index 0000000..00e5d24 --- /dev/null +++ b/src/FakeExpoNotificationsService.php @@ -0,0 +1,184 @@ +tickets = collect(); + } + + public function notify(ExpoMessage|Collection|array $expoMessages): Collection + { + /** @var Collection $expoMessages */ + $this->expoMessages = $expoMessages instanceof Collection ? $expoMessages : collect(Arr::wrap($expoMessages)); + + return $this->storeNotificationsToSendInTheNextBatch() + ->prepareNotificationsToSendNow() + ->sendNotifications(); + } + + public function receipts(array|Collection $tokenIds): Collection + { + // TODO: Implement receipts() method. + } + + public function getNotificationChunks(): Collection + { + return $this->notificationChunks ?? collect(); + } + + private function prepareNotificationsToSendNow(): FakeExpoNotificationsService + { + $this->notificationsToSend = $this->expoMessages + ->reject(fn (ExpoMessage $message) => $message->shouldBatch) + ->map(fn (ExpoMessage $message) => $message->toExpoData()) + ->values(); + + // Splits into multiples chunks of max limitation + $this->notificationChunks = $this->notificationsToSend->chunk(self::PUSH_NOTIFICATIONS_PER_REQUEST_LIMIT); + + return $this; + } + + private function storeNotificationsToSendInTheNextBatch(): FakeExpoNotificationsService + { + $this->expoMessages + ->filter(fn (ExpoMessage $message) => $message->shouldBatch) + ->each(fn (ExpoMessage $message) => $this->notificationStorage->store($message)); + + return $this; + } + + private function sendNotifications(): Collection + { + if ($this->notificationsToSend->isEmpty()) { + return collect(); + } + + $this->notificationChunks + ->each( + fn ($chunk, $index) => $this->sendNotificationsChunk($chunk->toArray(), $index) + ); + + $this->checkAndStoreTickets($this->notificationsToSend->pluck('to')->flatten()); + + return $this->tickets; + } + + private function sendNotificationsChunk(array $chunk, int $chunkId): void + { + $data = []; + foreach ($chunk as $notification) { + $data[] = [ + 'id' => Str::orderedUuid()->toString(), + 'status' => ExpoResponseStatus::OK->value, + '__notification' => $notification, + ]; + } + + $response = Http::fake([ + 'api-push/'.$chunkId => Http::response([ + 'data' => $data, + ]), + ])->get('/api-push/'.$chunkId); + + if (! $response->successful()) { + throw new ExpoNotificationsException($response->toPsrResponse()); + } + + $this->handleSendNotificationsResponse($response); + } + + private function handleSendNotificationsResponse(Response $response): void + { + $data = json_decode($response->body(), true, 512, JSON_THROW_ON_ERROR); + if (! empty($data['errors'])) { + throw new ExpoNotificationsException($response->toPsrResponse()); + } + + $this->setTicketsFromData($data); + } + + private function setTicketsFromData(array $data): FakeExpoNotificationsService + { + collect($data['data']) + ->each(function ($responseItem) { + if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) { + $this->tickets->push( + (new PushTicketResponse()) + ->status($responseItem['status']) + ->message($responseItem['message']) + ->details($responseItem['details']) + ); + } else { + $this->tickets->push( + (new PushTicketResponse()) + ->status($responseItem['status']) + ->ticketId($responseItem['id']) + ); + } + }); + + return $this; + } + + /** + * @param Collection $tokens + */ + private function checkAndStoreTickets(Collection $tokens): void + { + $this->tickets + ->intersectByKeys($tokens) + ->each(function (PushTicketResponse $ticket, $index) use ($tokens) { + if ($ticket->status === ExpoResponseStatus::ERROR->value) { + if ( + is_array($ticket->details) && + array_key_exists('error', $ticket->details) && + $ticket->details['error'] === ExpoResponseStatus::DEVICE_NOT_REGISTERED->value + ) { + event(new InvalidExpoToken($tokens->get($index))); + } + } else { + $this->ticketStorage->store($ticket->ticketId, $tokens->get($index)); + } + }); + } +} diff --git a/src/Jobs/CheckTickets.php b/src/Jobs/CheckTickets.php index 445d8fe..b675e99 100644 --- a/src/Jobs/CheckTickets.php +++ b/src/Jobs/CheckTickets.php @@ -8,11 +8,11 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; +use YieldStudio\LaravelExpoNotifier\Contracts\ExpoNotificationsServiceInterface; use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTicketStorageInterface; use YieldStudio\LaravelExpoNotifier\Dto\ExpoTicket; use YieldStudio\LaravelExpoNotifier\Enums\ExpoResponseStatus; use YieldStudio\LaravelExpoNotifier\Events\InvalidExpoToken; -use YieldStudio\LaravelExpoNotifier\ExpoNotificationsService; class CheckTickets { @@ -21,7 +21,7 @@ class CheckTickets use SerializesModels; public function handle( - ExpoNotificationsService $expoNotificationsService, + ExpoNotificationsServiceInterface $expoNotificationsService, ExpoTicketStorageInterface $ticketStorage ): void { while ($ticketStorage->count() > 0) { diff --git a/src/Jobs/SendPendingNotifications.php b/src/Jobs/SendPendingNotifications.php index ebcad36..300f1a8 100644 --- a/src/Jobs/SendPendingNotifications.php +++ b/src/Jobs/SendPendingNotifications.php @@ -7,9 +7,9 @@ use Illuminate\Bus\Queueable; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\SerializesModels; +use YieldStudio\LaravelExpoNotifier\Contracts\ExpoNotificationsServiceInterface; use YieldStudio\LaravelExpoNotifier\Contracts\ExpoPendingNotificationStorageInterface; use YieldStudio\LaravelExpoNotifier\Dto\ExpoNotification; -use YieldStudio\LaravelExpoNotifier\ExpoNotificationsService; class SendPendingNotifications { @@ -18,7 +18,7 @@ class SendPendingNotifications use SerializesModels; public function handle( - ExpoNotificationsService $expoNotificationsService, + ExpoNotificationsServiceInterface $expoNotificationsService, ExpoPendingNotificationStorageInterface $expoNotification, ): void { $sent = collect(); diff --git a/tests/Feature/ExpoNotificationServiceTest.php b/tests/Feature/ExpoNotificationServiceTest.php new file mode 100644 index 0000000..65e56ef --- /dev/null +++ b/tests/Feature/ExpoNotificationServiceTest.php @@ -0,0 +1,35 @@ +messages = collect(); + + for ($i = 0; $i < 120; $i++) { + $this->messages->push( + (new ExpoMessage()) + ->to([Str::orderedUuid()->toString()]) + ->title("A beautiful title #$i") + ->body('This is a content') + ->channelId('default') + ); + } + $this->notificationService = app(ExpoNotificationsServiceInterface::class); +}); + +it("creates 2 chunks if we're sending 20 notifications above limit", function () { + $this->notificationService->notify($this->messages); + $count = $this->notificationService->getNotificationChunks()->count(); + + expect($count)->toBe(2) + ->and(app(ExpoTicketStorageInterface::class)->count()) + ->toBe($this->messages->count()); +}); diff --git a/tests/Feature/Storage/ExpoPendingNotificationStorageTest.php b/tests/Feature/Storage/ExpoPendingNotificationStorageTest.php new file mode 100644 index 0000000..bb788d0 --- /dev/null +++ b/tests/Feature/Storage/ExpoPendingNotificationStorageTest.php @@ -0,0 +1,46 @@ + json_encode([ + 'foo' => $this->fake()->slug, + ], JSON_THROW_ON_ERROR), + ]); + } + + $this->notifications = ExpoNotification::all(); + $this->notificationStorage = app(config('expo-notifications.drivers.notification')); +}); + +it('retrieves notifications from storage', function () { + $retrievedNotifications = $this->notificationStorage->retrieve(); + + expect($retrievedNotifications) + ->toBeInstanceOf(Collection::class) + ->and($retrievedNotifications->first()->id) + ->toBe($this->notifications->first()->id) + ->and($retrievedNotifications->first()->message->foo) + ->toBe(json_decode($this->notifications->first()->data, true, 512, JSON_THROW_ON_ERROR)['foo']) + ->and($retrievedNotifications->get(2)->id) + ->toBe($this->notifications->get(2)->id) + ->and($retrievedNotifications->get(2)->message->foo) + ->toBe(json_decode($this->notifications->get(2)->data, true, 512, JSON_THROW_ON_ERROR)['foo']); +}); + +it('retrieves a max of 100 notifications', function () { + $retrievedNotifications = $this->notificationStorage->retrieve(); + + expect($retrievedNotifications) + ->toBeInstanceOf(Collection::class) + ->and($retrievedNotifications->last()->id) + ->toBe(100); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 96660e3..14ff86f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,13 +4,68 @@ namespace YieldStudio\LaravelExpoNotifier\Tests; +use Faker\Factory; +use Faker\Generator; use Orchestra\Testbench\TestCase as Orchestra; +use YieldStudio\LaravelExpoNotifier\Contracts\ExpoNotificationsServiceInterface; +use YieldStudio\LaravelExpoNotifier\Contracts\ExpoPendingNotificationStorageInterface; +use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTicketStorageInterface; +use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTokenStorageInterface; use YieldStudio\LaravelExpoNotifier\ExpoNotificationsServiceProvider; +use YieldStudio\LaravelExpoNotifier\FakeExpoNotificationsService; +use YieldStudio\LaravelExpoNotifier\Storage\ExpoPendingNotificationStorageMysql; +use YieldStudio\LaravelExpoNotifier\Storage\ExpoTicketStorageMysql; +use YieldStudio\LaravelExpoNotifier\Storage\ExpoTokenStorageMysql; -abstract class TestCase extends Orchestra +class TestCase extends Orchestra { protected function getPackageProviders($app): array { return [ExpoNotificationsServiceProvider::class]; } + + protected function setUp(): void + { + parent::setUp(); + + $this->app->bind(ExpoTokenStorageInterface::class, config('expo-notifications.drivers.token')); + $this->app->bind(ExpoTicketStorageInterface::class, config('expo-notifications.drivers.ticket')); + $this->app->bind(ExpoPendingNotificationStorageInterface::class, config('expo-notifications.drivers.notification')); + + $this->app->bind(ExpoNotificationsServiceInterface::class, function ($app) { + return new FakeExpoNotificationsService( + 'http://localhost', // won't be used, just here to respect the contract + 'localhost', // won't be used, just here to respect the contract + $app->make(ExpoPendingNotificationStorageInterface::class), + $app->make(ExpoTicketStorageInterface::class) + ); + }); + } + + protected function getEnvironmentSetUp($app): void + { + // Setup default database to use sqlite :memory: + config()->set('database.default', 'testbench'); + config()->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + // Setup queue database connections. + config()->set('queue.batching.database', 'testbench'); + config()->set('queue.failed.database', 'testbench'); + + // Setup Expo configuration + config()->set('expo-notifications.drivers', [ + 'token' => ExpoTokenStorageMysql::class, + 'ticket' => ExpoTicketStorageMysql::class, + 'notification' => ExpoPendingNotificationStorageMysql::class, + ]); + } + + protected function fake(): Generator + { + return Factory::create(); + } }