From 7cc88f103681fe98a9eeaec1d13d3b6f4f815433 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 22 Jun 2026 12:53:13 -0300 Subject: [PATCH] fix: address PR review findings - LinkedInController: catch NetworkAlreadyConnectedException so a profile colliding with a connected Page shows the specific network_taken message instead of the generic error (+ test). - Billing.vue: pass a string to transChoice replacements (vue-tsc TS2322). - NetworkConnectGrid: drop the now-unused 'connect' emit (no listener after AddSocialDialog removal). - Tests: assert the BillingCycle trial window; fix a stale section comment in StripeEventListenerTest. --- .../Controllers/Auth/LinkedInController.php | 3 ++ .../accounts/NetworkConnectGrid.vue | 4 -- .../js/pages/settings/account/Billing.vue | 2 +- tests/Feature/Billing/BillingCycleTest.php | 14 ++++++ .../Listeners/StripeEventListenerTest.php | 2 +- .../Feature/Social/LinkedInControllerTest.php | 44 +++++++++++++++++++ 6 files changed, 63 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index a493d725..05fb0b44 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -6,6 +6,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; use App\Services\Social\LinkedInTokenSynchronizer; use Illuminate\Http\RedirectResponse; @@ -83,6 +84,8 @@ public function callback(Request $request): View app(LinkedInTokenSynchronizer::class)->syncTokens($account); return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); } catch (\Exception $e) { Log::error('LinkedIn OAuth Error', [ 'error' => $e->getMessage(), diff --git a/resources/js/components/accounts/NetworkConnectGrid.vue b/resources/js/components/accounts/NetworkConnectGrid.vue index 60834915..2a5c4825 100644 --- a/resources/js/components/accounts/NetworkConnectGrid.vue +++ b/resources/js/components/accounts/NetworkConnectGrid.vue @@ -41,8 +41,6 @@ const props = withDefaults( }, ); -const emit = defineEmits<{ connect: [] }>(); - const getPlatformDescription = (platform: string): string => trans(`accounts.descriptions.${platform}`); @@ -182,8 +180,6 @@ const needsReconnect = (account: ConnectedAccount): boolean => account.status === 'disconnected' || account.status === 'token_expired'; const openConnect = (platformValue: string) => { - emit('connect'); - if (platformValue === Platform.Telegram) { telegramOpen.value = true; return; diff --git a/resources/js/pages/settings/account/Billing.vue b/resources/js/pages/settings/account/Billing.vue index 81241fb1..871d9233 100644 --- a/resources/js/pages/settings/account/Billing.vue +++ b/resources/js/pages/settings/account/Billing.vue @@ -67,7 +67,7 @@ const displayPrice = (slug: string | undefined): string => { return trans(`billing.subscribe.prices.${slug}.${key}`); }; -const workspacesLabel = computed(() => transChoice('billing.plan.workspaces', props.workspaceCount, { count: props.workspaceCount })); +const workspacesLabel = computed(() => transChoice('billing.plan.workspaces', props.workspaceCount, { count: String(props.workspaceCount) })); const monthlyPrice = computed(() => (props.plan ? trans(`billing.subscribe.prices.${props.plan.slug}.monthly`) : '')); const yearlyPerMonthPrice = computed(() => (props.plan ? trans(`billing.subscribe.prices.${props.plan.slug}.yearly_per_month`) : '')); diff --git a/tests/Feature/Billing/BillingCycleTest.php b/tests/Feature/Billing/BillingCycleTest.php index 86b79d10..c4ed4232 100644 --- a/tests/Feature/Billing/BillingCycleTest.php +++ b/tests/Feature/Billing/BillingCycleTest.php @@ -32,6 +32,20 @@ expect(BillingCycle::for($account)->creditAllotment())->toBe(2500); }); +test('during trial the window spans the subscription creation to the trial end', function () { + Carbon::setTestNow('2026-06-20 12:00:00'); + + $account = billingAccount('price_year', [ + 'created_at' => Carbon::parse('2026-06-18'), + 'trial_ends_at' => Carbon::parse('2026-06-25'), + ], workspaces: 1); + + $cycle = BillingCycle::for($account); + + expect($cycle->periodStart()->toDateString())->toBe('2026-06-18') + ->and($cycle->periodEnd()->toDateString())->toBe('2026-06-25'); +}); + test('monthly cycle window spans the anchor day to the next month', function () { Carbon::setTestNow('2026-06-20 12:00:00'); $account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-03-05')]); diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index 74ffc0ff..a3f71f5c 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -433,7 +433,7 @@ }); // ======================================== -// Pennant feature cache invalidation +// Plan mapping by Stripe price id // ======================================== test('subscription updated maps the plan by price id', function () { diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index baf35e68..953f7ad1 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -135,6 +135,50 @@ ]); }); +test('linkedin callback shows network_taken when the linkedin network is already connected', function () { + config()->set('trypost.self_hosted', false); + + // A LinkedIn Page occupies the shared "linkedin" network for this workspace. + SocialAccount::factory()->linkedinPage()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'existing-linkedin-page', + ]); + + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('new-profile-id'); + $socialiteUser->shouldReceive('getName')->andReturn('John Doe'); + $socialiteUser->shouldReceive('getAvatar')->andReturn(null); + $socialiteUser->token = 'test-access-token'; + $socialiteUser->refreshToken = 'test-refresh-token'; + $socialiteUser->expiresIn = 5184000; + $socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social']; + + Socialite::shouldReceive('driver') + ->with('linkedin') + ->andReturn(Mockery::mock(['user' => $socialiteUser])); + + Http::fake([ + 'https://api.linkedin.com/v2/me*' => Http::response([ + 'id' => 'new-profile-id', + 'vanityName' => 'johndoe', + ], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', false); + $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); + + $this->assertDatabaseMissing('social_accounts', [ + 'platform' => Platform::LinkedIn->value, + 'platform_user_id' => 'new-profile-id', + ]); +}); + test('linkedin oauth callback splits comma-separated approvedScopes before saving', function () { session(['social_connect_workspace' => $this->workspace->id]);