postJson('/api/auth/register', [ 'name' => 'Leon', 'email' => 'leon@example.com', 'password' => 'Motsdfdepasse123*', 'locale' => 'fr-FR', 'termsAccepted' => true, ]) ->assertCreated() ->assertJsonPath('user.email', 'leon@example.com') ->assertJsonPath('user.locale', 'fr') ->assertJsonPath('user.emailVerified', false) ->assertJsonPath('user.analysisAccessLevel', 'free') ->assertJsonPath('user.canAnalyzeMeals', false) ->assertJsonPath('user.onboardingStatus', 'required') ->assertJsonPath('user.nutritionGoals', null) ->assertCookieMissing('token'); $user = User::where('email', 'leon@example.com')->firstOrFail(); expect($user->hasVerifiedEmail())->toBeFalse() ->and($user->subscription_expires_at)->toBeNull() ->and($user->terms_accepted_at)->not->toBeNull(); $this->assertDatabaseHas('users', [ 'id' => $user->id, 'locale' => 'fr', 'physical_activity_level' => null, 'weight_goal' => null, 'pace_preference' => null, 'sex' => null, ]); expect($user->date_of_birth)->toBeNull(); Notification::assertSentTo($user, VerifyEmail::class); }); it('returns a temporary error when the verification email cannot be sent during registration', function () { Notification::shouldReceive('send') ->once() ->andThrow(new RuntimeException('Mail transport unavailable')); $this->postJson('/api/auth/register', [ 'name' => 'Leon', 'email' => 'leon@example.com', 'password' => 'Motsdfdepasse123*', 'locale' => 'fr-FR', 'termsAccepted' => true, ]) ->assertServiceUnavailable() ->assertJsonPath('code', 'VERIFICATION_EMAIL_FAILED'); $this->assertDatabaseMissing('users', [ 'email' => 'leon@example.com', ]); }); it('registers mobile users with a bearer token', function () { Notification::fake(); $this->postJson('/api/auth/mobile/register', [ 'name' => 'MobileLeon', 'email' => 'mobile.leon@example.com', 'password' => 'Motsdfdepasse123*', 'deviceName' => 'Bowli Android', 'locale' => 'fr-FR', 'termsAccepted' => true, ]) ->assertCreated() ->assertJsonPath('user.email', 'mobile.leon@example.com') ->assertJsonPath('user.emailVerified', false) ->assertJsonPath('user.onboardingStatus', 'required') ->assertJsonStructure(['token']) ->assertCookieMissing('token'); $user = User::where('email', 'mobile.leon@example.com')->firstOrFail(); $this->assertDatabaseHas('personal_access_tokens', [ 'name' => 'Bowli Android', 'tokenable_id' => $user->getKey(), ]); Notification::assertSentTo($user, VerifyEmail::class); }); it('requires users to accept the terms during registration', function () { Notification::fake(); $registrationData = [ 'name' => 'TermsLeon', 'email' => 'terms.leon@example.com', 'password' => 'Motsdfdepasse123*', 'termsAccepted' => false, ]; $this->postJson('/api/auth/mobile/register', $registrationData) ->assertUnprocessable() ->assertJsonValidationErrors('termsAccepted'); unset($registrationData['termsAccepted']); $this->postJson('/api/auth/mobile/register', $registrationData) ->assertUnprocessable() ->assertJsonValidationErrors('termsAccepted'); $this->assertDatabaseMissing('users', [ 'email' => 'terms.leon@example.com', ]); }); it('uses the custom account verification mailable', function () { $user = User::factory()->unverified()->create([ 'locale' => 'fr', ]); $mailable = (new VerifyEmail)->toMail($user); expect($mailable)->toBeInstanceOf(VerifyAccount::class); $mailable ->assertSeeInHtml('Vérifier mon compte') ->assertSeeInHtml($user->name); }); it('uses the user locale for account verification emails', function () { $user = User::factory()->unverified()->create([ 'locale' => 'en', ]); $mailable = (new VerifyEmail)->toMail($user); expect($mailable)->toBeInstanceOf(VerifyAccount::class); $mailable ->assertSeeInHtml('Verify my account') ->assertSeeInHtml('Confirm your email address') ->assertDontSeeInHtml('Vérifier mon compte'); }); it('generates verification links with a relative signature', function () { $user = User::factory()->unverified()->create(); $verificationUrl = (new VerifyEmail)->toMail($user)->verificationUrl; expect($verificationUrl)->toContain('/api/auth/email/verify/') ->and($verificationUrl)->toContain('signature='); $urlParts = parse_url($verificationUrl); $requestUrl = ($urlParts['path'] ?? '').'?'.($urlParts['query'] ?? ''); $this ->withServerVariables(['HTTP_HOST' => 'different-host.test']) ->getJson($requestUrl) ->assertOk() ->assertJsonPath('code', 'EMAIL_VERIFIED'); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); }); it('verifies a user from a signed email link', function () { $user = User::factory()->unverified()->create(); $url = URL::temporarySignedRoute('verification.verify', now()->addMinutes(60), [ 'id' => $user->getKey(), 'hash' => sha1($user->email), ], absolute: false); $this->getJson($url) ->assertOk() ->assertJsonPath('code', 'EMAIL_VERIFIED') ->assertJsonPath('user.emailVerified', true); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); }); it('rejects signed verification links with an invalid hash', function () { $user = User::factory()->unverified()->create(); $url = URL::temporarySignedRoute('verification.verify', now()->addMinutes(60), [ 'id' => $user->getKey(), 'hash' => sha1('wrong-address@example.com'), ], absolute: false); $this->getJson($url)->assertForbidden(); expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); }); it('blocks login for unverified users', function () { $user = User::factory()->unverified()->create([ 'password' => 'password', ]); $this->postJson('/api/auth/login', [ 'email' => $user->email, 'password' => 'password', ]) ->assertForbidden() ->assertJsonPath('code', 'EMAIL_NOT_VERIFIED') ->assertCookieMissing('token'); $this->assertDatabaseMissing('personal_access_tokens', [ 'tokenable_id' => $user->getKey(), ]); }); it('allows session login for verified users without issuing a token cookie', function () { $user = User::factory()->create([ 'locale' => 'en', 'password' => 'password', ]); $this->postJson('/api/auth/login', [ 'email' => $user->email, 'locale' => 'fr-FR', 'password' => 'password', ]) ->assertOk() ->assertJsonPath('user.email', $user->email) ->assertJsonPath('user.locale', 'fr') ->assertJsonPath('user.emailVerified', true) ->assertCookieMissing('token'); expect($user->fresh()->locale)->toBe('fr'); }); it('allows mobile login for verified users with a bearer token', function () { $user = User::factory()->create([ 'locale' => 'en', 'password' => 'password', ]); $this->postJson('/api/auth/mobile/login', [ 'deviceName' => 'Bowli iOS', 'email' => $user->email, 'locale' => 'fr-FR', 'password' => 'password', ]) ->assertOk() ->assertJsonPath('user.email', $user->email) ->assertJsonPath('user.locale', 'fr') ->assertJsonPath('user.emailVerified', true) ->assertJsonStructure(['token']) ->assertCookieMissing('token'); expect($user->fresh()->locale)->toBe('fr'); $this->assertDatabaseHas('personal_access_tokens', [ 'name' => 'Bowli iOS', 'tokenable_id' => $user->getKey(), ]); }); it('logs out mobile users by deleting the current bearer token', function () { $user = User::factory()->create(); $token = $user->createToken('Bowli iOS')->plainTextToken; $this ->withToken($token) ->postJson('/api/auth/logout') ->assertOk() ->assertJsonPath('code', 'LOGGED_OUT'); $this->assertDatabaseMissing('personal_access_tokens', [ 'name' => 'Bowli iOS', 'tokenable_id' => $user->getKey(), ]); }); it('resends a verification email for an unverified user without authentication', function () { Notification::fake(); $user = User::factory()->unverified()->create(); $this->postJson('/api/auth/email/verification-notification', [ 'email' => $user->email, ]) ->assertOk() ->assertJsonPath('code', 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED'); Notification::assertSentTo($user, VerifyEmail::class); }); it('does not reveal whether an email can receive a verification email', function () { Notification::fake(); $verifiedUser = User::factory()->create(); $this->postJson('/api/auth/email/verification-notification', [ 'email' => $verifiedUser->email, ]) ->assertOk() ->assertJsonPath('code', 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED'); $this->postJson('/api/auth/email/verification-notification', [ 'email' => 'missing@example.com', ]) ->assertOk() ->assertJsonPath('code', 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED'); Notification::assertNothingSent(); }); it('blocks verified routes for unverified users', function () { $user = User::factory()->unverified()->create(); Sanctum::actingAs($user); $this->getJson('/api/meal-posts')->assertForbidden(); });