The features JSON column required defensive fallback stubs in three places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder) and silently swallowed misspelled keys. Typed columns give Eloquent type-safe reads, simplify the Filament form (no more dotted JSON paths), and let resolveForUser fail loud when the free row is missing. PlanFeatures public API is unchanged so consumers (jobs, middleware) need no rewrites — one missed JSON read in SendScheduledWhatsAppJob was caught and converted to a typed where() query. tests/Pest.php seeds PlanSeeder in beforeEach so any feature test that resolves a plan finds the free row, mirroring production where plans always exist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
7.0 KiB
PHP
220 lines
7.0 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 Illuminate\Foundation\Testing\RefreshDatabase;
|
|
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);
|
|
});
|
|
|
|
// ─── 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';
|
|
});
|
|
});
|