diff --git a/app/Console/Commands/ResendSyncLeadsCommand.php b/app/Console/Commands/ResendSyncLeadsCommand.php index 319cd2f9..f28daa78 100644 --- a/app/Console/Commands/ResendSyncLeadsCommand.php +++ b/app/Console/Commands/ResendSyncLeadsCommand.php @@ -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.'); diff --git a/app/Http/Controllers/UserLeadController.php b/app/Http/Controllers/UserLeadController.php index 79bc40df..6745b742 100644 --- a/app/Http/Controllers/UserLeadController.php +++ b/app/Http/Controllers/UserLeadController.php @@ -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, diff --git a/app/Models/UserLead.php b/app/Models/UserLead.php index 21dc1ee4..f76757da 100644 --- a/app/Models/UserLead.php +++ b/app/Models/UserLead.php @@ -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 */ - 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 + */ + 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(); + } } diff --git a/app/Notifications/VerifyUserLeadEmailNotification.php b/app/Notifications/VerifyUserLeadEmailNotification.php new file mode 100644 index 00000000..e46f247b --- /dev/null +++ b/app/Notifications/VerifyUserLeadEmailNotification.php @@ -0,0 +1,37 @@ +onQueue('emails'); + } + + /** + * Get the notification's delivery channels. + * + * @return list + */ + 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, + ]); + } +} diff --git a/database/factories/UserLeadFactory.php b/database/factories/UserLeadFactory.php index 9d67a471..b734f829 100644 --- a/database/factories/UserLeadFactory.php +++ b/database/factories/UserLeadFactory.php @@ -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, + ]); + } } diff --git a/database/migrations/2026_04_14_120000_add_email_verification_to_user_leads_table.php b/database/migrations/2026_04_14_120000_add_email_verification_to_user_leads_table.php new file mode 100644 index 00000000..29ce4551 --- /dev/null +++ b/database/migrations/2026_04_14_120000_add_email_verification_to_user_leads_table.php @@ -0,0 +1,39 @@ +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'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index f750c6dd..22590b7c 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/pages/waitlist/check-email.tsx b/resources/js/pages/waitlist/check-email.tsx new file mode 100644 index 00000000..133eabc7 --- /dev/null +++ b/resources/js/pages/waitlist/check-email.tsx @@ -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 ( + <> + + + + +
+
+
+
+ +
+ +
+

+ {__('Confirm your email to join the list')} +

+

+ {__( + 'We sent a confirmation link to :email. Click it to reserve your waitlist spot and unlock your referral link.', + { email }, + )} +

+
+ + {errors.email && ( +
+ {errors.email} +
+ )} + +
+

+ {__('What happens after you confirm?')} +

+
+

+ {__('Your place in the queue is reserved.')} +

+

+ {__('You get your personal referral link.')} +

+

+ {__( + 'Each confirmed referral moves you 10 spots forward.', + )} +

+
+
+ + +
+
+
+ + ); +} diff --git a/resources/views/mail/verify-user-lead-email.blade.php b/resources/views/mail/verify-user-lead-email.blade.php new file mode 100644 index 00000000..4cf3958c --- /dev/null +++ b/resources/views/mail/verify-user-lead-email.blade.php @@ -0,0 +1,21 @@ + +# {{ __('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.') }} + + +{{ __('Confirm my email') }} + + +{{ __('If you did not request this, you can safely ignore this email.') }} + +{{ __('Best,') }}
+{{ __('Álvaro & Víctor') }}
+{{ __('Founders of Whisper Money') }} + + +{{ __('If you\'re having trouble clicking the "Confirm my email" button, copy and paste the URL below into your web browser:') }} [{{ $verificationUrl }}]({{ $verificationUrl }}) + +
diff --git a/routes/web.php b/routes/web.php index efb36618..06984d9d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 () { diff --git a/tests/Feature/MailSenderTest.php b/tests/Feature/MailSenderTest.php index 57799b58..afc92a74 100644 --- a/tests/Feature/MailSenderTest.php +++ b/tests/Feature/MailSenderTest.php @@ -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', diff --git a/tests/Feature/ResendSyncLeadsCommandTest.php b/tests/Feature/ResendSyncLeadsCommandTest.php index 88fecf37..42e1aeca 100644 --- a/tests/Feature/ResendSyncLeadsCommandTest.php +++ b/tests/Feature/ResendSyncLeadsCommandTest.php @@ -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, diff --git a/tests/Feature/UserLeadTest.php b/tests/Feature/UserLeadTest.php index 63e6dd0b..1f923933 100644 --- a/tests/Feature/UserLeadTest.php +++ b/tests/Feature/UserLeadTest.php @@ -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 1–4 expect($referrer->fresh()->position)->toBe(1); expect($withinRange->fresh()->position)->toBe(4);