Files
amare/tests/Feature/PublicSite/ContactBriefingOriginTest.php

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('/briefing?utm_source=instagram&utm_medium=social&utm_campaign=lancamento-2026')
->assertOk();
$this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('briefing'))
->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('briefing.store'), $this->validPayload());
$response
->assertRedirect(route('briefing'))
->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('/briefing')
->assertOk();
$this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('briefing'))
->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('/briefing?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('briefing.store'), $this->validPayload())
->assertRedirect(route('briefing'))
->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('/briefing?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('/briefing?utm_source=facebook&utm_medium=social')
->assertOk();
$this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('briefing'))
->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('/briefing?utm_source=instagram&utm_campaign=segredo-interno')
->assertOk();
$this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('briefing'))
->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('briefing.store'), $this->validPayload());
$response
->assertRedirect(route('briefing'))
->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('/briefing?'.http_build_query([
'utm_source' => '<script>alert(1)</script>',
'utm_campaign' => $overlongCampaign,
]))->assertOk();
$this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('briefing'))
->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);
}
}