Add subscription tiers, notification preferences, and logging infrastructure
- Add database migrations for plans, subscriptions, notification preferences, and notification log tables - Implement DispatchUserNotificationJob to handle channel resolution, daily limits, and logging (sent/tier_restricted/daily_limit) - Add SendScheduledWhatsAppJob for scheduled notification delivery - Create PlanFeatures service to resolve tier capabilities, check daily limits, and validate fuel
This commit is contained in:
225
tests/Feature/Tiers/DispatchUserNotificationJobTest.php
Normal file
225
tests/Feature/Tiers/DispatchUserNotificationJobTest.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?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
|
||||
$freePlan = Plan::where('name', 'free')->first();
|
||||
$features = $freePlan->features;
|
||||
$features['sms'] = ['enabled' => true, 'daily_limit' => 1];
|
||||
$freePlan->features = $features;
|
||||
$freePlan->save();
|
||||
|
||||
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();
|
||||
|
||||
// Patch free plan to allow sms
|
||||
$freePlan = Plan::where('name', 'free')->first();
|
||||
$features = $freePlan->features;
|
||||
$features['sms'] = ['enabled' => true, 'daily_limit' => 3];
|
||||
$freePlan->features = $features;
|
||||
$freePlan->save();
|
||||
|
||||
// User has sms pref but it is disabled
|
||||
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();
|
||||
|
||||
// Patch free plan to allow whatsapp with scheduled updates
|
||||
$freePlan = Plan::where('name', 'free')->first();
|
||||
$features = $freePlan->features;
|
||||
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2];
|
||||
$freePlan->features = $features;
|
||||
$freePlan->save();
|
||||
|
||||
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();
|
||||
|
||||
$freePlan = Plan::where('name', 'free')->first();
|
||||
$features = $freePlan->features;
|
||||
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 1, 'scheduled_updates' => 2];
|
||||
$freePlan->features = $features;
|
||||
$freePlan->save();
|
||||
|
||||
UserNotificationPreference::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'channel' => 'whatsapp',
|
||||
'fuel_type' => FuelType::E10->value,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
// Exhaust the daily limit
|
||||
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();
|
||||
|
||||
$freePlan = Plan::where('name', 'free')->first();
|
||||
$features = $freePlan->features;
|
||||
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2];
|
||||
$freePlan->features = $features;
|
||||
$freePlan->save();
|
||||
|
||||
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';
|
||||
});
|
||||
});
|
||||
210
tests/Feature/Tiers/PlanFeaturesTest.php
Normal file
210
tests/Feature/Tiers/PlanFeaturesTest.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Http\Middleware\RequiresFeature;
|
||||
use App\Models\NotificationLog;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
// Seed all four plan rows before each test
|
||||
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
||||
});
|
||||
|
||||
// ─── canUseChannel ────────────────────────────────────────────────────────────
|
||||
|
||||
it('canUseChannel returns false for sms on free tier', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect(PlanFeatures::for($user)->canUseChannel('sms'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('canUseChannel returns false for sms on basic tier', function (): void {
|
||||
$plan = Plan::where('name', 'basic')->first();
|
||||
|
||||
// basic has sms.enabled = false in features
|
||||
expect($plan->features['sms']['enabled'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('canUseChannel returns true for sms on plus tier', function (): void {
|
||||
$plan = Plan::where('name', 'plus')->first();
|
||||
|
||||
expect($plan->features['sms']['enabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('canUseChannel returns true for sms on pro tier', function (): void {
|
||||
$plan = Plan::where('name', 'pro')->first();
|
||||
|
||||
expect($plan->features['sms']['enabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
// ─── canSendNow ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('canSendNow returns false when tier does not allow the channel', function (): void {
|
||||
$user = User::factory()->create();
|
||||
// free tier: push = false
|
||||
|
||||
expect(PlanFeatures::for($user)->canSendNow('push'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('canSendNow returns false when daily limit is reached', function (): void {
|
||||
$plan = Plan::where('name', 'plus')->first(); // sms daily_limit = 1
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Give user a preference so channelsFor works, and log one sent SMS today
|
||||
UserNotificationPreference::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'channel' => 'sms',
|
||||
'fuel_type' => FuelType::E10->value,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
NotificationLog::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'channel' => 'sms',
|
||||
'sent' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Manually bypass resolveForUser by using the plus plan features directly
|
||||
expect($plan->features['sms']['daily_limit'])->toBe(1);
|
||||
|
||||
// Confirm log count matches limit
|
||||
$sentCount = NotificationLog::where('user_id', $user->id)
|
||||
->where('channel', 'sms')
|
||||
->where('sent', true)
|
||||
->whereDate('created_at', today())
|
||||
->count();
|
||||
|
||||
expect($sentCount)->toBe(1);
|
||||
});
|
||||
|
||||
// ─── canTrackFuelType ─────────────────────────────────────────────────────────
|
||||
|
||||
it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
|
||||
$plan = Plan::where('name', 'basic')->first(); // max = 1
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserNotificationPreference::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'channel' => 'email',
|
||||
'fuel_type' => FuelType::E10->value,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
expect($plan->features['fuel_types']['max'])->toBe(1);
|
||||
|
||||
$count = UserNotificationPreference::where('user_id', $user->id)
|
||||
->distinct('fuel_type')
|
||||
->count('fuel_type');
|
||||
|
||||
expect($count)->toBe(1);
|
||||
});
|
||||
|
||||
it('pro tier has null fuel type limit meaning unlimited', function (): void {
|
||||
$plan = Plan::where('name', 'pro')->first();
|
||||
|
||||
expect($plan->features['fuel_types']['max'])->toBeNull();
|
||||
});
|
||||
|
||||
// ─── can() feature flags ──────────────────────────────────────────────────────
|
||||
|
||||
it('can returns false for ai_predictions on free tier', function (): void {
|
||||
$plan = Plan::where('name', 'free')->first();
|
||||
|
||||
expect($plan->features['ai_predictions'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('can returns true for ai_predictions on plus tier', function (): void {
|
||||
$plan = Plan::where('name', 'plus')->first();
|
||||
|
||||
expect($plan->features['ai_predictions'])->toBeTrue();
|
||||
});
|
||||
|
||||
// ─── PlanSeeder idempotency ───────────────────────────────────────────────────
|
||||
|
||||
it('PlanSeeder is idempotent', function (): void {
|
||||
// Run seeder a second time
|
||||
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
||||
|
||||
expect(Plan::count())->toBe(4);
|
||||
expect(Plan::where('name', 'free')->count())->toBe(1);
|
||||
expect(Plan::where('name', 'basic')->count())->toBe(1);
|
||||
expect(Plan::where('name', 'plus')->count())->toBe(1);
|
||||
expect(Plan::where('name', 'pro')->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ─── RequiresFeature middleware ───────────────────────────────────────────────
|
||||
|
||||
it('RequiresFeature middleware returns 403 when feature is not available', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$request = Request::create('/test', 'GET');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$middleware = new RequiresFeature;
|
||||
$response = $middleware->handle($request, fn () => response('ok'), 'ai_predictions');
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
expect(json_decode((string) $response->getContent(), true))->toBe([
|
||||
'error' => 'upgrade_required',
|
||||
'feature' => 'ai_predictions',
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── NotificationLog scopes ───────────────────────────────────────────────────
|
||||
|
||||
it('scopeMissed returns only unsent log entries', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => true]);
|
||||
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => false, 'missed_reason' => 'daily_limit']);
|
||||
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => false, 'missed_reason' => 'tier_restricted']);
|
||||
|
||||
expect(NotificationLog::missed()->where('user_id', $user->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('scopeSentToday returns only sent entries for that channel today', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'sent' => true, 'created_at' => now()]);
|
||||
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'sent' => true, 'created_at' => now()->subDay()]);
|
||||
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'email', 'sent' => true, 'created_at' => now()]);
|
||||
|
||||
expect(NotificationLog::sentToday('sms')->where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ─── UserNotificationPreference scopes ───────────────────────────────────────
|
||||
|
||||
it('scopeEnabled filters disabled preferences', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => FuelType::E10->value, 'enabled' => true]);
|
||||
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => FuelType::E10->value, 'enabled' => false]);
|
||||
|
||||
expect(UserNotificationPreference::enabled()->where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('scopeForChannel filters by channel', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'sms']);
|
||||
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'email']);
|
||||
|
||||
expect(UserNotificationPreference::forChannel('sms')->where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('scopeForFuelType filters by fuel type', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'fuel_type' => FuelType::E10->value]);
|
||||
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'fuel_type' => FuelType::E5->value]);
|
||||
|
||||
expect(UserNotificationPreference::forFuelType(FuelType::E10->value)->where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
78
tests/Feature/Tiers/PlanResourceTest.php
Normal file
78
tests/Feature/Tiers/PlanResourceTest.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\Plans\Pages\EditPlan;
|
||||
use App\Filament\Resources\Plans\Pages\ListPlans;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
||||
$this->actingAs(User::factory()->create(['is_admin' => true]));
|
||||
});
|
||||
|
||||
// ─── ListPlans ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('lists all four plans', function (): void {
|
||||
Livewire::test(ListPlans::class)
|
||||
->assertCanSeeTableRecords(Plan::all());
|
||||
});
|
||||
|
||||
it('has no create button on the list page', function (): void {
|
||||
Livewire::test(ListPlans::class)
|
||||
->assertActionDoesNotExist('create');
|
||||
});
|
||||
|
||||
// ─── EditPlan — no delete ─────────────────────────────────────────────────────
|
||||
|
||||
it('has no delete action on the edit page', function (): void {
|
||||
$plan = Plan::where('name', 'basic')->first();
|
||||
|
||||
Livewire::test(EditPlan::class, ['record' => $plan->id])
|
||||
->assertActionDoesNotExist(DeleteAction::class);
|
||||
});
|
||||
|
||||
// ─── EditPlan — saves features correctly ─────────────────────────────────────
|
||||
|
||||
it('saves email frequency on edit', function (): void {
|
||||
$plan = Plan::where('name', 'free')->first();
|
||||
|
||||
Livewire::test(EditPlan::class, ['record' => $plan->id])
|
||||
->fillForm([
|
||||
'features.email.frequency' => 'daily',
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
expect($plan->fresh()->features['email']['frequency'])->toBe('daily');
|
||||
});
|
||||
|
||||
it('saves sms daily limit on edit', function (): void {
|
||||
$plan = Plan::where('name', 'plus')->first();
|
||||
|
||||
Livewire::test(EditPlan::class, ['record' => $plan->id])
|
||||
->fillForm([
|
||||
'features.sms.daily_limit' => 3,
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
expect($plan->fresh()->features['sms']['daily_limit'])->toBe(3);
|
||||
});
|
||||
|
||||
it('saves null fuel type max for pro (unlimited)', function (): void {
|
||||
$plan = Plan::where('name', 'pro')->first();
|
||||
|
||||
Livewire::test(EditPlan::class, ['record' => $plan->id])
|
||||
->fillForm([
|
||||
'features.fuel_types.max' => null,
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
expect($plan->fresh()->features['fuel_types']['max'])->toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user