diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php index a2313ef3..d591ba8c 100644 --- a/app/Actions/Workspace/CreateWorkspace.php +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -7,6 +7,7 @@ use App\Enums\UserWorkspace\Role; use App\Models\User; use App\Models\Workspace; +use Illuminate\Support\Facades\DB; class CreateWorkspace { @@ -26,16 +27,20 @@ public static function execute(User $user, array $data): Workspace 'content_language' => data_get($data, 'content_language'), ], static fn ($value): bool => $value !== null); - $workspace = Workspace::create([ - ...$attributes, - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); + $workspace = DB::transaction(function () use ($user, $attributes): Workspace { + $workspace = Workspace::create([ + ...$attributes, + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); - // Creator becomes Admin of the workspace they made. The Account Owner - // is resolved separately (via account.owner_id) and outranks this role. - $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); - $user->switchWorkspace($workspace); + // Creator becomes Admin of the workspace they made. The Account Owner + // is resolved separately (via account.owner_id) and outranks this role. + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->switchWorkspace($workspace); + + return $workspace; + }); $user->account?->forgetPlanFeatureCache(); $user->account?->syncWorkspaceQuantity(); diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php index 65986b02..68ef90da 100644 --- a/app/Http/Controllers/App/OnboardingController.php +++ b/app/Http/Controllers/App/OnboardingController.php @@ -48,6 +48,11 @@ public function store( $user = $request->user(); $account = $user->account; + + if ($account?->subscribed(Account::SUBSCRIPTION_NAME)) { + return redirect()->route('app.calendar'); + } + $persona = (string) $request->validated('persona'); $user->update(['persona' => $persona]); diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index 2e4fbfa9..fb41f597 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -34,7 +34,6 @@ public function creating(SocialAccount $socialAccount): void $conflict = SocialAccount::query() ->where('workspace_id', $socialAccount->workspace_id) ->whereIn('platform', $platform->networkPlatformValues()) - ->where('platform_user_id', '!=', $socialAccount->platform_user_id) ->exists(); if ($conflict) { diff --git a/tests/Feature/Billing/TrialLengthTest.php b/tests/Feature/Billing/TrialLengthTest.php index 957faa1a..cbd3a0fd 100644 --- a/tests/Feature/Billing/TrialLengthTest.php +++ b/tests/Feature/Billing/TrialLengthTest.php @@ -2,6 +2,21 @@ declare(strict_types=1); +use App\Actions\User\CreateUser; + test('the default trial length is 8 days', function () { expect(config('cashier.trial_days'))->toBe(8); }); + +test('signup grants a trial of cashier.trial_days in no-card mode', function () { + config(['trypost.billing.require_card_for_trial' => false]); + + $user = CreateUser::execute([ + 'name' => 'Trial User', + 'email' => 'trial@example.com', + 'password' => 'secret123', + ]); + + expect($user->account->trial_ends_at->toDateString()) + ->toBe(now()->addDays(config('cashier.trial_days'))->toDateString()); +}); diff --git a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php index 36044c95..1113ae2b 100644 --- a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php +++ b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php @@ -2,7 +2,11 @@ declare(strict_types=1); +use App\Actions\Workspace\CreateWorkspace; +use App\Actions\Workspace\DeleteWorkspace; use App\Models\Account; +use App\Models\User; +use App\Models\Workspace; use Laravel\Cashier\Subscription; test('syncWorkspaceQuantity does not touch Stripe in self-hosted mode', function () { @@ -45,3 +49,29 @@ $account->syncWorkspaceQuantity(); }); + +test('creating a workspace syncs the stripe quantity', function () { + $user = User::factory()->create(); + + $account = mock(Account::class)->makePartial(); + $account->shouldReceive('forgetPlanFeatureCache')->once(); + $account->shouldReceive('syncWorkspaceQuantity')->once(); + $user->setRelation('account', $account); + + CreateWorkspace::execute($user, ['name' => 'Wiring']); +}); + +test('deleting a workspace syncs the stripe quantity', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + + $account = mock(Account::class)->makePartial(); + $account->shouldReceive('forgetPlanFeatureCache')->once(); + $account->shouldReceive('syncWorkspaceQuantity')->once(); + $workspace->setRelation('account', $account); + + DeleteWorkspace::execute($user, $workspace); +}); diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php index 76294fc0..914ed270 100644 --- a/tests/Feature/Onboarding/OnboardingControllerTest.php +++ b/tests/Feature/Onboarding/OnboardingControllerTest.php @@ -97,3 +97,23 @@ Bus::assertDispatched(SendEvent::class); }); + +test('onboarding store redirects an already-subscribed account to the calendar without starting a second checkout', function () { + $this->user->account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->never(); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), [ + 'persona' => Persona::Agency->value, + ]); + + $response->assertRedirect(route('app.calendar')); + expect($this->user->fresh()->persona)->toBeNull(); +}); diff --git a/tests/Feature/Social/BlueskyControllerTest.php b/tests/Feature/Social/BlueskyControllerTest.php index f2006d66..ebc8d82e 100644 --- a/tests/Feature/Social/BlueskyControllerTest.php +++ b/tests/Feature/Social/BlueskyControllerTest.php @@ -79,7 +79,9 @@ ]); }); -test('user can connect multiple bluesky accounts', function () { +test('user can connect multiple bluesky accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->bluesky()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => 'did:plc:existing123', diff --git a/tests/Feature/Social/InstagramControllerTest.php b/tests/Feature/Social/InstagramControllerTest.php index f5f92dcf..cdb28fa0 100644 --- a/tests/Feature/Social/InstagramControllerTest.php +++ b/tests/Feature/Social/InstagramControllerTest.php @@ -82,7 +82,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple instagram accounts', function () { +test('user can connect multiple instagram accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram, @@ -118,6 +120,43 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(2); }); +test('instagram callback shows network_taken when the network is already connected', function () { + config()->set('trypost.self_hosted', false); + + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'platform_user_id' => 'existing-ig', + ]); + + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('12345678'); + $socialiteUser->shouldReceive('getNickname')->andReturn('testuser'); + $socialiteUser->shouldReceive('getName')->andReturn('Test User'); + $socialiteUser->shouldReceive('getAvatar')->andReturn(null); + $socialiteUser->token = 'test-access-token'; + $socialiteUser->refreshToken = 'test-refresh-token'; + $socialiteUser->expiresIn = 5184000; + $socialiteUser->user = ['account_type' => 'BUSINESS']; + + Socialite::shouldReceive('driver') + ->with('instagram') + ->andReturn(Mockery::mock([ + 'user' => $socialiteUser, + ])); + + $response = $this->actingAs($this->user)->get(route('app.social.instagram.callback')); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', false); + $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); + + expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(1); +}); + test('instagram callback handles oauth errors gracefully', function () { session([ 'social_connect_workspace' => $this->workspace->id, diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php new file mode 100644 index 00000000..89710a6b --- /dev/null +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -0,0 +1,94 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); +}); + +test('instagram-facebook select connects the page in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'instagram_facebook_oauth' => [ + 'user_token' => 'user-token', + 'reconnect_id' => null, + 'pages' => [ + [ + 'page_id' => 'page-1', + 'page_name' => 'My Page', + 'page_access_token' => 'page-token', + 'ig_id' => 'ig-new', + 'ig_username' => 'mybiz', + 'ig_name' => 'My Biz', + 'ig_picture' => null, + ], + ], + ], + ]); + + $response = $this->actingAs($this->user)->post(route('app.social.instagram-facebook.select'), [ + 'page_id' => 'page-1', + ]); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', true); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::InstagramFacebook->value, + 'platform_user_id' => 'ig-new', + 'username' => 'mybiz', + ]); +}); + +test('instagram-facebook select shows network_taken when a standalone instagram is already connected', function () { + config()->set('trypost.self_hosted', false); + + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'platform_user_id' => 'existing-ig', + ]); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'instagram_facebook_oauth' => [ + 'user_token' => 'user-token', + 'reconnect_id' => null, + 'pages' => [ + [ + 'page_id' => 'page-1', + 'page_name' => 'My Page', + 'page_access_token' => 'page-token', + 'ig_id' => 'ig-new', + 'ig_username' => 'mybiz', + 'ig_name' => 'My Biz', + 'ig_picture' => null, + ], + ], + ], + ]); + + $response = $this->actingAs($this->user)->post(route('app.social.instagram-facebook.select'), [ + 'page_id' => 'page-1', + ]); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', false); + $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); + + expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::Instagram->value, Platform::InstagramFacebook->value])->count())->toBe(1); +}); diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index 02ce1a50..baf35e68 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -181,7 +181,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple linkedin accounts', function () { +test('user can connect multiple linkedin accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->linkedin()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => 'abc123xyz', diff --git a/tests/Feature/Social/LinkedInPageControllerTest.php b/tests/Feature/Social/LinkedInPageControllerTest.php index 28911496..ac79d246 100644 --- a/tests/Feature/Social/LinkedInPageControllerTest.php +++ b/tests/Feature/Social/LinkedInPageControllerTest.php @@ -214,7 +214,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple linkedin pages', function () { +test('user can connect multiple linkedin pages in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->linkedinPage()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => '123456', @@ -268,3 +270,42 @@ $response->assertViewHas('success', false); $response->assertViewHas('message', 'Error connecting account. Please try again.'); }); + +test('linkedin page select shows network_taken when a linkedin account is already connected', function () { + config()->set('trypost.self_hosted', false); + + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + 'platform_user_id' => 'existing-linkedin', + ]); + + session([ + 'linkedin_page_pending' => [ + 'workspace_id' => $this->workspace->id, + 'user_id' => 'user123', + 'name' => 'John Doe', + 'avatar' => null, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'organizations' => [ + ['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null], + ], + ], + ]); + + $response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [ + 'organization_id' => 123456, + 'organization_name' => 'Test Company', + 'organization_vanity_name' => 'testcompany', + 'organization_logo' => null, + ]); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', false); + $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); + + expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::LinkedIn->value, Platform::LinkedInPage->value])->count())->toBe(1); +}); diff --git a/tests/Feature/Social/MastodonControllerTest.php b/tests/Feature/Social/MastodonControllerTest.php index cab9483d..1882fd20 100644 --- a/tests/Feature/Social/MastodonControllerTest.php +++ b/tests/Feature/Social/MastodonControllerTest.php @@ -143,7 +143,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple mastodon accounts', function () { +test('user can connect multiple mastodon accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->mastodon()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => '123456789', diff --git a/tests/Feature/Social/PinterestControllerTest.php b/tests/Feature/Social/PinterestControllerTest.php index 4bce71e0..90c32fae 100644 --- a/tests/Feature/Social/PinterestControllerTest.php +++ b/tests/Feature/Social/PinterestControllerTest.php @@ -112,7 +112,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple pinterest accounts', function () { +test('user can connect multiple pinterest accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->pinterest()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => 'pinterest_user_123', diff --git a/tests/Feature/Social/ThreadsControllerTest.php b/tests/Feature/Social/ThreadsControllerTest.php index 58f61e31..624097a4 100644 --- a/tests/Feature/Social/ThreadsControllerTest.php +++ b/tests/Feature/Social/ThreadsControllerTest.php @@ -105,7 +105,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple threads accounts', function () { +test('user can connect multiple threads accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->threads()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => '123456789', @@ -169,3 +171,48 @@ $response->assertViewHas('success', false); $response->assertViewHas('message', 'Error connecting account. Please try again.'); }); + +test('threads callback shows network_taken when the network is already connected', function () { + config()->set('trypost.self_hosted', false); + + SocialAccount::factory()->threads()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'existing-threads', + ]); + + $state = bin2hex(random_bytes(16)); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'threads_oauth_state' => $state, + ]); + + Http::fake([ + 'https://graph.threads.net/oauth/access_token' => Http::response([ + 'access_token' => 'short-lived-token', + 'user_id' => '123456789', + ], 200), + 'https://graph.threads.net/access_token*' => Http::response([ + 'access_token' => 'long-lived-token', + 'expires_in' => 5184000, + ], 200), + 'https://graph.threads.net/v1.0/123456789*' => Http::response([ + 'id' => '123456789', + 'username' => 'testuser', + 'name' => 'Test User', + 'threads_profile_picture_url' => null, + ], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [ + 'code' => 'test-auth-code', + 'state' => $state, + ])); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', false); + $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); + + expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1); +}); diff --git a/tests/Feature/Social/TikTokControllerTest.php b/tests/Feature/Social/TikTokControllerTest.php index 3f17356e..b16cbb4a 100644 --- a/tests/Feature/Social/TikTokControllerTest.php +++ b/tests/Feature/Social/TikTokControllerTest.php @@ -86,7 +86,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple tiktok accounts', function () { +test('user can connect multiple tiktok accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->tiktok()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => 'tiktok123', diff --git a/tests/Feature/Social/XControllerTest.php b/tests/Feature/Social/XControllerTest.php index 4dbf8b5f..52661b77 100644 --- a/tests/Feature/Social/XControllerTest.php +++ b/tests/Feature/Social/XControllerTest.php @@ -84,7 +84,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple x accounts', function () { +test('user can connect multiple x accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->x()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => '123456789', diff --git a/tests/Feature/Social/YouTubeControllerTest.php b/tests/Feature/Social/YouTubeControllerTest.php index 1e021420..187b5217 100644 --- a/tests/Feature/Social/YouTubeControllerTest.php +++ b/tests/Feature/Social/YouTubeControllerTest.php @@ -182,7 +182,9 @@ $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple youtube accounts', function () { +test('user can connect multiple youtube accounts in self-hosted mode', function () { + config()->set('trypost.self_hosted', true); + SocialAccount::factory()->youtube()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => 'UC_channel_123', @@ -301,3 +303,53 @@ $response->assertViewHas('success', false); $response->assertViewHas('message', 'Session expired. Please try again.'); }); + +test('youtube callback shows network_taken when the network is already connected', function () { + config()->set('trypost.self_hosted', false); + + SocialAccount::factory()->youtube()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'UC_existing', + ]); + + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('google_user_123'); + $socialiteUser->token = 'test-access-token'; + $socialiteUser->refreshToken = 'test-refresh-token'; + $socialiteUser->expiresIn = 3600; + + Socialite::shouldReceive('driver') + ->with('google') + ->andReturn(Mockery::mock([ + 'user' => $socialiteUser, + ])); + + Http::fake([ + 'https://www.googleapis.com/youtube/v3/channels*' => Http::response([ + 'items' => [ + [ + 'id' => 'UC_channel_123', + 'snippet' => [ + 'title' => 'My YouTube Channel', + 'customUrl' => '@mychannel', + 'thumbnails' => ['default' => ['url' => null]], + ], + 'statistics' => ['subscriberCount' => 1000], + ], + ], + ], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.youtube.callback')); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', false); + $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); + + expect($this->workspace->socialAccounts()->where('platform', Platform::YouTube)->count())->toBe(1); +}); diff --git a/tests/Feature/SocialAccount/NetworkUniquenessTest.php b/tests/Feature/SocialAccount/NetworkUniquenessTest.php index 55fdaf3b..f25d109e 100644 --- a/tests/Feature/SocialAccount/NetworkUniquenessTest.php +++ b/tests/Feature/SocialAccount/NetworkUniquenessTest.php @@ -109,3 +109,17 @@ expect($second->exists)->toBeTrue(); }); + +test('blocks a same-id account connected via a different network variant', function () { + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'platform_user_id' => 'shared-ig-id', + ]); + + expect(fn () => SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::InstagramFacebook, + 'platform_user_id' => 'shared-ig-id', + ]))->toThrow(NetworkAlreadyConnectedException::class); +});