trypost/tests/Feature/Auth/SignupUtmTrackingTest.php
Paulo Castellano 2e9e0f716b feat: capture signup UTMs/IP and add GitHub OAuth login
Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):

- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
  the register/redirect GET, retrieves them on the POST/callback —
  surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer

Adds GitHub as a second OAuth provider:

- GitHubController mirroring the Google one (now renamed from
  SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
  on Login/Register, rendering each enabled provider plus a single
  "or continue with" divider

UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
2026-05-04 18:42:25 -03:00

319 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
test('email registration saves utm parameters from the register page query string', function () {
$utms = [
'utm_source' => 'peerlist',
'utm_medium' => 'social',
'utm_campaign' => 'spring-launch',
'utm_term' => 'social-platform',
'utm_content' => 'cta-button',
];
$this->get(route('register', $utms));
$this->post(route('register.store'), [
'name' => 'UTM User',
'email' => 'utm@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', $utms, absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'utm@example.com',
...$utms,
]);
});
test('email registration without utm parameters saves null utm columns and redirects without query string', function () {
$this->post(route('register.store'), [
'name' => 'No UTM User',
'email' => 'no-utm@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'no-utm@example.com',
'utm_source' => null,
'utm_medium' => null,
'utm_campaign' => null,
'utm_term' => null,
'utm_content' => null,
]);
});
test('email registration strips non-utm query params from the success redirect', function () {
$this->get(route('register', [
'utm_source' => 'peerlist',
'foo' => 'bar',
'ref' => '123',
]));
$this->post(route('register.store'), [
'name' => 'Strip Test',
'email' => 'strip@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', ['utm_source' => 'peerlist'], absolute: false));
});
test('google registration saves utm parameters captured before the oauth round-trip', function () {
$utms = [
'utm_source' => 'twitter',
'utm_medium' => 'paid',
'utm_campaign' => 'q1-growth',
];
$this->get(route('auth.google.redirect', $utms));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'g-utm';
$socialiteUser->name = 'Google UTM';
$socialiteUser->email = 'google-utm@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', $utms, absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'google-utm@example.com',
...$utms,
'utm_term' => null,
'utm_content' => null,
]);
});
test('utm parameters captured on the register page survive a google oauth round-trip', function () {
$utms = ['utm_source' => 'newsletter', 'utm_medium' => 'email'];
$this->get(route('register', $utms));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'g-cross';
$socialiteUser->name = 'Cross Flow';
$socialiteUser->email = 'cross-flow@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', $utms, absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'cross-flow@example.com',
...$utms,
]);
});
test('existing google user login consumes the utm session so utms do not leak to a later signup', function () {
User::factory()->create([
'email' => 'existing@example.com',
'google_id' => 'g-existing',
]);
$this->get(route('auth.google.redirect', ['utm_source' => 'twitter']));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'g-existing';
$socialiteUser->name = 'Existing User';
$socialiteUser->email = 'existing@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('utm_parameters'))->toBeNull();
});
test('invitation registration does not include utm parameters in its redirect', function () {
$this->get(route('register', ['utm_source' => 'email']));
$this->post(route('register.store'), [
'name' => 'Invited User',
'email' => 'invited@example.com',
'password' => 'Password123!',
'redirect' => '/invites/some-token',
])
->assertRedirect('/invites/some-token');
});
test('utm values longer than 255 characters are truncated before being stored', function () {
$longValue = str_repeat('a', 300);
$this->get(route('register', ['utm_source' => $longValue]));
$this->post(route('register.store'), [
'name' => 'Long UTM User',
'email' => 'long-utm@example.com',
'password' => 'Password123!',
]);
$user = User::where('email', 'long-utm@example.com')->first();
expect(mb_strlen($user->utm_source))->toBe(255);
});
test('email registration captures the requesting ip address', function () {
$this->post(route('register.store'), [
'name' => 'IP User',
'email' => 'ip@example.com',
'password' => 'Password123!',
]);
$user = User::where('email', 'ip@example.com')->first();
expect($user->registration_ip)->not->toBeNull();
});
test('google registration captures the requesting ip address', function () {
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'g-ip';
$socialiteUser->name = 'Google IP';
$socialiteUser->email = 'google-ip@example.com';
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')
->andReturn($socialiteUser);
$this->get(route('auth.google.callback'));
$user = User::where('email', 'google-ip@example.com')->first();
expect($user->registration_ip)->not->toBeNull();
});
test('github registration saves utm parameters captured before the oauth round-trip', function () {
$utms = [
'utm_source' => 'hackernews',
'utm_medium' => 'organic',
'utm_campaign' => 'launch-week',
];
$this->get(route('auth.github.redirect', $utms));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'gh-utm';
$socialiteUser->name = 'GitHub UTM';
$socialiteUser->email = 'github-utm@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', $utms, absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'github-utm@example.com',
...$utms,
'utm_term' => null,
'utm_content' => null,
]);
});
test('github registration captures the requesting ip address', function () {
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'gh-ip';
$socialiteUser->name = 'GitHub IP';
$socialiteUser->email = 'github-ip@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'));
$user = User::where('email', 'github-ip@example.com')->first();
expect($user->registration_ip)->not->toBeNull();
expect($user->github_id)->toBe('gh-ip');
});
test('github registration without email redirects to login with error', function () {
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'gh-no-email';
$socialiteUser->name = 'No Email';
$socialiteUser->email = null;
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('login'))
->assertSessionHasErrors('email');
$this->assertGuest();
});
test('existing github user login consumes the utm session and skips signup success', function () {
User::factory()->create([
'email' => 'existing-gh@example.com',
'github_id' => 'gh-existing',
]);
$this->get(route('auth.github.redirect', ['utm_source' => 'hackernews']));
$socialiteUser = new SocialiteUser;
$socialiteUser->id = 'gh-existing';
$socialiteUser->name = 'Existing GitHub';
$socialiteUser->email = 'existing-gh@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('utm_parameters'))->toBeNull();
});