diff --git a/app/Console/Commands/RemoveExpiredGitHubAccess.php b/app/Console/Commands/RemoveExpiredGitHubAccess.php index 6483f4b5..5afbd1cc 100644 --- a/app/Console/Commands/RemoveExpiredGitHubAccess.php +++ b/app/Console/Commands/RemoveExpiredGitHubAccess.php @@ -24,8 +24,8 @@ public function handle(): int ->get(); foreach ($users as $user) { - // Check if user still has Max access (direct or sub-license) - if (! $user->hasMaxAccess()) { + // Check if user still qualifies for mobile repo access + if (! $user->hasMobileRepoAccess()) { // Remove from repository $success = $github->removeFromMobileRepo($user->github_username); diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index 403c9a7a..c9ad078b 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -138,7 +138,7 @@ public function requestRepoAccess(): RedirectResponse return back()->with('error', 'Please connect your GitHub account first.'); } - if (! $user->hasMaxAccess()) { + if (! $user->hasMobileRepoAccess()) { return back()->with('error', 'You need an active Max license to access the mobile repository.'); } diff --git a/app/Models/User.php b/app/Models/User.php index 0aee5dbc..24942277 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -215,6 +215,26 @@ public function hasMaxAccess(): bool return $this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense(); } + /** + * Check if the user should have access to the nativephp/mobile repository. + * Max license holders always have access. Ultra subscribers only qualify + * if their subscription was created before February 1, 2026. + */ + public function hasMobileRepoAccess(): bool + { + if ($this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense()) { + return true; + } + + $subscription = $this->subscription(); + + if (! $subscription || ! $subscription->active()) { + return false; + } + + return $subscription->created_at->lt('2026-02-01 00:00:00'); + } + /** * Check if the user's subscription is a comped (free) subscription. * Covers both legacy comped (is_comped flag) and comped Ultra price. diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php index f44a9d33..59919566 100644 --- a/resources/views/livewire/customer/integrations.blade.php +++ b/resources/views/livewire/customer/integrations.blade.php @@ -42,10 +42,13 @@ - @if(auth()->user()->hasMaxAccess()) -
+
+ @if(auth()->user()->hasMobileRepoAccess()) + @endif + + @if(auth()->user()->hasMaxAccess()) -
- @endif + @endif +
diff --git a/resources/views/livewire/git-hub-access-banner.blade.php b/resources/views/livewire/git-hub-access-banner.blade.php index a44d5416..736ed9c9 100644 --- a/resources/views/livewire/git-hub-access-banner.blade.php +++ b/resources/views/livewire/git-hub-access-banner.blade.php @@ -1,5 +1,5 @@
-@if(auth()->user()->hasMaxAccess()) +@if(auth()->user()->hasMobileRepoAccess())
!$inline])>
diff --git a/tests/Feature/MobileRepoAccessTest.php b/tests/Feature/MobileRepoAccessTest.php new file mode 100644 index 00000000..d5508678 --- /dev/null +++ b/tests/Feature/MobileRepoAccessTest.php @@ -0,0 +1,235 @@ + self::MAX_PRICE_ID]); + + Cache::flush(); + } + + // ======================================== + // hasMobileRepoAccess() Unit Tests + // ======================================== + + public function test_user_with_active_max_license_has_mobile_repo_access(): void + { + $user = User::factory()->create(); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $this->assertTrue($user->hasMobileRepoAccess()); + } + + public function test_ultra_subscriber_before_cutoff_has_mobile_repo_access(): void + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-01-15 00:00:00', + ]); + + $this->assertTrue($user->hasMobileRepoAccess()); + } + + public function test_ultra_subscriber_after_cutoff_does_not_have_mobile_repo_access(): void + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-02-01 00:00:00', + ]); + + $this->assertFalse($user->hasMobileRepoAccess()); + } + + public function test_ultra_subscriber_on_cutoff_date_does_not_have_mobile_repo_access(): void + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-02-01 12:00:00', + ]); + + $this->assertFalse($user->hasMobileRepoAccess()); + } + + public function test_user_without_license_or_subscription_does_not_have_mobile_repo_access(): void + { + $user = User::factory()->create(); + + $this->assertFalse($user->hasMobileRepoAccess()); + } + + public function test_user_with_inactive_subscription_does_not_have_mobile_repo_access(): void + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + + CashierSubscription::factory()->for($user)->canceled()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2025-12-01 00:00:00', + ]); + + $this->assertFalse($user->hasMobileRepoAccess()); + } + + // ======================================== + // Integrations Page Visibility Tests + // ======================================== + + public function test_ultra_subscriber_before_cutoff_sees_mobile_repo_banner(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-01-15 00:00:00', + ]); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + + $response->assertStatus(200); + $response->assertSee('nativephp/mobile'); + $response->assertSee('Repo Access'); + } + + public function test_ultra_subscriber_after_cutoff_does_not_see_mobile_repo_banner(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-02-15 00:00:00', + ]); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + + $response->assertStatus(200); + $response->assertDontSee('nativephp/mobile Repo Access', false); + } + + // ======================================== + // Request Access Controller Tests + // ======================================== + + public function test_ultra_subscriber_after_cutoff_cannot_request_repo_access(): void + { + $user = User::factory()->create([ + 'stripe_id' => 'cus_'.uniqid(), + 'github_username' => 'testuser', + 'github_id' => '123456', + ]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-02-15 00:00:00', + ]); + + $response = $this->actingAs($user) + ->post('/dashboard/github/request-access'); + + $response->assertRedirect(); + $response->assertSessionHas('error'); + } + + public function test_ultra_subscriber_before_cutoff_can_request_repo_access(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 201), + ]); + + $user = User::factory()->create([ + 'stripe_id' => 'cus_'.uniqid(), + 'github_username' => 'testuser', + 'github_id' => '123456', + ]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-01-15 00:00:00', + ]); + + $response = $this->actingAs($user) + ->post('/dashboard/github/request-access'); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + $this->assertNotNull($user->fresh()->mobile_repo_access_granted_at); + } + + // ======================================== + // Cleanup Command Tests + // ======================================== + + public function test_cleanup_command_removes_access_for_post_cutoff_ultra_subscriber(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 204), + ]); + + $user = User::factory()->create([ + 'stripe_id' => 'cus_'.uniqid(), + 'github_username' => 'testuser', + 'github_id' => '123456', + 'mobile_repo_access_granted_at' => now()->subDays(10), + ]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-02-15 00:00:00', + ]); + + $this->artisan('github:remove-expired-access') + ->assertExitCode(0); + + $this->assertNull($user->fresh()->mobile_repo_access_granted_at); + } + + public function test_cleanup_command_retains_access_for_pre_cutoff_ultra_subscriber(): void + { + $user = User::factory()->create([ + 'stripe_id' => 'cus_'.uniqid(), + 'github_username' => 'testuser', + 'github_id' => '123456', + 'mobile_repo_access_granted_at' => now()->subDays(10), + ]); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + 'created_at' => '2026-01-15 00:00:00', + ]); + + $this->artisan('github:remove-expired-access') + ->assertExitCode(0); + + $this->assertNotNull($user->fresh()->mobile_repo_access_granted_at); + } +}