* fix(contato): normalizar e-mail e telefone no formulário público de briefing
SPEC.md §12.3 exige normalização de e-mail e telefone no formulário
público, mas o ContactBriefingRequest só validava, sem normalizar
(MAN-104).
E-mail: aplica trim + lowercase em prepareForValidation — evita
duplicatas triviais ("Maria@X.com" vs "maria@x.com") e mantém a
detecção de duplicidade por hash consistente.
Telefone: normaliza para o formato brasileiro legível
"(DD) 9XXXX-XXXX"/"(DD) XXXX-XXXX", extraído para o value object
App\Domain\Contact\BrazilianPhoneNumber por concentrar lógica de
decisão (contagem de dígitos, remoção de DDI 55) que merece teste
isolado. Optou-se pelo formato com máscara em vez de dígitos puros
porque o Mailable ContactBriefing e seus templates (html/text) apenas
imprimem o valor do campo "Telefone/WhatsApp" em uma tabela/lista,
sem link "tel:" nem formatação na view — "(11) 98888-7777" é o que
fica legível para quem recebe o briefing por e-mail, enquanto
"11988887777" é opaco de bater o olho.
Para não descartar informação que o destinatário precisa, o
normalizador só reformata quando a string é composta exclusivamente
por caracteres de telefone; anotações como "(WhatsApp)" ou "falar com
João" junto do número são preservadas como estão. Entradas que não
batem com 10/11 dígitos (após remover DDI) também são preservadas,
apenas com espaços internos colapsados.
Honeypot, rate limit e aceite de privacidade não foram tocados.
Cobertura: teste de unidade para o value object (formatos válidos,
DDI, anotações, formatos não reconhecidos) e teste de feature
provando que uma submissão com e-mail maiúsculo/padded e telefone
"sujo" chega normalizada nos Mailables ContactBriefing e
ContactBriefingConfirmation.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
* fix(contato): evitar fabricar DDD/9º dígito em números não brasileiros
O normalizador aceitava qualquer string de 10 ou 11 dígitos como se
fosse um telefone brasileiro, sem checar se os dois primeiros dígitos
formam um DDD plausível (11-99, nunca com zero em nenhuma posição) ou
se um número de 11 dígitos tem o 9º dígito obrigatório do celular.
Isso fazia números estrangeiros como '2025551234' (EUA) virarem
'(20) 2555-1234' — um número brasileiro plausível, porém inventado,
que destrói silenciosamente o contato real na tabela do e-mail de
briefing. Um '+55' explícito sem DDD (ex.: '+55 98888-7777') também
era lido como DDD 55 em vez de número incompleto.
Passa a validar a forma do DDD e o 9º dígito do celular antes de
formatar, preservando o texto original quando a checagem falha —
mesmo comportamento já usado para números com contagem de dígitos
fora do padrão. Mantém DDD 55 (Rio Grande do Sul) funcionando
normalmente quando o DDD é digitado de fato.
Limitação residual conhecida e aceita: DDDs americanos que colidem
estruturalmente com um DDD brasileiro válido (ex. '2125551234', área
212 de Nova York, bate com DDD 21) continuam sendo formatados como
brasileiros — não há como distinguir os dois casos só pela forma dos
dígitos. Da mesma forma, um '55' sem o '+' explícito (ex.
'55988887777') é ambiguamente tratado como DDD 55 real, já que nada
no texto indica se é código de país ou área.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
---------
Co-authored-by: manoel.neto <manoel.neto@creditas.com>
190 lines
6.2 KiB
PHP
190 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\PublicSite;
|
|
|
|
use App\Mail\ContactBriefing;
|
|
use App\Mail\ContactBriefingConfirmation;
|
|
use App\Models\SiteSetting;
|
|
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Tests\TestCase;
|
|
|
|
class ContactBriefingTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->withoutMiddleware([ThrottleRequests::class, PreventRequestForgery::class]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function validPayload(array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'nome' => 'Maria Silva',
|
|
'email' => 'maria@example.com',
|
|
'telefone' => '(11) 98888-7777',
|
|
'tipo_evento' => 'Casamento',
|
|
'data_periodo' => 'novembro de 2027',
|
|
'cidade' => 'São Paulo',
|
|
'convidados' => '120',
|
|
'servico_interesse' => 'Planejamento completo',
|
|
'mensagem' => 'Queremos um casamento ao ar livre para 120 convidados.',
|
|
'privacidade' => '1',
|
|
'empresa' => '',
|
|
], $overrides);
|
|
}
|
|
|
|
public function test_contact_page_renders_briefing_form_with_all_fields(): void
|
|
{
|
|
SiteSetting::instance();
|
|
|
|
$response = $this->get(route('contact'));
|
|
|
|
$response
|
|
->assertOk()
|
|
->assertSee('<form', false)
|
|
->assertSee('action="'.route('contact.store').'"', false)
|
|
->assertSee('name="nome"', false)
|
|
->assertSee('name="email"', false)
|
|
->assertSee('name="telefone"', false)
|
|
->assertSee('name="tipo_evento"', false)
|
|
->assertSee('name="data_periodo"', false)
|
|
->assertSee('name="cidade"', false)
|
|
->assertSee('name="convidados"', false)
|
|
->assertSee('name="servico_interesse"', false)
|
|
->assertSee('name="mensagem"', false)
|
|
->assertSee('name="privacidade"', false)
|
|
->assertSee('name="empresa"', false)
|
|
->assertSee(route('privacy'), false);
|
|
}
|
|
|
|
public function test_valid_submission_sends_briefing_and_confirmation_emails(): void
|
|
{
|
|
$settings = SiteSetting::instance();
|
|
Mail::fake();
|
|
|
|
$response = $this->post(route('contact.store'), $this->validPayload());
|
|
|
|
$response
|
|
->assertRedirect(route('contact'))
|
|
->assertSessionHas('status', 'briefing-sent');
|
|
|
|
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
|
return $mail->hasTo($settings->email);
|
|
});
|
|
|
|
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
|
|
return $mail->hasTo('maria@example.com');
|
|
});
|
|
}
|
|
|
|
public function test_submission_normalizes_email_and_phone_before_reaching_mailables(): void
|
|
{
|
|
$settings = SiteSetting::instance();
|
|
Mail::fake();
|
|
|
|
$response = $this->post(route('contact.store'), $this->validPayload([
|
|
'email' => ' Maria.Silva@EXAMPLE.com ',
|
|
'telefone' => '+55 (11) 98888-7777',
|
|
]));
|
|
|
|
$response
|
|
->assertRedirect(route('contact'))
|
|
->assertSessionHas('status', 'briefing-sent');
|
|
|
|
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
|
return $mail->hasTo($settings->email)
|
|
&& $mail->fields['E-mail'] === 'maria.silva@example.com'
|
|
&& $mail->fields['Telefone/WhatsApp'] === '(11) 98888-7777';
|
|
});
|
|
|
|
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
|
|
return $mail->hasTo('maria.silva@example.com');
|
|
});
|
|
}
|
|
|
|
public function test_honeypot_submission_is_dropped_without_sending_emails(): void
|
|
{
|
|
SiteSetting::instance();
|
|
Mail::fake();
|
|
|
|
$response = $this->post(route('contact.store'), $this->validPayload([
|
|
'empresa' => 'http://spam.example',
|
|
]));
|
|
|
|
$response
|
|
->assertRedirect(route('contact'))
|
|
->assertSessionHas('status', 'briefing-sent');
|
|
|
|
Mail::assertNothingSent();
|
|
}
|
|
|
|
public function test_submission_requires_privacy_acceptance(): void
|
|
{
|
|
SiteSetting::instance();
|
|
Mail::fake();
|
|
|
|
$response = $this->from(route('contact'))->post(route('contact.store'), $this->validPayload([
|
|
'privacidade' => '',
|
|
]));
|
|
|
|
$response->assertRedirect(route('contact'));
|
|
$response->assertSessionHasErrors('privacidade');
|
|
|
|
Mail::assertNothingSent();
|
|
}
|
|
|
|
public function test_invalid_submission_returns_validation_errors(): void
|
|
{
|
|
SiteSetting::instance();
|
|
Mail::fake();
|
|
|
|
$response = $this->from(route('contact'))->post(route('contact.store'), []);
|
|
|
|
$response->assertRedirect(route('contact'));
|
|
|
|
foreach (['nome', 'email', 'telefone', 'tipo_evento', 'cidade', 'mensagem', 'privacidade'] as $field) {
|
|
$response->assertSessionHasErrors($field);
|
|
}
|
|
|
|
Mail::assertNothingSent();
|
|
}
|
|
|
|
public function test_duplicate_submission_is_not_sent_twice(): void
|
|
{
|
|
SiteSetting::instance();
|
|
Mail::fake();
|
|
|
|
$payload = $this->validPayload();
|
|
|
|
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
|
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
|
|
|
Mail::assertQueued(ContactBriefing::class, 1);
|
|
Mail::assertQueued(ContactBriefingConfirmation::class, 1);
|
|
}
|
|
|
|
public function test_email_failure_does_not_block_submission(): void
|
|
{
|
|
SiteSetting::instance();
|
|
config(['mail.default' => 'mailer-inexistente']);
|
|
|
|
$response = $this->post(route('contact.store'), $this->validPayload());
|
|
|
|
$response
|
|
->assertRedirect(route('contact'))
|
|
->assertSessionHas('status', 'briefing-sent');
|
|
}
|
|
}
|