From 2525d834b0c48ba3b3887f4b9526986319bdbf08 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 25 Jun 2026 21:01:46 -0300 Subject: [PATCH] test(onboarding): cover the full flow; drop goal exclusivity rule - add an end-to-end walk (account gate -> persona -> goals -> connect -> Stripe) and a self-hosted bypass test, plus the connect no-workspace and just-exploring-saved cases - drop the just_exploring exclusivity: the backend now saves any valid goal combination (the front-end still clears siblings as a UX nicety), removing the withValidator rule, the unused Goal::isExclusive(), and the now-unused goals_exclusive copy --- app/Enums/User/Goal.php | 9 --- .../StoreOnboardingGoalsRequest.php | 12 --- lang/en/onboarding.php | 1 - lang/es/onboarding.php | 1 - lang/pt-BR/onboarding.php | 1 - .../Onboarding/OnboardingControllerTest.php | 81 ++++++++++++++++++- 6 files changed, 79 insertions(+), 26 deletions(-) diff --git a/app/Enums/User/Goal.php b/app/Enums/User/Goal.php index 2211a2c0..ced8c382 100644 --- a/app/Enums/User/Goal.php +++ b/app/Enums/User/Goal.php @@ -18,13 +18,4 @@ enum Goal: string case TrackPerformance = 'track_performance'; case JustExploring = 'just_exploring'; case Other = 'other'; - - /** - * Selecting this goal means the user has no specific intent yet, so it is - * mutually exclusive with every other goal. - */ - public function isExclusive(): bool - { - return $this === self::JustExploring; - } } diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php index fdda3bbf..e127eab3 100644 --- a/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php @@ -5,7 +5,6 @@ namespace App\Http\Requests\App\Onboarding; use App\Enums\User\Goal; -use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -26,15 +25,4 @@ public function rules(): array 'goals.*' => [Rule::enum(Goal::class)], ]; } - - public function withValidator(Validator $validator): void - { - $validator->after(function (Validator $validator): void { - $goals = (array) $this->input('goals', []); - - if (in_array(Goal::JustExploring->value, $goals, true) && count($goals) > 1) { - $validator->errors()->add('goals', __('onboarding.goals_exclusive')); - } - }); - } } diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php index a56d1c12..75768dd0 100644 --- a/lang/en/onboarding.php +++ b/lang/en/onboarding.php @@ -19,7 +19,6 @@ ], 'goals_title' => 'What\'s your goal with TryPost?', 'goals_description' => 'Pick everything that fits and we\'ll set TryPost up for you.', - 'goals_exclusive' => 'If you\'re just exploring, leave the other goals unchecked.', 'goals' => [ 'save_time' => 'Save time by posting everywhere at once', 'ai_content' => 'Create posts faster with AI', diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php index 74eac002..f612aa85 100644 --- a/lang/es/onboarding.php +++ b/lang/es/onboarding.php @@ -19,7 +19,6 @@ ], 'goals_title' => '¿Cuál es tu objetivo con TryPost?', 'goals_description' => 'Marca todo lo que encaje y adaptamos TryPost a ti.', - 'goals_exclusive' => 'Si solo estás explorando, deja los demás objetivos sin marcar.', 'goals' => [ 'save_time' => 'Ahorrar tiempo publicando en todas mis redes a la vez', 'ai_content' => 'Crear publicaciones más rápido con IA', diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php index 49455f30..ae9ed497 100644 --- a/lang/pt-BR/onboarding.php +++ b/lang/pt-BR/onboarding.php @@ -19,7 +19,6 @@ ], 'goals_title' => 'Qual o seu objetivo com o TryPost?', 'goals_description' => 'Marque tudo que faz sentido e a gente ajusta o TryPost pra você.', - 'goals_exclusive' => 'Se você só está dando uma olhada, deixe os outros desmarcados.', 'goals' => [ 'save_time' => 'Economizar tempo postando em todas as redes de uma vez', 'ai_content' => 'Criar posts mais rápido com IA', diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php index de911dd4..5f25e6aa 100644 --- a/tests/Feature/Onboarding/OnboardingControllerTest.php +++ b/tests/Feature/Onboarding/OnboardingControllerTest.php @@ -175,14 +175,15 @@ function subscribeOnboardingAccount(Account $account): void $response->assertSessionHasErrors('goals.0'); }); -test('goals store rejects just exploring combined with other goals', function () { +test('goals store accepts any combination of valid goals', function () { $this->user->update(['persona' => Persona::Agency->value]); $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [ 'goals' => [Goal::JustExploring->value, Goal::SaveTime->value], ]); - $response->assertSessionHasErrors('goals'); + $response->assertRedirect(route('app.onboarding.connect')); + expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->value, Goal::SaveTime->value]); }); test('goals store saves the goals, mirrors to PostHog and advances to the connect step', function () { @@ -201,6 +202,22 @@ function subscribeOnboardingAccount(Account $account): void Bus::assertDispatched(SendEvent::class); }); +test('goals store saves just exploring on its own as a real signal', function () { + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); + Bus::fake(); + + $this->user->update(['persona' => Persona::Agency->value]); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [ + 'goals' => [Goal::JustExploring->value], + ]); + + $response->assertRedirect(route('app.onboarding.connect')); + expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->value]); + + Bus::assertDispatched(SendEvent::class); +}); + test('goals store redirects to the persona step when no persona was chosen', function () { $response = $this->actingAs($this->user)->post(route('app.onboarding.goals.store'), [ 'goals' => [Goal::SaveTime->value], @@ -356,3 +373,63 @@ function subscribeOnboardingAccount(Account $account): void $response->assertRedirect(route('app.calendar')); }); + +test('connect redirects to workspace creation when no workspace exists', function () { + $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); + + $response->assertRedirect(route('app.workspaces.create')); +}); + +test('a user walks the full onboarding flow from the account gate to stripe checkout', function () { + Plan::where('slug', Slug::Workspace)->firstOrFail()->update(['stripe_monthly_price_id' => 'price_monthly_test']); + $workspace = onboardingWorkspace($this->user); + + // The account gate sends an unsubscribed user into onboarding. + $this->actingAs($this->user->fresh())->get(route('app.calendar')) + ->assertRedirect(route('app.onboarding')); + + // Persona advances to goals. + $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), ['persona' => Persona::Creator->value]) + ->assertRedirect(route('app.onboarding.goals')); + + // Goals advances to connect. + $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [ + 'goals' => [Goal::AiContent->value, Goal::SaveTime->value], + ])->assertRedirect(route('app.onboarding.connect')); + + // Connect renders now that persona, goals and a workspace are present. + $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'))->assertOk(); + + // Checkout blocks until a network is connected. + $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout')) + ->assertRedirect(route('app.onboarding.connect')); + + // Once a network is connected, checkout hands off to Stripe. + SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->once() + ->andReturn(redirect('https://checkout.stripe.test/session')); + + $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout')) + ->assertRedirect('https://checkout.stripe.test/session'); + + expect($this->user->fresh()->persona)->toBe(Persona::Creator); + expect($this->user->fresh()->goals)->toBe([Goal::AiContent->value, Goal::SaveTime->value]); +}); + +test('a self-hosted user never enters the onboarding flow', function () { + config(['trypost.self_hosted' => true]); + onboardingWorkspace($this->user); + + // The app is reachable directly, with no subscription. + $this->actingAs($this->user->fresh())->get(route('app.calendar'))->assertOk(); + + // Every onboarding step just bounces to the calendar. + foreach (['app.onboarding', 'app.onboarding.goals', 'app.onboarding.connect'] as $routeName) { + $this->actingAs($this->user->fresh())->get(route($routeName))->assertRedirect(route('app.calendar')); + } +});