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:
parent
1f9c0cf030
commit
d0aab3d11b
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue