The DispatchUserNotificationJob has been logging sent=true for every
allowed channel without actually sending anything — a TODO marker covered
by the existing test suite, which only asserted log rows. The downstream
"missed today" widget read those rows and reported falsely. This commit
makes the telemetry truthful by wiring the real send.
- App\Notifications\FuelPriceAlert — Notification class with via() that
returns the per-tier-filtered channel list passed in by the dispatcher.
Implements toMail / toOneSignal / toVonageWhatsApp / toVonageSms.
ShouldQueue on the 'notifications' queue.
- App\Notifications\Channels\OneSignalChannel — raw HTTP to OneSignal
REST API, gated on services.onesignal.{app_id,api_key} + user
push_token. Logs every call to api_logs via ApiLogger.
- App\Notifications\Channels\VonageWhatsAppChannel — raw HTTP to Vonage
Messages API, gated on whatsapp_verified_at + whatsapp_number.
- App\Notifications\Channels\VonageSmsChannel — raw HTTP to Vonage SMS
API, gated on whatsapp_number.
- DispatchUserNotificationJob now calls $user->notify(new
FuelPriceAlert(...)) before logging.
- New tests: assert the notification IS dispatched with the right
channels, and that nothing is dispatched when no channels are allowed.
Channels gracefully no-op when their credentials are unset (logging at
info level), so existing tests without a Notification::fake() still
pass — the channels just early-return on missing config.
No new composer dependencies — Vonage SDK avoided in favour of raw HTTP
through the existing ApiLogger pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
8.2 KiB
PHP
255 lines
8.2 KiB
PHP
<?php
|
|
|
|
use App\Enums\FuelType;
|
|
use App\Jobs\DispatchUserNotificationJob;
|
|
use App\Jobs\SendScheduledWhatsAppJob;
|
|
use App\Models\NotificationLog;
|
|
use App\Models\Plan;
|
|
use App\Models\User;
|
|
use App\Models\UserNotificationPreference;
|
|
use App\Notifications\FuelPriceAlert;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
|
});
|
|
|
|
// ─── DispatchUserNotificationJob — sent logging ───────────────────────────────
|
|
|
|
it('logs a sent entry for each allowed channel', function (): void {
|
|
// Free tier allows email (weekly_digest). User has email pref enabled.
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'email',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value, price: 143.9))->handle();
|
|
|
|
$log = NotificationLog::where('user_id', $user->id)->where('channel', 'email')->first();
|
|
|
|
expect($log)->not->toBeNull()
|
|
->and($log->sent)->toBeTrue()
|
|
->and($log->trigger_type)->toBe('price_threshold')
|
|
->and($log->fuel_type)->toBe(FuelType::E10->value);
|
|
});
|
|
|
|
it('actually dispatches FuelPriceAlert with the allowed channels', function (): void {
|
|
Notification::fake();
|
|
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'email',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value, price: 143.9))->handle();
|
|
|
|
Notification::assertSentTo($user, FuelPriceAlert::class, function (FuelPriceAlert $n) {
|
|
return $n->triggerType === 'price_threshold'
|
|
&& $n->fuelType === FuelType::E10->value
|
|
&& $n->price === 143.9
|
|
&& in_array('email', $n->channels, true);
|
|
});
|
|
});
|
|
|
|
it('does not dispatch FuelPriceAlert when no channels are allowed', function (): void {
|
|
Notification::fake();
|
|
|
|
// Free user with no preferences — channelsFor returns []
|
|
$user = User::factory()->create();
|
|
|
|
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
|
|
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
// ─── DispatchUserNotificationJob — tier_restricted logging ───────────────────
|
|
|
|
it('logs tier_restricted for channels the user wants but their tier forbids', function (): void {
|
|
// Free tier: sms is disabled. User has sms pref enabled.
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'sms',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
|
|
|
|
$log = NotificationLog::where('user_id', $user->id)
|
|
->where('channel', 'sms')
|
|
->first();
|
|
|
|
expect($log)->not->toBeNull()
|
|
->and($log->sent)->toBeFalse()
|
|
->and($log->missed_reason)->toBe('tier_restricted');
|
|
});
|
|
|
|
// ─── DispatchUserNotificationJob — daily_limit logging ───────────────────────
|
|
|
|
it('logs daily_limit when the channel is allowed but the limit is exhausted', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
// Patch the free plan to allow sms with limit 1
|
|
Plan::where('name', 'free')->first()->update([
|
|
'sms_enabled' => true,
|
|
'sms_daily_limit' => 1,
|
|
]);
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'sms',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
// Pre-log one sent SMS to hit the daily limit
|
|
NotificationLog::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'sms',
|
|
'sent' => true,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
|
|
|
|
$missed = NotificationLog::where('user_id', $user->id)
|
|
->where('channel', 'sms')
|
|
->where('sent', false)
|
|
->first();
|
|
|
|
expect($missed)->not->toBeNull()
|
|
->and($missed->missed_reason)->toBe('daily_limit');
|
|
});
|
|
|
|
// ─── DispatchUserNotificationJob — does not log user-disabled channels ────────
|
|
|
|
it('does not log channels the user has explicitly disabled', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
Plan::where('name', 'free')->first()->update([
|
|
'sms_enabled' => true,
|
|
'sms_daily_limit' => 3,
|
|
]);
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'sms',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => false,
|
|
]);
|
|
|
|
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
|
|
|
|
expect(NotificationLog::where('user_id', $user->id)->where('channel', 'sms')->count())->toBe(0);
|
|
});
|
|
|
|
// ─── DispatchUserNotificationJob — queued on notifications queue ──────────────
|
|
|
|
it('is dispatched on the notifications queue', function (): void {
|
|
Queue::fake();
|
|
|
|
$user = User::factory()->create();
|
|
|
|
DispatchUserNotificationJob::dispatch($user, 'price_threshold', FuelType::E10->value);
|
|
|
|
Queue::assertPushedOn('notifications', DispatchUserNotificationJob::class);
|
|
});
|
|
|
|
// ─── SendScheduledWhatsAppJob — dispatches per eligible user ─────────────────
|
|
|
|
it('dispatches DispatchUserNotificationJob for eligible whatsapp users', function (): void {
|
|
Queue::fake();
|
|
|
|
$user = User::factory()->create();
|
|
|
|
Plan::where('name', 'free')->first()->update([
|
|
'whatsapp_enabled' => true,
|
|
'whatsapp_daily_limit' => 5,
|
|
'whatsapp_scheduled_updates' => 2,
|
|
]);
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'whatsapp',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
(new SendScheduledWhatsAppJob('morning'))->handle();
|
|
|
|
Queue::assertPushedOn('notifications', DispatchUserNotificationJob::class);
|
|
});
|
|
|
|
// ─── SendScheduledWhatsAppJob — skips users over daily limit ─────────────────
|
|
|
|
it('skips users who have hit their whatsapp daily limit', function (): void {
|
|
Queue::fake();
|
|
|
|
$user = User::factory()->create();
|
|
|
|
Plan::where('name', 'free')->first()->update([
|
|
'whatsapp_enabled' => true,
|
|
'whatsapp_daily_limit' => 1,
|
|
'whatsapp_scheduled_updates' => 2,
|
|
]);
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'whatsapp',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
NotificationLog::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'whatsapp',
|
|
'sent' => true,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
(new SendScheduledWhatsAppJob('evening'))->handle();
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
// ─── SendScheduledWhatsAppJob — correct trigger type per period ───────────────
|
|
|
|
it('passes scheduled_morning trigger for morning period', function (): void {
|
|
Queue::fake();
|
|
|
|
$user = User::factory()->create();
|
|
|
|
Plan::where('name', 'free')->first()->update([
|
|
'whatsapp_enabled' => true,
|
|
'whatsapp_daily_limit' => 5,
|
|
'whatsapp_scheduled_updates' => 2,
|
|
]);
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'whatsapp',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
(new SendScheduledWhatsAppJob('morning'))->handle();
|
|
|
|
Queue::assertPushed(DispatchUserNotificationJob::class, function (DispatchUserNotificationJob $job): bool {
|
|
return $job->triggerType === 'scheduled_morning';
|
|
});
|
|
});
|