255 lines
9.5 KiB
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);
|
|
}
|
|
}
|