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
This commit is contained in:
Paulo Castellano 2026-06-25 21:01:46 -03:00
parent afc4c7a80b
commit 2525d834b0
6 changed files with 79 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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