feat: add persistent mobile notifications
CI / 🧪 Tests Laravel (push) Successful in 2m7s
CI / 🐳 Build & Push Image (push) Successful in 42s

This commit is contained in:
2026-08-11 16:46:05 +02:00
parent bd1b4bba20
commit df4f523089
24 changed files with 822 additions and 75 deletions
@@ -50,6 +50,15 @@ it('sends engagement reminders only to eligible mobile users', function () {
'platform' => 'ios',
]);
$optedOutUser = User::factory()->create([
'locale' => 'fr',
'engagement_reminders_enabled' => false,
]);
$optedOutUser->deviceTokens()->create([
'expo_push_token' => 'ExponentPushToken[opted-out]',
'platform' => 'ios',
]);
$this->artisan('notifications:send-engagement-reminders')
->expectsOutput('1 engagement reminder notification(s) sent.')
->assertSuccessful();
+144
View File
@@ -0,0 +1,144 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
function storeMobileNotification(User $user, array $data, ?string $readAt = null): string
{
$id = (string) Str::uuid();
DB::table('notifications')->insert([
'id' => $id,
'type' => $data['type'],
'notifiable_type' => User::class,
'notifiable_id' => $user->getKey(),
'data' => json_encode($data, JSON_THROW_ON_ERROR),
'read_at' => $readAt,
'created_at' => now(),
'updated_at' => now(),
]);
return $id;
}
it('lists paginated mobile notifications with the unread count', function () {
$user = User::factory()->create();
$readNotificationId = storeMobileNotification($user, [
'type' => 'new_follower',
'category' => 'social',
'title' => 'Nouvel abonné',
'body' => 'Alex vous suit maintenant.',
'url' => 'bowli://users/123',
], now()->toISOString());
$unreadNotificationId = storeMobileNotification($user, [
'type' => 'payment_failed',
'category' => 'account',
'title' => 'Paiement échoué',
'body' => 'Vérifiez votre moyen de paiement.',
'url' => 'bowli://profile/settings/billing',
]);
DB::table('notifications')->insert([
'id' => (string) Str::uuid(),
'type' => 'filament',
'notifiable_type' => User::class,
'notifiable_id' => $user->getKey(),
'data' => json_encode(['format' => 'filament'], JSON_THROW_ON_ERROR),
'read_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
Sanctum::actingAs($user);
$this->getJson('/api/notifications?per_page=1')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $unreadNotificationId)
->assertJsonPath('data.0.readAt', null)
->assertJsonPath('meta.total', 2)
->assertJsonPath('unreadCount', 1);
$this->getJson('/api/notifications?per_page=1&page=2')
->assertOk()
->assertJsonPath('data.0.id', $readNotificationId);
});
it('marks only the authenticated users notifications as read', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$firstId = storeMobileNotification($user, [
'type' => 'new_follower',
'category' => 'social',
'title' => 'Nouvel abonné',
'body' => 'Alex vous suit maintenant.',
'url' => 'bowli://users/123',
]);
storeMobileNotification($user, [
'type' => 'meal_commented',
'category' => 'social',
'title' => 'Nouveau commentaire',
'body' => 'Un commentaire.',
'url' => 'bowli://meals/123',
]);
$otherId = storeMobileNotification($otherUser, [
'type' => 'payment_failed',
'category' => 'account',
'title' => 'Paiement échoué',
'body' => 'Paiement échoué.',
'url' => 'bowli://profile/settings/billing',
]);
Sanctum::actingAs($user);
$this->patchJson("/api/notifications/{$otherId}/read")->assertNotFound();
$this->patchJson("/api/notifications/{$firstId}/read")
->assertOk()
->assertJsonPath('data.id', $firstId)
->assertJsonPath('data.readAt', fn ($value): bool => is_string($value));
$this->getJson('/api/notifications/unread-count')
->assertOk()
->assertJsonPath('data.count', 1);
$this->patchJson('/api/notifications/read-all')
->assertOk()
->assertJsonPath('data.updated', 1);
$this->getJson('/api/notifications/unread-count')
->assertOk()
->assertJsonPath('data.count', 0);
});
it('shows and updates notification preferences', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$this->getJson('/api/notification-preferences')
->assertOk()
->assertJsonPath('data.socialNotificationsEnabled', true)
->assertJsonPath('data.engagementRemindersEnabled', true);
$this->patchJson('/api/notification-preferences', [
'socialNotificationsEnabled' => false,
'engagementRemindersEnabled' => false,
])
->assertOk()
->assertJsonPath('data.socialNotificationsEnabled', false)
->assertJsonPath('data.engagementRemindersEnabled', false);
expect($user->fresh())
->social_notifications_enabled->toBeFalse()
->engagement_reminders_enabled->toBeFalse();
$this->patchJson('/api/notification-preferences', [
'socialNotificationsEnabled' => 'no',
])->assertUnprocessable();
});
@@ -0,0 +1,95 @@
<?php
use App\Actions\ReviewIdentityVerification;
use App\Enums\IdentityVerificationStatus;
use App\Enums\UserRole;
use App\Listeners\HandleStripeWebhook;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use App\Notifications\CertificationReviewedNotification;
use App\Notifications\EngagementReminderNotification;
use App\Notifications\NewFollowerNotification;
use App\Notifications\PaymentFailedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('respects social and engagement push preferences', function () {
$follower = User::factory()->create();
$user = User::factory()->create([
'social_notifications_enabled' => false,
'engagement_reminders_enabled' => false,
]);
expect((new NewFollowerNotification($follower))->via($user))
->toBe(['database'])
->and((new EngagementReminderNotification('publish_meal'))->via($user))
->toBe([])
->and((new PaymentFailedNotification)->via($user))
->toBe(['database', 'expo']);
$user->notifyNow(new NewFollowerNotification($follower));
$this->assertDatabaseHas('notifications', [
'type' => 'new_follower',
'notifiable_id' => $user->getKey(),
]);
});
it('notifies a user only once when a new follower is attached', function () {
Notification::fake();
$follower = User::factory()->create();
$followed = User::factory()->create();
Sanctum::actingAs($follower);
$this->postJson("/api/users/{$followed->id}/follow")->assertOk();
$this->postJson("/api/users/{$followed->id}/follow")->assertOk();
Notification::assertSentToTimes($followed, NewFollowerNotification::class, 1);
});
it('notifies the user when certification is reviewed', function () {
Notification::fake();
Storage::fake('identity_documents');
$user = User::factory()->create(['certification_purchased_at' => now()]);
$admin = User::factory()->create(['role' => UserRole::ADMIN]);
$verificationRequest = IdentityVerificationRequest::factory()->for($user)->create();
Storage::disk('identity_documents')->put($verificationRequest->document_path, 'identity');
app(ReviewIdentityVerification::class)->execute(
$verificationRequest,
IdentityVerificationStatus::APPROVED,
$admin,
);
Notification::assertSentTo($user, CertificationReviewedNotification::class);
});
it('notifies a user only once when a failed invoice webhook is replayed', function () {
Notification::fake();
$user = User::factory()->create(['stripe_id' => 'cus_failed_payment']);
$event = (object) [
'payload' => [
'id' => 'evt_failed_payment',
'type' => 'invoice.payment_failed',
'data' => [
'object' => [
'id' => 'in_failed_payment',
'customer' => 'cus_failed_payment',
'amount_due' => 999,
'currency' => 'eur',
'lines' => ['data' => []],
],
],
],
];
$listener = app(HandleStripeWebhook::class);
$listener->handle($event);
$listener->handle($event);
Notification::assertSentToTimes($user, PaymentFailedNotification::class, 1);
});
+19 -26
View File
@@ -3,9 +3,9 @@
use App\Models\MealPosts;
use App\Models\PostReviews;
use App\Models\User;
use App\Notifications\MealPostCommentedNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
@@ -43,13 +43,7 @@ it('creates one review per user for a meal post', function () {
});
it('sends a push notification to the meal owner when another user comments on their meal', function () {
Http::fake([
'https://exp.host/*' => Http::response([
'data' => [
['status' => 'ok', 'id' => 'ticket-one'],
],
]),
]);
Notification::fake();
$owner = User::factory()->create();
$owner->deviceTokens()->create([
@@ -68,23 +62,22 @@ it('sends a push notification to the meal owner when another user comments on th
'comment' => 'Très bon repas.',
])->assertCreated();
Http::assertSent(fn (Request $request) => $request->url() === 'https://exp.host/--/api/v2/push/send'
&& $request->data() === [
[
'to' => 'ExponentPushToken[owner]',
'title' => 'New comment',
'body' => 'Alex commented on your meal "Recovery bowl".',
'sound' => 'default',
'channelId' => 'default',
'data' => [
'url' => "bowli://meals/{$mealPost->id}",
],
],
]);
Notification::assertSentTo(
$owner,
MealPostCommentedNotification::class,
function (MealPostCommentedNotification $notification) use ($mealPost, $owner): bool {
$payload = $notification->toExpoPush($owner);
return $payload['title'] === 'New comment'
&& $payload['body'] === 'Alex commented on your meal "Recovery bowl".'
&& $payload['data']['type'] === 'meal_commented'
&& $payload['data']['url'] === "bowli://meals/{$mealPost->id}";
},
);
});
it('does not send a push notification for rating-only reviews', function () {
Http::fake();
Notification::fake();
$owner = User::factory()->create();
$owner->deviceTokens()->create([
@@ -100,11 +93,11 @@ it('does not send a push notification for rating-only reviews', function () {
'rating' => 5,
])->assertCreated();
Http::assertNothingSent();
Notification::assertNothingSent();
});
it('does not notify the owner when they comment on their own meal', function () {
Http::fake();
Notification::fake();
$owner = User::factory()->create();
$owner->deviceTokens()->create([
@@ -120,7 +113,7 @@ it('does not notify the owner when they comment on their own meal', function ()
'comment' => 'Note personnelle.',
])->assertCreated();
Http::assertNothingSent();
Notification::assertNothingSent();
});
it('lists reviews for a single meal post', function () {