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.
351 lines
11 KiB
PHP
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();
|
|
});
|