trypost/tests/Feature/OnboardingControllerTest.php
Paulo Castellano bde10059e3 feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:

- name        ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language    ← <html lang> mapped to en / pt-BR / es
- logo        ← apple-touch-icon | largest link[rel*=icon] | og:image

The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.

Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.

Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
  link-local ranges, enforces http(s) scheme on both the initial
  page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
  alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
  types are silently dropped so users never see broken images.

UX:
- 'Autofill' button next to the website input, disabled until there
  is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
  so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
  translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
  overwritten if parsing gave us nothing.

Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 11:13:54 -03:00

351 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Role]);
});
// Step 1 tests
test('step1 requires authentication', function () {
$response = $this->get(route('app.onboarding.role'));
$response->assertRedirect(route('login'));
});
test('step1 shows persona selection', function () {
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Role', false)
->has('personas')
);
});
// Store Step 1 tests
test('store step1 requires authentication', function () {
$response = $this->post(route('app.onboarding.role.store'), [
'persona' => Persona::Founder->value,
]);
$response->assertRedirect(route('login'));
});
test('store step1 saves persona and redirects to brand step', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.role.store'), [
'persona' => Persona::Founder->value,
]);
$response->assertRedirect(route('app.onboarding.brand'));
$this->user->refresh();
expect($this->user->persona)->toBe(Persona::Founder);
expect($this->user->setup)->toBe(Setup::Brand);
});
test('store step1 validates persona is required', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.role.store'), [
'persona' => '',
]);
$response->assertSessionHasErrors('persona');
});
test('store step1 validates persona is valid enum', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.role.store'), [
'persona' => 'invalid',
]);
$response->assertSessionHasErrors('persona');
});
// Brand step tests
test('brand step requires authentication', function () {
$response = $this->get(route('app.onboarding.brand'));
$response->assertRedirect(route('login'));
});
test('brand step redirects users not at the brand step', function () {
$this->user->update(['setup' => Setup::Role]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.brand'));
$response->assertRedirect(route('app.onboarding.role'));
});
test('brand step shows the form with workspace defaults', function () {
$workspace = Workspace::factory()->create([
'user_id' => $this->user->id,
'brand_tone' => 'casual',
'content_language' => 'pt-BR',
]);
$this->user->update([
'current_workspace_id' => $workspace->id,
'setup' => Setup::Brand,
]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.brand'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Brand', false)
->where('workspace.brand_tone', 'casual')
->where('workspace.content_language', 'pt-BR')
);
});
test('store brand persists workspace settings and advances to connections', function () {
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update([
'current_workspace_id' => $workspace->id,
'setup' => Setup::Brand,
]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.brand.store'), [
'brand_website' => 'https://example.com',
'brand_description' => 'We build social tools.',
'brand_tone' => 'friendly',
'brand_voice_notes' => 'Short and punchy.',
'content_language' => 'pt-BR',
]);
$response->assertRedirect(route('app.onboarding.account'));
$workspace->refresh();
expect($workspace->brand_website)->toBe('https://example.com');
expect($workspace->brand_tone)->toBe('friendly');
expect($workspace->content_language)->toBe('pt-BR');
$this->user->refresh();
expect($this->user->setup)->toBe(Setup::Connections);
});
test('store brand validates tone is in allowed list', function () {
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update([
'current_workspace_id' => $workspace->id,
'setup' => Setup::Brand,
]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.brand.store'), [
'brand_tone' => 'invalid-tone',
'content_language' => 'en',
]);
$response->assertSessionHasErrors('brand_tone');
});
test('store brand validates content_language is supported', function () {
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update([
'current_workspace_id' => $workspace->id,
'setup' => Setup::Brand,
]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.brand.store'), [
'brand_tone' => 'professional',
'content_language' => 'fr',
]);
$response->assertSessionHasErrors('content_language');
});
test('autofill brand returns extracted fields from the site', function () {
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update([
'current_workspace_id' => $workspace->id,
'setup' => Setup::Brand,
]);
Http::fake([
'acme.com' => Http::response(<<<'HTML'
<html lang="pt-BR">
<head>
<title>Acme | Best in class</title>
<meta name="description" content="Tools for creators.">
<meta property="og:site_name" content="Acme">
</head>
</html>
HTML, 200),
]);
$response = $this->actingAs($this->user)->postJson(
route('app.onboarding.brand.autofill'),
['url' => 'https://acme.com'],
);
$response->assertOk();
$response->assertJson([
'name' => 'Acme',
'brand_description' => 'Tools for creators.',
'content_language' => 'pt-BR',
]);
});
test('autofill brand returns 422 when site cannot be reached', function () {
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update([
'current_workspace_id' => $workspace->id,
'setup' => Setup::Brand,
]);
$response = $this->actingAs($this->user)->postJson(
route('app.onboarding.brand.autofill'),
['url' => 'http://127.0.0.1'],
);
$response->assertUnprocessable();
$response->assertJsonStructure(['message']);
});
test('skip brand advances setup without saving workspace changes', function () {
$workspace = Workspace::factory()->create([
'user_id' => $this->user->id,
'brand_tone' => 'casual',
]);
$this->user->update([
'current_workspace_id' => $workspace->id,
'setup' => Setup::Brand,
]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.brand.skip'));
$response->assertRedirect(route('app.onboarding.account'));
$workspace->refresh();
expect($workspace->brand_tone)->toBe('casual');
$this->user->refresh();
expect($this->user->setup)->toBe(Setup::Connections);
});
// Step 2 tests
test('step2 requires authentication', function () {
$response = $this->get(route('app.onboarding.account'));
$response->assertRedirect(route('login'));
});
test('step2 shows social accounts connection', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Account', false)
->has('platforms')
->has('hasWorkspace')
);
});
test('step2 shows connected accounts for workspace', function () {
$this->user->update(['setup' => Setup::Connections]);
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $workspace->id]);
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::LinkedIn,
]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('hasWorkspace', true)
);
});
// Store Step 2 tests
test('store step2 requires authentication', function () {
$response = $this->post(route('app.onboarding.account.store'));
$response->assertRedirect(route('login'));
});
test('store step2 completes setup in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.account.store'));
$response->assertRedirect(route('app.calendar'));
$this->user->refresh();
expect($this->user->setup)->toBe(Setup::Completed);
});
// Complete tests
test('complete requires authentication', function () {
$response = $this->get(route('app.onboarding.account'));
$response->assertRedirect(route('login'));
});
test('completed user is redirected to calendar', function () {
$this->user->update(['setup' => Setup::Completed]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertRedirect(route('app.calendar'));
});
// Step enforcement tests
test('step1 redirects to connect when user already completed role step', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertRedirect(route('app.onboarding.account'));
});
test('step1 redirects to calendar when setup is completed', function () {
$this->user->update(['setup' => Setup::Completed]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertRedirect(route('app.calendar'));
});
test('step2 redirects to role when user has not completed role step', function () {
$this->user->update(['setup' => Setup::Role]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertRedirect(route('app.onboarding.role'));
});
test('step2 redirects to calendar when setup is completed', function () {
$this->user->update(['setup' => Setup::Completed]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertRedirect(route('app.calendar'));
});
test('user on role step can access role page', function () {
$this->user->update(['setup' => Setup::Role]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.role'));
$response->assertOk();
});
test('user on connections step can access connect page', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.account'));
$response->assertOk();
});