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();
+ }
}