fix: merge-readiness — close two billing/network bugs, harden tests

Bugs (both with regression tests):
- OnboardingController::store now guards already-subscribed accounts (mirrors
  index), preventing a second Stripe Checkout / double subscription if a
  subscribed user re-POSTs /onboarding.
- SocialAccountObserver: drop the `platform_user_id != …` clause from the
  creating-time one-per-network check. On create there is no "self" to exclude,
  so it only weakened the rule — the same account connected via two network
  variants (e.g. Instagram standalone + via Facebook, same id) could slip a
  second account into the network. Now any account in the network blocks.

Robustness:
- CreateWorkspace wraps create + member attach + switchWorkspace in a
  transaction (cache-forget / quantity-sync run after), so a partial failure
  can't leave an orphan workspace that inflates the Stripe seat count — covers
  both the signup and the add-workspace paths.

Test honesty & coverage:
- Scope the ten "connect multiple <platform> accounts" tests to self-hosted
  mode (config + name); they only passed because the test env defaults
  SELF_HOSTED=true, and in cloud the one-per-network rule blocks them.
- network_taken popup now has controller-level tests on all six OAuth
  controllers (added Threads, YouTube, LinkedInPage, and a new
  InstagramFacebook test file; LinkedInPage/InstagramFacebook also exercise
  variant collapse).
- Wiring tests that creating/deleting a workspace actually calls
  syncWorkspaceQuantity (guards per-seat billing against silent breakage).
- Strengthen TrialLengthTest to assert the configured length reaches
  trial_ends_at; add a same-id network-variant block test.
This commit is contained in:
Paulo Castellano 2026-06-22 09:26:31 -03:00
parent 3d01f3aab4
commit 17a091975d
18 changed files with 393 additions and 20 deletions

View file

@ -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();

View file

@ -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]);

View file

@ -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) {

View file

@ -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());
});

View file

@ -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);
});

View file

@ -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();
});

View file

@ -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',

View file

@ -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,

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->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);
});

View file

@ -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',

View file

@ -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);
});

View file

@ -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',

View file

@ -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',

View file

@ -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);
});

View file

@ -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',

View file

@ -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',

View file

@ -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);
});

View file

@ -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);
});