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>
234 lines
9.8 KiB
PHP
234 lines
9.8 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();
|
|
|
|
expect($plan->sms_enabled)->toBeFalse();
|
|
});
|
|
|
|
it('canUseChannel returns true for sms on plus tier', function (): void {
|
|
$plan = Plan::where('name', 'plus')->first();
|
|
|
|
expect($plan->sms_enabled)->toBeTrue();
|
|
});
|
|
|
|
it('canUseChannel returns true for sms on pro tier', function (): void {
|
|
$plan = Plan::where('name', 'pro')->first();
|
|
|
|
expect($plan->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();
|
|
|
|
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(),
|
|
]);
|
|
|
|
expect($plan->sms_daily_limit)->toBe(1);
|
|
|
|
$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_fuel_types = 1
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'email',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
expect($plan->max_fuel_types)->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->max_fuel_types)->toBeNull();
|
|
});
|
|
|
|
// ─── can() feature flags ──────────────────────────────────────────────────────
|
|
|
|
it('can returns false for ai_predictions on free tier', function (): void {
|
|
$plan = Plan::where('name', 'free')->first();
|
|
|
|
expect($plan->ai_predictions)->toBeFalse();
|
|
});
|
|
|
|
it('can returns true for ai_predictions on plus tier', function (): void {
|
|
$plan = Plan::where('name', 'plus')->first();
|
|
|
|
expect($plan->ai_predictions)->toBeTrue();
|
|
});
|
|
|
|
// ─── PlanSeeder idempotency ───────────────────────────────────────────────────
|
|
|
|
it('PlanSeeder is idempotent', function (): void {
|
|
$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())
|
|
->push_enabled->toBeFalse()->push_frequency->toBe('none')
|
|
->and(Plan::where('name', 'basic')->first())
|
|
->push_enabled->toBeTrue()->push_frequency->toBe('daily')
|
|
->and(Plan::where('name', 'plus')->first())
|
|
->push_enabled->toBeTrue()->push_frequency->toBe('triggered')
|
|
->and(Plan::where('name', 'pro')->first())
|
|
->push_enabled->toBeTrue()->push_frequency->toBe('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');
|
|
});
|