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); }); // ─── push frequency ─────────────────────────────────────────────────────────── it('seeds push.frequency for every tier', function (): void { expect(Plan::where('name', 'free')->first()->features['push']) ->toBe(['enabled' => false, 'frequency' => 'none']) ->and(Plan::where('name', 'basic')->first()->features['push']) ->toBe(['enabled' => true, 'frequency' => 'daily']) ->and(Plan::where('name', 'plus')->first()->features['push']) ->toBe(['enabled' => true, 'frequency' => 'triggered']) ->and(Plan::where('name', 'pro')->first()->features['push']) ->toBe(['enabled' => true, 'frequency' => 'triggered']); }); // ─── display name ───────────────────────────────────────────────────────────── it('Plan::displayName returns the user-facing label for each seeded tier', function (): void { expect(Plan::where('name', 'free')->first()->displayName())->toBe('Free') ->and(Plan::where('name', 'basic')->first()->displayName())->toBe('Daily') ->and(Plan::where('name', 'plus')->first()->displayName())->toBe('Smart') ->and(Plan::where('name', 'pro')->first()->displayName())->toBe('Pro'); }); it('PlanFeatures::displayName delegates to the resolved tier', function (): void { $user = User::factory()->create(); expect(PlanFeatures::for($user)->displayName())->toBe('Free'); });