feat: verify waitlist leads (#285)

## Summary
- gate waitlist signup behind email confirmation so only verified leads
receive a queue position and referral code
- add signed lead verification routes, a check-email page, and a
dedicated verification email for the waitlist flow
- update lead syncing and feature tests so only verified leads are
exported to Resend and referral movement happens after verification

## Testing
- php artisan test --compact tests/Feature/UserLeadTest.php
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
- php artisan test --compact tests/Feature/MailSenderTest.php
- vendor/bin/pint --dirty --format agent
This commit is contained in:
Víctor Falcón 2026-04-14 11:26:01 +01:00 committed by GitHub
parent 1f9c0cf030
commit d0aab3d11b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 578 additions and 29 deletions

View File

@ -26,7 +26,9 @@ class ResendSyncLeadsCommand extends Command
return self::FAILURE;
}
$leads = UserLead::query()->get();
$leads = UserLead::query()
->whereNotNull('email_verified_at')
->get();
if ($leads->isEmpty()) {
$this->info('No user leads to sync.');

View File

@ -7,8 +7,10 @@ use App\Mail\WaitlistOvertaken;
use App\Mail\WaitlistReferralNotification;
use App\Mail\WaitlistWelcome;
use App\Models\UserLead;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
@ -33,13 +35,37 @@ class UserLeadController extends Controller
'locale' => $validated['locale'] ?? null,
]);
if ($referrer) {
$lead->sendEmailVerificationNotification();
return to_route('waitlist.check-email', $lead);
}
public function verify(UserLead $lead): RedirectResponse
{
if (! hash_equals((string) request()->query('hash'), sha1(Str::lower($lead->email)))) {
return to_route('waitlist.check-email', $lead)
->withErrors(['email' => __('The verification link is invalid.')]);
}
if ($lead->hasVerifiedEmail()) {
return to_route('waitlist.thank-you', $lead);
}
$lead->markEmailAsVerified();
$lead->assignWaitlistSpot();
/** @var UserLead|null $referrer */
$referrer = $lead->referredBy?->fresh();
if ($referrer && $referrer->hasVerifiedEmail() && $referrer->position !== null) {
$oldPosition = $referrer->position;
$newPosition = max(1, $oldPosition - 10);
$referrer->update(['position' => $newPosition]);
$overtaken = UserLead::whereBetween('position', [$newPosition, $oldPosition - 1])
$overtaken = UserLead::query()
->whereNotNull('email_verified_at')
->whereBetween('position', [$newPosition, $oldPosition - 1])
->where('id', '!=', $referrer->id)
->get();
@ -57,17 +83,34 @@ class UserLeadController extends Controller
}
Mail::to($lead->email)->send(
(new WaitlistWelcome($lead))->locale($lead->locale),
(new WaitlistWelcome($lead->fresh()))->locale($lead->locale),
);
event(new Verified($lead));
return to_route('waitlist.thank-you', $lead);
}
public function checkEmail(UserLead $lead): Response|RedirectResponse
{
if ($lead->hasVerifiedEmail()) {
return to_route('waitlist.thank-you', $lead);
}
return Inertia::render('waitlist/check-email', [
'email' => $lead->email,
]);
}
/**
* Show the waitlist thank you page.
*/
public function thankYou(UserLead $lead): Response
public function thankYou(UserLead $lead): Response|RedirectResponse
{
if (! $lead->hasVerifiedEmail()) {
return to_route('waitlist.check-email', $lead);
}
return Inertia::render('waitlist/thank-you', [
'position' => $lead->position,
'referralUrl' => $lead->referral_url,

View File

@ -2,31 +2,48 @@
namespace App\Models;
use App\Notifications\VerifyUserLeadEmailNotification;
use Database\Factories\UserLeadFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
class UserLead extends Model
class UserLead extends Model implements HasLocalePreference, MustVerifyEmail
{
/** @use HasFactory<UserLeadFactory> */
use HasFactory, HasUuids;
use HasFactory, HasUuids, Notifiable;
protected $fillable = [
'email',
'email_verified_at',
'position',
'referral_code',
'referred_by_id',
'locale',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (UserLead $lead): void {
if (empty($lead->referral_code)) {
if ($lead->email_verified_at !== null && empty($lead->referral_code)) {
do {
$code = strtoupper(Str::random(8));
} while (static::where('referral_code', $code)->exists());
@ -34,7 +51,7 @@ class UserLead extends Model
$lead->referral_code = $code;
}
if (empty($lead->position)) {
if ($lead->email_verified_at !== null && empty($lead->position)) {
$maxPosition = static::max('position') ?? 499;
$lead->position = (int) $maxPosition + 1;
}
@ -70,4 +87,66 @@ class UserLead extends Model
{
return url('/').'?ref='.$this->referral_code;
}
public function hasVerifiedEmail(): bool
{
return $this->email_verified_at !== null;
}
public function markEmailAsVerified(): bool
{
return $this->forceFill([
'email_verified_at' => $this->freshTimestamp(),
])->save();
}
public function markEmailAsUnverified(): bool
{
return $this->forceFill([
'email_verified_at' => null,
'position' => null,
'referral_code' => null,
])->save();
}
public function preferredLocale(): string
{
return $this->locale ?? 'en';
}
public function getEmailForVerification(): string
{
return $this->email;
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyUserLeadEmailNotification($this->verification_url));
}
public function getVerificationUrlAttribute(): string
{
return url()->temporarySignedRoute(
'user-leads.verify',
now()->addDay(),
['lead' => $this->id, 'hash' => sha1(Str::lower($this->email))],
);
}
public function assignWaitlistSpot(): bool
{
if ($this->position === null) {
$this->position = (int) (static::query()->max('position') ?? 499) + 1;
}
if (empty($this->referral_code)) {
do {
$code = strtoupper(Str::random(8));
} while (static::where('referral_code', $code)->exists());
$this->referral_code = $code;
}
return $this->save();
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class VerifyUserLeadEmailNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly string $verificationUrl)
{
$this->onQueue('emails');
}
/**
* Get the notification's delivery channels.
*
* @return list<string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject(__('Confirm your waitlist spot - Whisper Money'))
->markdown('mail.verify-user-lead-email', [
'verificationUrl' => $this->verificationUrl,
]);
}
}

View File

@ -23,9 +23,19 @@ class UserLeadFactory extends Factory
return [
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'position' => $position,
'referral_code' => strtoupper(Str::random(8)),
'locale' => 'en',
];
}
public function unverified(): static
{
return $this->state(fn (): array => [
'email_verified_at' => null,
'position' => null,
'referral_code' => null,
]);
}
}

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_leads', function (Blueprint $table) {
$table->timestamp('email_verified_at')->nullable()->after('email');
$table->unsignedInteger('position')->nullable()->change();
$table->string('referral_code', 12)->nullable()->change();
});
DB::table('user_leads')
->whereNull('email_verified_at')
->update([
'email_verified_at' => DB::raw('COALESCE(created_at, NOW())'),
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_leads', function (Blueprint $table) {
$table->string('referral_code', 12)->nullable(false)->change();
$table->unsignedInteger('position')->nullable(false)->change();
$table->dropColumn('email_verified_at');
});
}
};

View File

@ -203,6 +203,7 @@
"Automation rules to categorize transactions automatically.": "Reglas de automatizaci\u00f3n para categorizar transacciones autom\u00e1ticamente.",
"Available:": "Disponible:",
"Back": "Atr\u00e1s",
"Back to home": "Volver al inicio",
"Back to Transactions": "Volver a Transacciones",
"Back to accounts": "Volver a las cuentas",
"Balance": "Balance",
@ -317,6 +318,9 @@
"Conditions": "Condiciones",
"Conditions joined by:": "Condiciones unidas por:",
"Confirm": "Confirmar",
"Confirm your email - Whisper Money": "Confirma tu correo electr\u00f3nico - Whisper Money",
"Confirm your email address to reserve your Whisper Money waitlist spot.": "Confirma tu direcci\u00f3n de correo electr\u00f3nico para reservar tu puesto en la lista de espera de Whisper Money.",
"Confirm your email to join the list": "Confirma tu correo electr\u00f3nico para unirte a la lista",
"Confirm Password": "Confirmar Contrase\u00f1a",
"Confirm password": "Confirmar contrase\u00f1a",
"Confirm your encryption password": "Confirma tu contrase\u00f1a de encriptaci\u00f3n",
@ -447,7 +451,12 @@
"Drop your file here, or click to browse": "Arrastra tu archivo aqu\u00ed, o haz clic para explorar",
"Duplicate": "Duplicado",
"Duplicates": "Duplicados",
"Each confirmed referral moves you 10 spots forward.": "Cada referido confirmado te adelanta 10 puestos.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada c\u00f3digo de recuperaci\u00f3n se puede usar una vez para acceder a tu cuenta y ser\u00e1 eliminado despu\u00e9s de usarse. Si necesitas m\u00e1s, haz clic",
"We sent a confirmation link to :email. Click it to reserve your waitlist spot and unlock your referral link.": "Te enviamos un enlace de confirmaci\u00f3n a :email. Haz clic para reservar tu puesto en la lista de espera y desbloquear tu enlace de referidos.",
"What happens after you confirm?": "\u00bfQu\u00e9 pasa despu\u00e9s de confirmar?",
"You get your personal referral link.": "Obtienes tu enlace personal de referidos.",
"Your place in the queue is reserved.": "Tu lugar en la cola queda reservado.",
"Each recovery code can be used once to\\n access your account and will be removed\\n after use. If you need more, click": "Cada c\u00f3digo de recuperaci\u00f3n se puede usar una vez para acceder a tu cuenta y se eliminar\u00e1 despu\u00e9s de su uso. Si necesitas m\u00e1s, haz clic",
"Edit": "Editar",
"Edit Account": "Editar Cuenta",

View File

@ -0,0 +1,72 @@
import { Button } from '@/components/ui/button';
import { __ } from '@/utils/i18n';
import { Head, Link, usePage } from '@inertiajs/react';
import { MailCheckIcon } from 'lucide-react';
export default function CheckEmail({ email }: { email: string }) {
const errors = usePage<{ errors: { email?: string } }>().props.errors ?? {};
return (
<>
<Head title={__('Confirm your email - Whisper Money')}>
<meta
name="description"
content={__(
'Confirm your email address to reserve your Whisper Money waitlist spot.',
)}
/>
</Head>
<div className="flex min-h-screen flex-col bg-[#FDFDFC] text-[#1b1b18] dark:bg-[#0a0a0a] dark:text-[#EDEDEC]">
<main className="flex flex-1 items-center justify-center px-6 py-32">
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-8 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/60">
<MailCheckIcon className="size-8 text-blue-600 dark:text-blue-300" />
</div>
<div className="flex flex-col gap-3">
<h1 className="font-heading text-3xl font-semibold sm:text-4xl">
{__('Confirm your email to join the list')}
</h1>
<p className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
{__(
'We sent a confirmation link to :email. Click it to reserve your waitlist spot and unlock your referral link.',
{ email },
)}
</p>
</div>
{errors.email && (
<div className="w-full rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700 dark:border-red-950 dark:bg-red-950/30 dark:text-red-200">
{errors.email}
</div>
)}
<div className="w-full rounded-3xl border border-[#e3e3e0] bg-[#f8f8f7] px-6 py-6 text-left dark:border-[#3E3E3A] dark:bg-[#161615]">
<p className="text-sm font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
{__('What happens after you confirm?')}
</p>
<div className="mt-4 grid gap-3 text-sm text-[#706f6c] dark:text-[#A1A09A]">
<p>
{__('Your place in the queue is reserved.')}
</p>
<p>
{__('You get your personal referral link.')}
</p>
<p>
{__(
'Each confirmed referral moves you 10 spots forward.',
)}
</p>
</div>
</div>
<Button asChild size="lg" variant="secondary">
<Link href="/">{__('Back to home')}</Link>
</Button>
</div>
</main>
</div>
</>
);
}

View File

@ -0,0 +1,21 @@
<x-mail::message>
# {{ __('Confirm your waitlist spot') }}
{{ __('Thanks for joining the Whisper Money waiting list.') }}
{{ __('Please confirm your email address so we can reserve your place in line and unlock your referral link.') }}
<x-mail::button :url="$verificationUrl">
{{ __('Confirm my email') }}
</x-mail::button>
{{ __('If you did not request this, you can safely ignore this email.') }}
{{ __('Best,') }}<br>
{{ __('Álvaro & Víctor') }}<br>
{{ __('Founders of Whisper Money') }}
<x-mail::subcopy>
{{ __('If you\'re having trouble clicking the "Confirm my email" button, copy and paste the URL below into your web browser:') }} <span class="break-all">[{{ $verificationUrl }}]({{ $verificationUrl }})</span>
</x-mail::subcopy>
</x-mail::message>

View File

@ -67,6 +67,10 @@ Route::get('sitemap.xml', [SitemapController::class, 'index'])->name('sitemap');
Route::get('robots.txt', [RobotsController::class, 'index'])->name('robots');
Route::post('user-leads', [UserLeadController::class, 'store'])->name('user-leads.store');
Route::get('waitlist/check-email/{lead}', [UserLeadController::class, 'checkEmail'])->name('waitlist.check-email');
Route::get('user-leads/{lead}/verify', [UserLeadController::class, 'verify'])
->middleware('signed')
->name('user-leads.verify');
Route::get('waitlist/thank-you/{lead}', [UserLeadController::class, 'thankYou'])->name('waitlist.thank-you');
Route::get('privacy', function () {

View File

@ -17,6 +17,7 @@ use App\Models\BankingConnection;
use App\Models\User;
use App\Models\UserLead;
use App\Notifications\VerifyEmailNotification;
use App\Notifications\VerifyUserLeadEmailNotification;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailer;
use Illuminate\Mail\Transport\ArrayTransport;
@ -148,9 +149,21 @@ test('verification notification uses the default sender', function () {
->and($from->getName())->toBe('Whisper Money');
});
test('user lead verification notification uses the default sender', function () {
$lead = UserLead::factory()->unverified()->create();
$lead->notify(new VerifyUserLeadEmailNotification('https://example.com/verify'));
$from = lastSentMailMessage()->getOriginalMessage()->getFrom()[0];
expect($from->getAddress())->toBe('no-reply@whisper.money')
->and($from->getName())->toBe('Whisper Money');
});
test('mail blade signatures use alvaro before victor', function () {
$mailViews = [
'mail/verify-email.blade.php',
'mail/verify-user-lead-email.blade.php',
'mail/waitlist-welcome.blade.php',
'mail/waitlist-referral-notification.blade.php',
'mail/user-lead-invitation.blade.php',

View File

@ -26,6 +26,24 @@ test('resend:sync-leads syncs all user leads to resend', function () {
->assertSuccessful();
});
test('resend:sync-leads only syncs verified user leads to resend', function () {
config([
'services.resend.key' => 'test-api-key',
'services.resend.leads_segment_id' => TEST_RESEND_LEADS_SEGMENT_ID,
]);
UserLead::factory()->count(2)->create();
UserLead::factory()->unverified()->count(2)->create();
$resendService = mock(ResendService::class);
$resendService->shouldReceive('syncLead')->times(2);
artisan('resend:sync-leads')
->expectsOutputToContain('Syncing 2 user leads to Resend...')
->expectsOutputToContain('Synced 2 user leads to Resend.')
->assertSuccessful();
});
test('resend:sync-leads fails when api key is not configured', function () {
config([
'services.resend.key' => null,

View File

@ -4,37 +4,88 @@ use App\Mail\WaitlistOvertaken;
use App\Mail\WaitlistReferralNotification;
use App\Mail\WaitlistWelcome;
use App\Models\UserLead;
use App\Notifications\VerifyUserLeadEmailNotification;
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\URL;
beforeEach(function () {
Mail::fake();
Notification::fake();
});
test('user lead is created with position starting at 500', function () {
test('user lead is created as unverified and pending confirmation', function () {
$response = $this->post(route('user-leads.store'), [
'email' => 'first@example.com',
]);
$lead = UserLead::where('email', 'first@example.com')->first();
expect($lead)->not->toBeNull();
expect($lead->position)->toBe(500);
expect($lead->email_verified_at)->toBeNull();
expect($lead->position)->toBeNull();
expect($lead->referral_code)->toBeNull();
$response->assertRedirect(route('waitlist.check-email', $lead));
});
test('each subsequent lead gets a higher position', function () {
test('verification email is sent when a user lead is created', function () {
$this->post(route('user-leads.store'), ['email' => 'test@example.com']);
$lead = UserLead::where('email', 'test@example.com')->firstOrFail();
Notification::assertSentTo($lead, VerifyUserLeadEmailNotification::class);
Mail::assertNothingQueued();
});
test('verified lead gets a position starting at 500', function () {
$this->post(route('user-leads.store'), ['email' => 'first@example.com']);
$lead = UserLead::where('email', 'first@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
expect($lead->fresh()->position)->toBe(500);
});
test('each subsequent verified lead gets a higher position', function () {
UserLead::factory()->create(['position' => 500]);
$this->post(route('user-leads.store'), ['email' => 'second@example.com']);
$lead = UserLead::where('email', 'second@example.com')->first();
expect($lead->position)->toBe(501);
$lead = UserLead::where('email', 'second@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
expect($lead->fresh()->position)->toBe(501);
});
test('user lead gets a referral code on creation', function () {
test('user lead gets a referral code after verification', function () {
$this->post(route('user-leads.store'), ['email' => 'test@example.com']);
$lead = UserLead::where('email', 'test@example.com')->first();
expect($lead->referral_code)->not->toBeEmpty();
expect(strlen($lead->referral_code))->toBe(8);
$lead = UserLead::where('email', 'test@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
expect($lead->fresh()->referral_code)->not->toBeEmpty();
expect(strlen($lead->fresh()->referral_code))->toBe(8);
});
test('user lead referral url is correct', function () {
@ -43,24 +94,56 @@ test('user lead referral url is correct', function () {
expect($lead->referral_url)->toContain('?ref=TESTCODE');
});
test('user lead redirects to the thank you page', function () {
$response = $this->post(route('user-leads.store'), [
'email' => 'test@example.com',
]);
test('verified user lead redirects to the thank you page', function () {
$this->post(route('user-leads.store'), ['email' => 'test@example.com']);
$lead = UserLead::where('email', 'test@example.com')->first();
$response->assertRedirect(route('waitlist.thank-you', $lead));
$lead = UserLead::where('email', 'test@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl)
->assertRedirect(route('waitlist.thank-you', $lead));
});
test('welcome email is sent when a user lead is created', function () {
test('welcome email is sent when a user lead verifies their email', function () {
$this->post(route('user-leads.store'), ['email' => 'test@example.com']);
$lead = UserLead::where('email', 'test@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
Mail::assertQueued(WaitlistWelcome::class, function (WaitlistWelcome $mail) {
return $mail->hasTo('test@example.com');
});
});
test('referrer moves forward 10 positions when someone uses their link', function () {
test('lead verification dispatches the verified event', function () {
$this->post(route('user-leads.store'), ['email' => 'verified-test@example.com']);
$lead = UserLead::where('email', 'verified-test@example.com')->firstOrFail();
Event::fake([Verified::class]);
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
Event::assertDispatched(Verified::class);
});
test('referrer moves forward 10 positions when a verified lead uses their link', function () {
$referrer = UserLead::factory()->create(['position' => 510]);
$this->post(route('user-leads.store'), [
@ -68,9 +151,29 @@ test('referrer moves forward 10 positions when someone uses their link', functio
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
expect($referrer->fresh()->position)->toBe(500);
});
test('unverified referred lead does not move referrer forward yet', function () {
$referrer = UserLead::factory()->create(['position' => 510]);
$this->post(route('user-leads.store'), [
'email' => 'new@example.com',
'referrer_code' => $referrer->referral_code,
]);
expect($referrer->fresh()->position)->toBe(510);
});
test('referrer position cannot go below 1', function () {
$referrer = UserLead::factory()->create(['position' => 5]);
@ -79,10 +182,19 @@ test('referrer position cannot go below 1', function () {
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
expect($referrer->fresh()->position)->toBe(1);
});
test('referral notification email is sent to the referrer', function () {
test('referral notification email is sent to the referrer after verification', function () {
$referrer = UserLead::factory()->create(['position' => 510]);
$this->post(route('user-leads.store'), [
@ -90,6 +202,15 @@ test('referral notification email is sent to the referrer', function () {
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
Mail::assertQueued(WaitlistReferralNotification::class, function (WaitlistReferralNotification $mail) use ($referrer) {
return $mail->hasTo($referrer->email);
});
@ -103,7 +224,7 @@ test('new lead is linked to the referrer', function () {
'referrer_code' => $referrer->referral_code,
]);
$newLead = UserLead::where('email', 'new@example.com')->first();
$newLead = UserLead::where('email', 'new@example.com')->firstOrFail();
expect($newLead->referred_by_id)->toBe($referrer->id);
});
@ -116,7 +237,7 @@ test('invalid referrer code is silently ignored', function () {
$lead = UserLead::where('email', 'test@example.com')->first();
expect($lead)->not->toBeNull();
expect($lead->referred_by_id)->toBeNull();
Mail::assertQueued(WaitlistWelcome::class);
Notification::assertSentTo($lead, VerifyUserLeadEmailNotification::class);
Mail::assertNotQueued(WaitlistReferralNotification::class);
});
@ -173,6 +294,33 @@ test('user lead defaults to app locale when no locale is submitted', function ()
expect($lead->locale)->toBe(app()->getLocale());
});
test('check email page shows the pending email address', function () {
$lead = UserLead::factory()->unverified()->create(['email' => 'pending@example.com']);
$response = $this->withoutVite()->get(route('waitlist.check-email', $lead));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('waitlist/check-email')
->where('email', 'pending@example.com')
);
});
test('verification link with invalid hash does not verify the lead', function () {
$lead = UserLead::factory()->unverified()->create(['email' => 'pending@example.com']);
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1('wrong-email')],
);
$this->get($verificationUrl)
->assertSessionHasErrors('email');
expect($lead->fresh()->hasVerifiedEmail())->toBeFalse();
});
test('overtaken email is sent using the overtaken lead locale', function () {
$referrer = UserLead::factory()->create(['position' => 520]);
$between = UserLead::factory()->create(['position' => 515, 'locale' => 'es']);
@ -182,6 +330,15 @@ test('overtaken email is sent using the overtaken lead locale', function () {
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
Mail::assertQueued(WaitlistOvertaken::class, function (WaitlistOvertaken $mail) use ($between) {
return $mail->hasTo($between->email) && $mail->locale === 'es';
});
@ -198,6 +355,15 @@ test('overtaken leads are pushed back one position when referrer jumps forward',
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
expect($between1->fresh()->position)->toBe(512);
expect($between2->fresh()->position)->toBe(516);
expect($outsideRange->fresh()->position)->toBe(525);
@ -212,6 +378,15 @@ test('overtaken leads receive the overtaken email', function () {
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
Mail::assertQueued(WaitlistOvertaken::class, function (WaitlistOvertaken $mail) use ($between) {
return $mail->hasTo($between->email);
});
@ -225,6 +400,15 @@ test('referrer does not receive the overtaken email', function () {
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
Mail::assertNotQueued(WaitlistOvertaken::class, function (WaitlistOvertaken $mail) use ($referrer) {
return $mail->hasTo($referrer->email);
});
@ -238,6 +422,15 @@ test('no overtaken emails when referrer is alone in their range', function () {
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
Mail::assertNotQueued(WaitlistOvertaken::class);
});
@ -251,6 +444,15 @@ test('clamped referrer only pushes back leads within the actual range', function
'referrer_code' => $referrer->referral_code,
]);
$lead = UserLead::where('email', 'new@example.com')->firstOrFail();
$verificationUrl = URL::temporarySignedRoute(
'user-leads.verify',
now()->addMinutes(60),
['lead' => $lead->id, 'hash' => sha1(strtolower($lead->email))],
);
$this->get($verificationUrl);
// Referrer clamps to 1, overtaken range is positions 14
expect($referrer->fresh()->position)->toBe(1);
expect($withinRange->fresh()->position)->toBe(4);