Files
amare/tests/Feature/PublicSite/ContactBriefingOriginTest.php
Manoel Freitas 37296f758f feat: capturar origem de marketing do briefing de contato (MAN-105) (#37)
* feat: capturar origem de marketing do briefing de contato (MAN-105)

Adiciona tracking simples da origem (UTM ou referrer, primeiro-toque
vence) para satisfazer WEB-05: a origem viaja apenas com o e-mail
interno do briefing, sem Lead model, tabela ou superfície de CRM
(Fase 2 fora de escopo). Middleware no grupo "web" cobre só as rotas
públicas (o painel Filament tem sua própria stack de middleware);
lógica de captura/sanitização isolada em App\Domain\Contact para não
poluir controller nem view.

Fail-open por construção: qualquer falha ao capturar ou ler a origem é
logada sem dados pessoais e a submissão segue normalmente. Respeita
LGPD (§12.5): nada além da origem é guardado, valores são truncados
para não inflar a sessão, e um referrer apontando para o próprio host
é descartado por não ser sinal de marketing. Valores não confiáveis
(query string, referrer) são sanitizados antes de entrar na sessão e
no e-mail em HTML.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(contato): capturar apenas a origem do referrer, não a URL completa

O fallback de referrer em MarketingOrigin::capture() armazenava a URL
inteira (incluindo query string e fragmento), não apenas o site de
origem. Um link de campanha de e-mail personalizado (ex.:
?email=...&subscriber_id=...) chegava intacto à sessão (persistida em
banco via SESSION_DRIVER=database) e ao corpo do e-mail de briefing
lido por um humano — indo além do que SPEC.md WEB-05 pede ("origem de
marketing") e do §12.5 ("coletar apenas dados necessários").

Agora o referrer é reduzido a esquema+host antes de sanitizar,
descartando path, query string e userinfo.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* test(contato): cobrir first-touch quando a navegação seguinte traz UTM concorrente

test_first_touch_wins_when_later_navigation_has_no_utm não exercitava
de fato a guarda "primeira captura vence" em CaptureMarketingOrigin —
as duas navegações seguintes não carregavam nenhum sinal (nem UTM, nem
referrer), então MarketingOrigin::capture() já retornava [] e o
early-return por ausência de sinal (não a guarda de sessão) é quem
impedia a sobrescrita. Confirmado removendo a guarda: a suíte
continuava passando.

Este teste novo usa uma segunda navegação com UTM diferente
(utm_source=facebook), o que só passa se a guarda de sessão estiver
ativa — mutação verificada manualmente antes de escrever a asserção.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(contato): não capturar origem em sitemap.xml e robots.txt

O middleware estava anexado ao grupo `web` inteiro, que também cobre
`/sitemap.xml` e `/robots.txt`. Um crawler chegando em
`/sitemap.xml?utm_source=...` consumia o slot de primeiro toque com
tráfego que nunca vai enviar um briefing, e ainda iniciava sessão em uma
rota técnica.

As duas rotas passam a ser ignoradas por nome, com teste cobrindo que
elas não gravam nada na sessão e que uma visita humana seguinte ainda é
capturada normalmente.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 09:12:26 -03:00

255 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Domain\Contact\MarketingOrigin;
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;
/**
* Covers MAN-105 — simple marketing-origin tracking that rides along
* with the briefing e-mail (SPEC.md WEB-05). No Lead model, no CRM
* surface: the origin never outlives the session/e-mail round trip.
*/
class ContactBriefingOriginTest 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_utm_parameters_present_on_arrival_reach_the_briefing_email(): void
{
SiteSetting::instance();
Mail::fake();
$this->get('/contato?utm_source=instagram&utm_medium=social&utm_campaign=lancamento-2026')
->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
$origin = $mail->fields['Origem de marketing'];
return $origin === 'origem: instagram | mídia: social | campanha: lancamento-2026';
});
}
public function test_no_utm_or_referrer_still_submits_and_omits_the_origin(): void
{
SiteSetting::instance();
Mail::fake();
// No GET beforehand: posting cold, exactly like a direct/no-JS submission.
$response = $this->post(route('contact.store'), $this->validPayload());
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return array_key_exists('Origem de marketing', $mail->fields)
&& $mail->fields['Origem de marketing'] === null;
});
}
public function test_referrer_only_visit_records_only_the_referring_site_as_origin(): void
{
SiteSetting::instance();
Mail::fake();
// The query string is a personalized campaign link (carries an
// identifiable e-mail/subscriber id) — only the site identity may
// be captured as marketing origin, never the query string itself.
$this->withHeader('referer', 'https://mail.example.com/click?email=maria%40example.com&subscriber_id=42')
->get('/contato')
->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return $mail->fields['Origem de marketing'] === 'https://mail.example.com';
});
}
public function test_first_touch_wins_when_later_navigation_has_no_utm(): void
{
SiteSetting::instance();
Mail::fake();
$this->get('/contato?utm_source=google&utm_medium=cpc')
->assertOk();
// Navigate elsewhere with no UTM parameters at all before submitting.
$this->get('/servicos')->assertOk();
$this->get('/sobre')->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return $mail->fields['Origem de marketing'] === 'origem: google | mídia: cpc';
});
}
public function test_first_touch_wins_when_later_navigation_carries_a_competing_utm(): void
{
SiteSetting::instance();
Mail::fake();
$this->get('/contato?utm_source=google&utm_medium=cpc')
->assertOk();
// A second page load with its own, different UTM parameters must
// not overwrite what was captured on first touch.
$this->get('/contato?utm_source=facebook&utm_medium=social')
->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return $mail->fields['Origem de marketing'] === 'origem: google | mídia: cpc';
});
}
public function test_confirmation_email_never_exposes_the_marketing_origin(): void
{
$settings = SiteSetting::instance();
Mail::fake();
$this->get('/contato?utm_source=instagram&utm_campaign=segredo-interno')
->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
$rendered = $mail->render();
return ! str_contains($rendered, 'instagram') && ! str_contains($rendered, 'segredo-interno');
});
// The internal briefing mail is the only one carrying the origin.
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
return $mail->hasTo($settings->email)
&& str_contains((string) $mail->fields['Origem de marketing'], 'segredo-interno');
});
}
public function test_fail_open_when_stored_marketing_origin_is_corrupted(): void
{
SiteSetting::instance();
Mail::fake();
// Simulates the session value having been tampered with or
// corrupted (e.g. by an unrelated bug) into something that is not
// the array shape the controller expects. The acceptance
// criterion is that this never blocks the submission.
$response = $this->withSession([MarketingOrigin::SESSION_KEY => 'lixo'])
->post(route('contact.store'), $this->validPayload());
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return array_key_exists('Origem de marketing', $mail->fields)
&& $mail->fields['Origem de marketing'] === null;
});
}
public function test_hostile_marketing_origin_values_are_neutralised(): void
{
SiteSetting::instance();
Mail::fake();
$overlongCampaign = str_repeat('a', 300);
$this->get('/contato?'.http_build_query([
'utm_source' => '<script>alert(1)</script>',
'utm_campaign' => $overlongCampaign,
]))->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
$capturedOrigin = null;
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use (&$capturedOrigin): bool {
$capturedOrigin = $mail->fields['Origem de marketing'];
return true;
});
self::assertIsString($capturedOrigin);
self::assertStringNotContainsString('<script', $capturedOrigin);
self::assertStringNotContainsString('<', $capturedOrigin);
self::assertStringNotContainsString('>', $capturedOrigin);
self::assertMatchesRegularExpression('/campanha: (a+)/', $capturedOrigin);
preg_match('/campanha: (a+)/', $capturedOrigin, $matches);
self::assertSame(100, mb_strlen($matches[1]));
// Rendering into the HTML e-mail must not reintroduce markup either.
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
$rendered = $mail->render();
return ! str_contains($rendered, '<script>alert(1)</script>');
});
}
public function test_technical_routes_do_not_consume_the_first_touch_slot(): void
{
$this->get('/sitemap.xml?utm_source=crawler&utm_medium=bot')->assertOk();
$this->get('/robots.txt?utm_source=crawler')->assertOk();
$this->assertNull(session(MarketingOrigin::SESSION_KEY));
$this->get('/?utm_source=instagram&utm_medium=perfil')->assertOk();
$this->assertSame('instagram', session(MarketingOrigin::SESSION_KEY)['utm_source'] ?? null);
}
}