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>
221 lines
6.9 KiB
PHP
221 lines
6.9 KiB
PHP
<?php
|
|
|
|
use App\Models\Plan;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
|
});
|
|
|
|
it('registers a new user and returns a token', function () {
|
|
$this->postJson('/api/auth/register', [
|
|
'name' => 'Test User',
|
|
'email' => 'test@example.com',
|
|
'password' => 'password',
|
|
'password_confirmation' => 'password',
|
|
])
|
|
->assertCreated()
|
|
->assertJsonStructure(['token', 'user' => ['id', 'name', 'email']]);
|
|
});
|
|
|
|
it('returns 422 when register fields are missing', function () {
|
|
$this->postJson('/api/auth/register')
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors(['name', 'email', 'password']);
|
|
});
|
|
|
|
it('returns 422 when email is already taken', function () {
|
|
User::factory()->create(['email' => 'taken@example.com']);
|
|
|
|
$this->postJson('/api/auth/register', [
|
|
'name' => 'Another User',
|
|
'email' => 'taken@example.com',
|
|
'password' => 'password',
|
|
'password_confirmation' => 'password',
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors(['email']);
|
|
});
|
|
|
|
it('logs in with valid credentials and returns a token', function () {
|
|
$user = User::factory()->create(['password' => bcrypt('secret123')]);
|
|
|
|
$this->postJson('/api/auth/login', [
|
|
'email' => $user->email,
|
|
'password' => 'secret123',
|
|
])
|
|
->assertOk()
|
|
->assertJsonStructure(['token', 'user']);
|
|
});
|
|
|
|
it('returns 401 for invalid credentials', function () {
|
|
User::factory()->create(['email' => 'user@example.com', 'password' => bcrypt('correct')]);
|
|
|
|
$this->postJson('/api/auth/login', [
|
|
'email' => 'user@example.com',
|
|
'password' => 'wrong',
|
|
])->assertUnauthorized();
|
|
});
|
|
|
|
it('returns the authenticated user on /me', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user, 'sanctum')
|
|
->getJson('/api/auth/me')
|
|
->assertOk()
|
|
->assertJsonPath('email', $user->email);
|
|
});
|
|
|
|
it('reports subscription_cancelled=false for a user with no subscription', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user, 'sanctum')
|
|
->getJson('/api/auth/me')
|
|
->assertOk()
|
|
->assertJsonPath('subscription_cancelled', false);
|
|
});
|
|
|
|
it('reports subscription_cancelled=false for an active paid subscription', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$user->subscriptions()->create([
|
|
'type' => 'default',
|
|
'stripe_id' => 'sub_active',
|
|
'stripe_status' => 'active',
|
|
'stripe_price' => 'price_plus_monthly',
|
|
'quantity' => 1,
|
|
]);
|
|
|
|
$this->actingAs($user, 'sanctum')
|
|
->getJson('/api/auth/me')
|
|
->assertOk()
|
|
->assertJsonPath('subscription_cancelled', false);
|
|
});
|
|
|
|
it('reports subscription_cancelled=true once the subscription is set to end at period end', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$user->subscriptions()->create([
|
|
'type' => 'default',
|
|
'stripe_id' => 'sub_cancelling',
|
|
'stripe_status' => 'active',
|
|
'stripe_price' => 'price_plus_monthly',
|
|
'quantity' => 1,
|
|
'ends_at' => now()->addDays(20),
|
|
]);
|
|
|
|
$this->actingAs($user, 'sanctum')
|
|
->getJson('/api/auth/me')
|
|
->assertOk()
|
|
->assertJsonPath('subscription_cancelled', true);
|
|
});
|
|
|
|
it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () {
|
|
Plan::where('name', 'plus')->update([
|
|
'stripe_price_id_monthly' => 'price_plus_monthly_test',
|
|
'stripe_price_id_annual' => 'price_plus_annual_test',
|
|
]);
|
|
|
|
$user = User::factory()->create();
|
|
|
|
$subscribedAt = now()->subDays(10)->startOfSecond();
|
|
$renewalAt = now()->addDays(20)->startOfSecond();
|
|
|
|
$user->subscriptions()->create([
|
|
'type' => 'default',
|
|
'stripe_id' => 'sub_monthly_active',
|
|
'stripe_status' => 'active',
|
|
'stripe_price' => 'price_plus_monthly_test',
|
|
'quantity' => 1,
|
|
'current_period_end' => $renewalAt,
|
|
'created_at' => $subscribedAt,
|
|
'updated_at' => $subscribedAt,
|
|
]);
|
|
|
|
$response = $this->actingAs($user, 'sanctum')
|
|
->getJson('/api/auth/me')
|
|
->assertOk()
|
|
->assertJsonPath('subscription_cancelled', false)
|
|
->assertJsonPath('subscription_cadence', 'monthly');
|
|
|
|
expect($response->json('subscribed_at'))->toStartWith($subscribedAt->toDateString());
|
|
expect($response->json('subscription_expires_at'))->toStartWith($renewalAt->toDateString());
|
|
});
|
|
|
|
it('reports cadence as annual when the active price is the annual one', function () {
|
|
Plan::where('name', 'pro')->update([
|
|
'stripe_price_id_monthly' => 'price_pro_monthly_test',
|
|
'stripe_price_id_annual' => 'price_pro_annual_test',
|
|
]);
|
|
|
|
$user = User::factory()->create();
|
|
|
|
$user->subscriptions()->create([
|
|
'type' => 'default',
|
|
'stripe_id' => 'sub_annual_active',
|
|
'stripe_status' => 'active',
|
|
'stripe_price' => 'price_pro_annual_test',
|
|
'quantity' => 1,
|
|
]);
|
|
|
|
$this->actingAs($user, 'sanctum')
|
|
->getJson('/api/auth/me')
|
|
->assertOk()
|
|
->assertJsonPath('subscription_cadence', 'annual');
|
|
});
|
|
|
|
it('uses ends_at as the expiry date when subscription is cancelled', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$endsAt = now()->addDays(15)->startOfSecond();
|
|
$renewalAt = now()->addDays(30)->startOfSecond();
|
|
|
|
$user->subscriptions()->create([
|
|
'type' => 'default',
|
|
'stripe_id' => 'sub_cancelling_with_period',
|
|
'stripe_status' => 'active',
|
|
'stripe_price' => 'price_plus_monthly',
|
|
'quantity' => 1,
|
|
'ends_at' => $endsAt,
|
|
'current_period_end' => $renewalAt,
|
|
]);
|
|
|
|
$response = $this->actingAs($user, 'sanctum')
|
|
->getJson('/api/auth/me')
|
|
->assertOk()
|
|
->assertJsonPath('subscription_cancelled', true);
|
|
|
|
expect($response->json('subscription_expires_at'))->toStartWith($endsAt->toDateString());
|
|
});
|
|
|
|
it('returns null subscription metadata for users with no subscription', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user, 'sanctum')
|
|
->getJson('/api/auth/me')
|
|
->assertOk()
|
|
->assertJsonPath('subscription_cancelled', false)
|
|
->assertJsonPath('subscription_cadence', null)
|
|
->assertJsonPath('subscribed_at', null)
|
|
->assertJsonPath('subscription_expires_at', null);
|
|
});
|
|
|
|
it('logs out and revokes the token', function () {
|
|
$user = User::factory()->create();
|
|
$token = $user->createToken('api')->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->postJson('/api/auth/logout')
|
|
->assertOk()
|
|
->assertJsonPath('message', 'Logged out.');
|
|
|
|
expect($user->tokens()->count())->toBe(0);
|
|
});
|
|
|
|
it('returns 401 on protected routes without a token', function () {
|
|
$this->getJson('/api/auth/me')->assertUnauthorized();
|
|
});
|