feat: capture ad click IDs for ad-platform conversion attribution (#276)
* feat: capture ad click IDs for Meta/Google/LinkedIn/TikTok/Reddit/Pinterest attribution Adds gclid, fbclid, li_fat_id, ttclid, rdt_cid, and epik columns to users, captured the same way UTM parameters already are (query string -> session -> persisted on signup, surviving the OAuth redirect round-trip via the new PreservesClickIds trait). Forwards them as first-touch ($set_once) PostHog person properties in SyncUser, so PostHog's native ad-platform destinations (Meta Ads Conversions API, Google Ads Conversions, LinkedIn Ads, TikTok Ads, Reddit Ads, Pinterest) have first-party click IDs to match conversions back to the originating ad click. * refactor: unify PreservesUtmParameters and PreservesClickIds into one trait Both traits captured a set of query-string keys into the session and retrieved them at signup, with identical extract/store/retrieve logic and every call site always using both together — the split added no real separation, just duplicated the same mechanism twice. PreservesAttributionParameters replaces both with a single ATTRIBUTION_KEYS list and one session key. Adding a future ad network's click ID is now one line in that list instead of a second trait. * refactor: split UTM_KEYS and CLICK_ID_KEYS into separate constants Same single trait, single session key, single extract/store/retrieve mechanism — just two named arrays instead of one merged list, so it's clear at a glance which key belongs to which category. * fix: don't truncate ad click IDs to 255 chars, only UTM parameters Ad platforms explicitly warn against assuming a fixed max length for click IDs (Google: gclid has already grown from 26 to 100+ chars, and their docs say never truncate or validate against a fixed length). Truncating would silently corrupt the value into something that no longer matches the real click ID, which is worse than not capturing it at all. Widens the click-id columns from string (VARCHAR 255) to text — safe to edit the migration in place since it hasn't shipped to production yet. UTM parameters still get truncated to 255, since those are ours (our own campaign URLs) and the column stays VARCHAR(255). * refactor: use Laravel collection/Str helpers, forward UTMs to PostHog too - extractAttributionParameters now reads through collect()/Str::limit() instead of raw array_filter/array_map/mb_substr; storeAttributionParameters drops its now-redundant emptiness check since retrieveAttributionParameters already treats "absent" and "present-but-empty" the same via pull()'s default. - SyncUser forwards utm_source/medium/campaign/term/content alongside the click ids as first-touch ($set_once) PostHog person properties. UTMs were never sent to PostHog before this, on any prior code — now that PostHog is the source of truth for ad-platform attribution, it should have the full picture, not just click ids. - Adds the missing GitHub-existing-user click-id session test, mirroring the Google one (parity with the existing UTM coverage). * fix: 3 issues found by review — empty-string leak, duplicated key list, comment style - extractAttributionParameters no longer keeps an empty-string value (e.g. ?utm_source=&gclid=, which some ad/email templates always append even for unfilled slots). The refactor to collect()/Str::limit() a few commits back dropped the outer array_filter() that used to strip these, so they were slipping into User::create() as '' instead of staying null. Restored via a trailing ->filter() on the merged result, and extended the same protection to click ids (which never had it, even before that refactor). - New App\Support\AttributionKeys centralizes the UTM_KEYS/CLICK_ID_KEYS lists that PreservesAttributionParameters and SyncUser each maintained independently. SyncUser previously hand-listed the same 11 field names as a second array with no shared source of truth — a future ad network added to the trait would silently never reach PostHog unless someone remembered to update this second copy too. - Removed the // comment block from the click-id migration explaining the text-column rationale — CLAUDE.md's PHP rules reserve inline comments for exceptionally complex logic; the rationale already lives in the commit message that introduced it.
This commit is contained in:
parent
95bb537f3d
commit
ca1e346227
13 changed files with 427 additions and 75 deletions
|
|
@ -17,11 +17,11 @@ class CreateUser
|
|||
{
|
||||
/**
|
||||
* @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null} $data
|
||||
* @param array<string, string> $utmParameters
|
||||
* @param array<string, string> $attributionParameters UTM parameters and ad click IDs (gclid, fbclid, etc.) captured before signup
|
||||
*/
|
||||
public static function execute(array $data, array $utmParameters = []): User
|
||||
public static function execute(array $data, array $attributionParameters = []): User
|
||||
{
|
||||
$user = DB::transaction(function () use ($data, $utmParameters): User {
|
||||
$user = DB::transaction(function () use ($data, $attributionParameters): User {
|
||||
$isInviteRegistration = data_get($data, 'is_invite', false);
|
||||
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
||||
$accountAttributes = [
|
||||
|
|
@ -45,7 +45,7 @@ public static function execute(array $data, array $utmParameters = []): User
|
|||
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
|
||||
'account_id' => $account->id,
|
||||
'registration_ip' => data_get($data, 'registration_ip'),
|
||||
], $utmParameters));
|
||||
], $attributionParameters));
|
||||
|
||||
$account->update(['owner_id' => $user->id]);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth\Concerns;
|
||||
|
||||
use App\Support\AttributionKeys;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait PreservesAttributionParameters
|
||||
{
|
||||
/**
|
||||
* UTM values are ours (our own campaign URLs), so truncating them to a
|
||||
* safe length is fine. Click IDs are opaque tokens assigned by the ad
|
||||
* platform — Google explicitly warns never to truncate or assume a
|
||||
* fixed max length for gclid, since it has already grown over time. The
|
||||
* `gclid`/etc. columns are `text`, so there's no need to cap them here.
|
||||
*
|
||||
* A key present with an empty value (e.g. `?utm_source=&gclid=`, which
|
||||
* some ad/email templates always append) is filtered out along with
|
||||
* absent keys, so it never overwrites a null column with `''`.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function extractAttributionParameters(Request $request): array
|
||||
{
|
||||
$utm = collect($request->only(AttributionKeys::UTM))
|
||||
->filter(fn ($value) => is_string($value))
|
||||
->map(fn (string $value) => Str::limit($value, 255, ''));
|
||||
|
||||
$clickIds = collect($request->only(AttributionKeys::CLICK_ID))
|
||||
->filter(fn ($value) => is_string($value));
|
||||
|
||||
return $utm->merge($clickIds)->filter()->all();
|
||||
}
|
||||
|
||||
private function storeAttributionParameters(Request $request): void
|
||||
{
|
||||
$request->session()->put('attribution_parameters', $this->extractAttributionParameters($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function retrieveAttributionParameters(): array
|
||||
{
|
||||
return session()->pull('attribution_parameters', []);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait PreservesUtmParameters
|
||||
{
|
||||
private const array UTM_KEYS = [
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function extractUtmParameters(Request $request): array
|
||||
{
|
||||
return array_filter(
|
||||
array_map(
|
||||
fn (string $value) => mb_substr($value, 0, 255),
|
||||
array_filter($request->only(self::UTM_KEYS), 'is_string'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private function storeUtmParameters(Request $request): void
|
||||
{
|
||||
$utms = $this->extractUtmParameters($request);
|
||||
|
||||
if ($utms !== []) {
|
||||
$request->session()->put('utm_parameters', $utms);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function retrieveUtmParameters(): array
|
||||
{
|
||||
return session()->pull('utm_parameters', []);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Actions\User\CreateUser;
|
||||
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
|
||||
use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
|
|
@ -16,11 +16,11 @@
|
|||
|
||||
class GitHubController extends Controller
|
||||
{
|
||||
use PreservesUtmParameters;
|
||||
use PreservesAttributionParameters;
|
||||
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$this->storeUtmParameters($request);
|
||||
$this->storeAttributionParameters($request);
|
||||
|
||||
return Socialite::driver('github')
|
||||
->scopes(['read:user', 'user:email'])
|
||||
|
|
@ -90,14 +90,14 @@ private function loginExistingUser(User $user, string $githubId): RedirectRespon
|
|||
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
$this->retrieveUtmParameters();
|
||||
$this->retrieveAttributionParameters();
|
||||
|
||||
return redirect()->route('app.home');
|
||||
}
|
||||
|
||||
private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): RedirectResponse
|
||||
{
|
||||
$utmParameters = $this->retrieveUtmParameters();
|
||||
$attributionParameters = $this->retrieveAttributionParameters();
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => $githubUser->getName() ?? $githubUser->getNickname() ?? explode('@', $githubUser->getEmail())[0],
|
||||
|
|
@ -105,7 +105,7 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser):
|
|||
'github_id' => (string) $githubUser->getId(),
|
||||
'email_verified_at' => now(),
|
||||
'registration_ip' => request()->ip(),
|
||||
], $utmParameters);
|
||||
], $attributionParameters);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
|
|
@ -113,6 +113,6 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser):
|
|||
|
||||
session()->flash('auth_provider', 'github');
|
||||
|
||||
return redirect()->route('register.success', $utmParameters);
|
||||
return redirect()->route('register.success', $attributionParameters);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Actions\User\CreateUser;
|
||||
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
|
||||
use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
|
|
@ -16,11 +16,11 @@
|
|||
|
||||
class GoogleController extends Controller
|
||||
{
|
||||
use PreservesUtmParameters;
|
||||
use PreservesAttributionParameters;
|
||||
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$this->storeUtmParameters($request);
|
||||
$this->storeAttributionParameters($request);
|
||||
|
||||
return Socialite::driver('google-auth')->redirect();
|
||||
}
|
||||
|
|
@ -82,14 +82,14 @@ private function loginExistingUser(User $user, string $googleId): RedirectRespon
|
|||
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
$this->retrieveUtmParameters();
|
||||
$this->retrieveAttributionParameters();
|
||||
|
||||
return redirect()->route('app.home');
|
||||
}
|
||||
|
||||
private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse
|
||||
{
|
||||
$utmParameters = $this->retrieveUtmParameters();
|
||||
$attributionParameters = $this->retrieveAttributionParameters();
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => $googleUser->getName(),
|
||||
|
|
@ -97,7 +97,7 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser):
|
|||
'google_id' => $googleUser->getId(),
|
||||
'email_verified_at' => now(),
|
||||
'registration_ip' => request()->ip(),
|
||||
], $utmParameters);
|
||||
], $attributionParameters);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
|
|
@ -105,6 +105,6 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser):
|
|||
|
||||
session()->flash('auth_provider', 'google');
|
||||
|
||||
return redirect()->route('register.success', $utmParameters);
|
||||
return redirect()->route('register.success', $attributionParameters);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Actions\User\CreateUser;
|
||||
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
|
||||
use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\Auth\RegisterRequest;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
|
|
@ -17,11 +17,11 @@
|
|||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
use PreservesUtmParameters;
|
||||
use PreservesAttributionParameters;
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$this->storeUtmParameters($request);
|
||||
$this->storeAttributionParameters($request);
|
||||
|
||||
return Inertia::render('auth/Register', [
|
||||
'email' => $request->query('email'),
|
||||
|
|
@ -32,7 +32,7 @@ public function create(Request $request): Response
|
|||
|
||||
public function store(RegisterRequest $request): RedirectResponse
|
||||
{
|
||||
$utmParameters = $this->retrieveUtmParameters();
|
||||
$attributionParameters = $this->retrieveAttributionParameters();
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => $request->validated('name'),
|
||||
|
|
@ -40,7 +40,7 @@ public function store(RegisterRequest $request): RedirectResponse
|
|||
'password' => $request->validated('password'),
|
||||
'is_invite' => $request->isInviteRegistration(),
|
||||
'registration_ip' => $request->ip(),
|
||||
], $utmParameters);
|
||||
], $attributionParameters);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
|
|
@ -56,6 +56,6 @@ public function store(RegisterRequest $request): RedirectResponse
|
|||
|
||||
session()->flash('auth_provider', 'email');
|
||||
|
||||
return redirect()->route('register.success', $utmParameters);
|
||||
return redirect()->route('register.success', $attributionParameters);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Models\User;
|
||||
use App\Services\PostHogService;
|
||||
use App\Support\AttributionKeys;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
|
|
@ -37,10 +38,18 @@ public function handle(PostHogService $postHog): void
|
|||
return;
|
||||
}
|
||||
|
||||
$attribution = collect(AttributionKeys::all())
|
||||
->mapWithKeys(fn (string $key) => [$key => $user->{$key}])
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$postHog->identify($user->id, [
|
||||
'$email' => $user->email,
|
||||
'$name' => $user->name,
|
||||
'$set_once' => ['signed_up_at' => $user->created_at?->toIso8601String()],
|
||||
'$set_once' => [
|
||||
'signed_up_at' => $user->created_at?->toIso8601String(),
|
||||
...$attribution,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($user->account_id) {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable
|
|||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'gclid',
|
||||
'fbclid',
|
||||
'li_fat_id',
|
||||
'ttclid',
|
||||
'rdt_cid',
|
||||
'epik',
|
||||
'registration_ip',
|
||||
'persona',
|
||||
'goals',
|
||||
|
|
|
|||
44
app/Support/AttributionKeys.php
Normal file
44
app/Support/AttributionKeys.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* UTM parameters and ad click IDs captured before signup (query string ->
|
||||
* session -> persisted on the users row), shared by
|
||||
* App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters (capture)
|
||||
* and App\Jobs\PostHog\SyncUser (forwarding to PostHog).
|
||||
*/
|
||||
final class AttributionKeys
|
||||
{
|
||||
public const array UTM = [
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-platform ad click IDs (Google, Meta, LinkedIn, TikTok, Reddit,
|
||||
* Pinterest). Adding a new ad network's click ID is just one more key
|
||||
* here.
|
||||
*/
|
||||
public const array CLICK_ID = [
|
||||
'gclid',
|
||||
'fbclid',
|
||||
'li_fat_id',
|
||||
'ttclid',
|
||||
'rdt_cid',
|
||||
'epik',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [...self::UTM, ...self::CLICK_ID];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->text('gclid')->nullable()->after('utm_content');
|
||||
$table->text('fbclid')->nullable()->after('gclid');
|
||||
$table->text('li_fat_id')->nullable()->after('fbclid');
|
||||
$table->text('ttclid')->nullable()->after('li_fat_id');
|
||||
$table->text('rdt_cid')->nullable()->after('ttclid');
|
||||
$table->text('epik')->nullable()->after('rdt_cid');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn(['gclid', 'fbclid', 'li_fat_id', 'ttclid', 'rdt_cid', 'epik']);
|
||||
});
|
||||
}
|
||||
};
|
||||
215
tests/Feature/Auth/SignupClickIdTrackingTest.php
Normal file
215
tests/Feature/Auth/SignupClickIdTrackingTest.php
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
||||
beforeEach(fn () => config()->set('trypost.self_hosted', false));
|
||||
|
||||
test('email registration saves ad click ids from the register page query string', function () {
|
||||
$clickIds = [
|
||||
'gclid' => 'gclid-value',
|
||||
'fbclid' => 'fbclid-value',
|
||||
'li_fat_id' => 'li-fat-id-value',
|
||||
'ttclid' => 'ttclid-value',
|
||||
'rdt_cid' => 'rdt-cid-value',
|
||||
'epik' => 'epik-value',
|
||||
];
|
||||
|
||||
$this->get(route('register', $clickIds));
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Click User',
|
||||
'email' => 'click@example.com',
|
||||
'password' => 'Password123!',
|
||||
])
|
||||
->assertRedirect(route('register.success', $clickIds, absolute: false));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'click@example.com',
|
||||
...$clickIds,
|
||||
]);
|
||||
});
|
||||
|
||||
test('email registration without click ids saves null columns', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'No Click User',
|
||||
'email' => 'no-click@example.com',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'no-click@example.com',
|
||||
'gclid' => null,
|
||||
'fbclid' => null,
|
||||
'li_fat_id' => null,
|
||||
'ttclid' => null,
|
||||
'rdt_cid' => null,
|
||||
'epik' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('click id values longer than 255 characters are stored in full, unlike utm parameters', function () {
|
||||
$longValue = str_repeat('a', 300);
|
||||
|
||||
$this->get(route('register', ['gclid' => $longValue]));
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Long Click User',
|
||||
'email' => 'long-click@example.com',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$user = User::where('email', 'long-click@example.com')->first();
|
||||
|
||||
expect($user->gclid)->toBe($longValue);
|
||||
});
|
||||
|
||||
test('google registration saves ad click ids captured before the oauth round-trip', function () {
|
||||
$clickIds = ['gclid' => 'g-click', 'fbclid' => 'fb-click'];
|
||||
|
||||
$this->get(route('auth.google.redirect', $clickIds));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'g-click-user';
|
||||
$socialiteUser->name = 'Google Click';
|
||||
$socialiteUser->email = 'google-click@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.google.callback'))
|
||||
->assertRedirect(route('register.success', $clickIds, absolute: false));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'google-click@example.com',
|
||||
...$clickIds,
|
||||
]);
|
||||
});
|
||||
|
||||
test('github registration saves ad click ids captured before the oauth round-trip', function () {
|
||||
$clickIds = ['li_fat_id' => 'li-click', 'ttclid' => 'tt-click'];
|
||||
|
||||
$this->get(route('auth.github.redirect', $clickIds));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'gh-click-user';
|
||||
$socialiteUser->name = 'GitHub Click';
|
||||
$socialiteUser->email = 'github-click@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('github')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('scopes')
|
||||
->andReturnSelf();
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.github.callback'))
|
||||
->assertRedirect(route('register.success', $clickIds, absolute: false));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'github-click@example.com',
|
||||
...$clickIds,
|
||||
]);
|
||||
});
|
||||
|
||||
test('existing google user login consumes the click id session so it does not leak to a later signup', function () {
|
||||
User::factory()->create([
|
||||
'email' => 'existing-click@example.com',
|
||||
'google_id' => 'g-existing-click',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.google.redirect', ['gclid' => 'stale-click']));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'g-existing-click';
|
||||
$socialiteUser->name = 'Existing Click User';
|
||||
$socialiteUser->email = 'existing-click@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google-auth')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.google.callback'))
|
||||
->assertRedirect(route('app.home'));
|
||||
|
||||
expect(session()->get('attribution_parameters'))->toBeNull();
|
||||
});
|
||||
|
||||
test('existing github user login consumes the click id session so it does not leak to a later signup', function () {
|
||||
User::factory()->create([
|
||||
'email' => 'existing-gh-click@example.com',
|
||||
'github_id' => 'gh-existing-click',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.github.redirect', ['gclid' => 'stale-gh-click']));
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser->id = 'gh-existing-click';
|
||||
$socialiteUser->name = 'Existing GitHub Click User';
|
||||
$socialiteUser->email = 'existing-gh-click@example.com';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('github')
|
||||
->andReturn($driver = Mockery::mock());
|
||||
|
||||
$driver->shouldReceive('scopes')
|
||||
->andReturnSelf();
|
||||
|
||||
$driver->shouldReceive('user')
|
||||
->andReturn($socialiteUser);
|
||||
|
||||
$this->get(route('auth.github.callback'))
|
||||
->assertRedirect(route('app.home'));
|
||||
|
||||
expect(session()->get('attribution_parameters'))->toBeNull();
|
||||
});
|
||||
|
||||
test('an empty query string value is treated the same as an absent one, not stored as an empty string', function () {
|
||||
// Some ad/email templates always append every tracking key, leaving
|
||||
// unfilled slots blank (?utm_source=&gclid=) instead of omitting them.
|
||||
$this->get(route('register', ['utm_source' => '', 'gclid' => '']));
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Empty Param User',
|
||||
'email' => 'empty-param@example.com',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'empty-param@example.com',
|
||||
'utm_source' => null,
|
||||
'gclid' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('click ids and utm parameters are both saved when present together', function () {
|
||||
$this->get(route('register', [
|
||||
'utm_source' => 'google',
|
||||
'gclid' => 'mixed-click',
|
||||
]));
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Mixed User',
|
||||
'email' => 'mixed@example.com',
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'mixed@example.com',
|
||||
'utm_source' => 'google',
|
||||
'gclid' => 'mixed-click',
|
||||
]);
|
||||
});
|
||||
|
|
@ -146,7 +146,7 @@
|
|||
$this->get(route('auth.google.callback'))
|
||||
->assertRedirect(route('app.home'));
|
||||
|
||||
expect(session()->get('utm_parameters'))->toBeNull();
|
||||
expect(session()->get('attribution_parameters'))->toBeNull();
|
||||
});
|
||||
|
||||
test('invitation registration does not include utm parameters in its redirect', function () {
|
||||
|
|
@ -317,5 +317,5 @@
|
|||
$this->get(route('auth.github.callback'))
|
||||
->assertRedirect(route('app.home'));
|
||||
|
||||
expect(session()->get('utm_parameters'))->toBeNull();
|
||||
expect(session()->get('attribution_parameters'))->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,53 @@
|
|||
});
|
||||
});
|
||||
|
||||
test('handle forwards ad click ids as first-touch person properties when present', function () {
|
||||
$this->user->update(['gclid' => 'g-123', 'fbclid' => 'fb-456']);
|
||||
Queue::fake();
|
||||
|
||||
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
|
||||
|
||||
Queue::assertPushed(SendEvent::class, function ($job) {
|
||||
return $job->payload['properties']['$set_once']['gclid'] === 'g-123'
|
||||
&& $job->payload['properties']['$set_once']['fbclid'] === 'fb-456';
|
||||
});
|
||||
});
|
||||
|
||||
test('handle forwards utm parameters as first-touch person properties when present', function () {
|
||||
$this->user->update(['utm_source' => 'google', 'utm_medium' => 'cpc', 'utm_campaign' => 'spring-launch']);
|
||||
Queue::fake();
|
||||
|
||||
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
|
||||
|
||||
Queue::assertPushed(SendEvent::class, function ($job) {
|
||||
return $job->payload['properties']['$set_once']['utm_source'] === 'google'
|
||||
&& $job->payload['properties']['$set_once']['utm_medium'] === 'cpc'
|
||||
&& $job->payload['properties']['$set_once']['utm_campaign'] === 'spring-launch';
|
||||
});
|
||||
});
|
||||
|
||||
test('handle omits attribution properties when none are set', function () {
|
||||
Queue::fake();
|
||||
|
||||
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
|
||||
|
||||
Queue::assertPushed(SendEvent::class, function ($job) {
|
||||
$setOnce = $job->payload['properties']['$set_once'];
|
||||
|
||||
return ! array_key_exists('utm_source', $setOnce)
|
||||
&& ! array_key_exists('utm_medium', $setOnce)
|
||||
&& ! array_key_exists('utm_campaign', $setOnce)
|
||||
&& ! array_key_exists('utm_term', $setOnce)
|
||||
&& ! array_key_exists('utm_content', $setOnce)
|
||||
&& ! array_key_exists('gclid', $setOnce)
|
||||
&& ! array_key_exists('fbclid', $setOnce)
|
||||
&& ! array_key_exists('li_fat_id', $setOnce)
|
||||
&& ! array_key_exists('ttclid', $setOnce)
|
||||
&& ! array_key_exists('rdt_cid', $setOnce)
|
||||
&& ! array_key_exists('epik', $setOnce);
|
||||
});
|
||||
});
|
||||
|
||||
test('handle dispatches SyncAccountUsage with the user account id', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue