Files
fuel-price/tests/Feature/Tiers/PlanFeaturesTest.php
Ovidiu U c2466e5a61 feat(tiers): add display-name layer, push.frequency entitlement, and rename pricing cards
Reconciles tier docs with `PlanSeeder` reality (basic has price_threshold
and score_alerts; schema is stripe_price_id_monthly + stripe_price_id_annual)
and introduces the display-name layer from pricing-plan.md v2.

- PlanTier::label() + Plan::displayName() + PlanFeatures::displayName()
  expose user-facing names (Free/Daily/Smart/Pro); backend identifiers stay
  basic/plus/pro so every call site, Stripe mapping, and test keeps working.
- push.frequency key added to features JSON (none/daily/triggered), mirroring
  email.frequency so Daily's daily push is distinguishable from Smart/Pro's
  triggered push. Seeder, factory, free-tier stubs, and Filament form updated.
- Homepage pricing cards renamed: Basic→Daily, Plus→Smart; badge
  "Most Popular"→"Most pick this"; CTAs refreshed.
- docs/tiers.md change log records the full diff.

Fleet tier, 14-day trial copy, and Smart dark-card treatment deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:57:24 +01:00

239 lines
10 KiB
PHP

<?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);
});
// ─── 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');
});