feat: add persistent mobile notifications
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user