feat: locale
Laravel CI-CD / Tests Unitaires (push) Successful in 39s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m52s Details

This commit is contained in:
Leon Morival 2026-05-21 21:32:20 +02:00
parent 19e65e36f3
commit 8e5a50dd2b
21 changed files with 337 additions and 32 deletions

View File

@ -35,6 +35,11 @@ class UserForm
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('locale')
->label(__('admin.users.fields.locale'))
->options(self::localeOptions())
->default('en')
->required(),
FileUpload::make('avatar_url')
->label(__('admin.users.fields.avatar'))
->image()
@ -136,4 +141,18 @@ class UserForm
]),
]);
}
/**
* @return array<string, string>
*/
private static function localeOptions(): array
{
$labels = __('admin.users.locales');
return collect(config('app.supported_locales', ['fr', 'en']))
->mapWithKeys(fn (string $locale): array => [
$locale => is_array($labels) ? ($labels[$locale] ?? $locale) : $locale,
])
->all();
}
}

View File

@ -65,6 +65,10 @@ class AuthController extends Controller
], 401);
}
if (isset($data['locale']) && $user->locale !== $data['locale']) {
$user->forceFill(['locale' => $data['locale']])->save();
}
if (! $user->hasVerifiedEmail()) {
return response()->json([
'message' => __('api.auth.email_not_verified'),
@ -162,9 +166,13 @@ class AuthController extends Controller
public function showResetPasswordForm(Request $request, string $token): View
{
$email = (string) $request->query('email', '');
$locale = $this->localeForEmail($email);
return view('auth.reset-password', [
'appName' => config('app.name'),
'email' => (string) $request->query('email', ''),
'email' => $email,
'locale' => $locale,
'passwordReset' => false,
'token' => $token,
]);
@ -172,6 +180,9 @@ class AuthController extends Controller
public function resetPasswordFromView(ResetPasswordRequest $request): RedirectResponse|View
{
$locale = $this->localeForEmail((string) $request->validated('email'));
app()->setLocale($locale);
$status = $this->resetPasswordFor($request->safe());
if ($status !== Password::PASSWORD_RESET) {
@ -183,6 +194,7 @@ class AuthController extends Controller
return view('auth.reset-password', [
'appName' => config('app.name'),
'email' => strtolower($request->validated('email')),
'locale' => $locale,
'passwordReset' => true,
'token' => '',
]);
@ -298,6 +310,11 @@ class AuthController extends Controller
);
}
private function localeForEmail(string $email): string
{
return User::where('email', strtolower($email))->value('locale') ?: 'en';
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
@ -307,6 +324,7 @@ class AuthController extends Controller
$attributes = Arr::only($data, [
'name',
'avatar_url',
'locale',
'height',
'bio',
'sex',

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoginRequest extends FormRequest
{
@ -23,7 +24,24 @@ class LoginRequest extends FormRequest
{
return [
'email' => ['required', 'string'],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'password' => ['required', 'string'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('locale')) {
$this->merge([
'locale' => $this->normalizeLocale((string) $this->input('locale')),
]);
}
}
private function normalizeLocale(string $locale): string
{
$locale = strtolower(str_replace('_', '-', $locale));
return explode('-', $locale)[0];
}
}

View File

@ -37,6 +37,7 @@ class RegisterRequest extends FormRequest
'unique:users,name',
],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'password' => ['required', 'string', 'min:8'],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'bio' => ['nullable', 'string'],
@ -56,4 +57,20 @@ class RegisterRequest extends FormRequest
'avatar.max' => __('api.validation.image_max_2mb'),
];
}
protected function prepareForValidation(): void
{
if ($this->has('locale')) {
$this->merge([
'locale' => $this->normalizeLocale((string) $this->input('locale')),
]);
}
}
private function normalizeLocale(string $locale): string
{
$locale = strtolower(str_replace('_', '-', $locale));
return explode('-', $locale)[0];
}
}

View File

@ -21,6 +21,7 @@ class UpdateUserRequest extends FormRequest
return [
'bio' => ['sometimes', 'nullable', 'string'],
'height' => ['sometimes', 'nullable', 'integer'],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'nutritionGoals' => ['sometimes', 'array'],
'nutritionGoals.calories' => ['required_with:nutritionGoals', 'integer', 'min:0', 'max:100000'],
@ -46,4 +47,20 @@ class UpdateUserRequest extends FormRequest
'nutritionGoals.*.max' => __('api.validation.nutrition_goals_max'),
];
}
protected function prepareForValidation(): void
{
if ($this->has('locale')) {
$this->merge([
'locale' => $this->normalizeLocale((string) $this->input('locale')),
]);
}
}
private function normalizeLocale(string $locale): string
{
$locale = strtolower(str_replace('_', '-', $locale));
return explode('-', $locale)[0];
}
}

View File

@ -18,6 +18,7 @@ class UserResource extends JsonResource
return [
'name' => $this->name,
'email' => $this->email,
'locale' => $this->locale,
'emailVerified' => $this->hasVerifiedEmail(),
'emailVerifiedAt' => $this->email_verified_at?->toISOString(),
'height' => $this->height,

View File

@ -27,7 +27,7 @@ class ResetPasswordMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Réinitialise ton mot de passe Bowli',
subject: trans('mail.reset_password.subject', [], $this->mailLocale()),
);
}
@ -40,6 +40,7 @@ class ResetPasswordMail extends Mailable
view: 'emails.reset-password',
with: [
'expiresInMinutes' => (int) config('auth.passwords.users.expire', 60),
'locale' => $this->mailLocale(),
'resetUrl' => $this->resetUrl,
'userName' => $this->user->name,
],
@ -55,4 +56,9 @@ class ResetPasswordMail extends Mailable
{
return [];
}
private function mailLocale(): string
{
return $this->user->preferredLocale() ?? 'en';
}
}

View File

@ -27,7 +27,7 @@ class VerifyAccount extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Vérifie ton compte Daily Meal',
subject: trans('mail.verify_account.subject', [], $this->mailLocale()),
);
}
@ -40,6 +40,7 @@ class VerifyAccount extends Mailable
view: 'emails.verify-account',
with: [
'expiresInMinutes' => (int) config('auth.verification.expire', 60),
'locale' => $this->mailLocale(),
'userName' => $this->user->name,
'verificationUrl' => $this->verificationUrl,
],
@ -55,4 +56,9 @@ class VerifyAccount extends Mailable
{
return [];
}
private function mailLocale(): string
{
return $this->user->preferredLocale() ?? 'en';
}
}

View File

@ -9,6 +9,7 @@ use App\Enums\WeightGoal;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -18,7 +19,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser, MustVerifyEmail
class User extends Authenticatable implements FilamentUser, HasLocalePreference, MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasUlids, Notifiable;
@ -31,6 +32,7 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
protected $fillable = [
'name',
'email',
'locale',
'email_verified_at',
'password',
'avatar_url',
@ -115,6 +117,11 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
->all();
}
public function preferredLocale(): ?string
{
return $this->locale ?: 'en';
}
public function isAdmin()
{
return true;

View File

@ -26,6 +26,7 @@ class UserFactory extends Factory
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'locale' => 'en',
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'avatar_url' => null,

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('locale', 8)->default('en')->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('locale');
});
}
};

View File

@ -18,6 +18,7 @@ return [
'id' => 'ID',
'name' => 'Name',
'email' => 'Email address',
'locale' => 'Language',
'email_verified_at' => 'Email verified at',
'account_verified_at' => 'Account verified at',
'avatar' => 'Avatar',
@ -38,6 +39,10 @@ return [
'created_at' => 'Created at',
'updated_at' => 'Updated at',
],
'locales' => [
'en' => 'English',
'fr' => 'French',
],
'filters' => [
'email_verified' => 'Email verified',
'account_verified' => 'Account verified',

36
lang/en/mail.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'verify_account' => [
'subject' => 'Verify your Bowli account',
'title' => 'Verify your account',
'greeting' => 'Hi :name,',
'default_name' => 'there',
'intro' => 'Confirm your email address to activate your account and access Bowli.',
'action' => 'Verify my account',
'expires' => 'This link expires in :minutes minutes.',
'ignore' => 'If you did not create a Bowli account, you can ignore this email.',
],
'reset_password' => [
'subject' => 'Reset your Bowli password',
'title' => 'Reset your password',
'greeting' => 'Hi :name,',
'default_name' => 'there',
'intro' => 'Click the button below to create a new password. The link opens a secure Bowli API page.',
'action' => 'Choose a new password',
'expires' => 'This link expires in :minutes minutes.',
'copy_link' => 'If the button does not work, copy this link into your browser:',
'ignore' => 'If you did not request a password reset, you can ignore this email.',
],
'reset_password_form' => [
'page_title' => 'New password',
'success_title' => 'Password updated',
'title' => 'New password',
'intro' => 'Choose a new password to secure your account.',
'success' => 'Your password has been reset. You can return to the app and sign in.',
'email' => 'Email',
'password' => 'New password',
'password_confirmation' => 'Confirm password',
'submit' => 'Update password',
],
];

View File

@ -18,6 +18,7 @@ return [
'id' => 'ID',
'name' => 'Nom',
'email' => 'Adresse email',
'locale' => 'Langue',
'email_verified_at' => 'Email vérifié le',
'account_verified_at' => 'Compte vérifié le',
'avatar' => 'Avatar',
@ -38,6 +39,10 @@ return [
'created_at' => 'Créé le',
'updated_at' => 'Mis à jour le',
],
'locales' => [
'en' => 'Anglais',
'fr' => 'Français',
],
'filters' => [
'email_verified' => 'Email vérifié',
'account_verified' => 'Compte vérifié',

36
lang/fr/mail.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'verify_account' => [
'subject' => 'Vérifie ton compte Bowli',
'title' => 'Vérifie ton compte',
'greeting' => 'Bonjour :name,',
'default_name' => 'à toi',
'intro' => 'Confirme ton adresse email pour activer ton compte et accéder à Bowli.',
'action' => 'Vérifier mon compte',
'expires' => 'Ce lien expire dans :minutes minutes.',
'ignore' => 'Si tu nas pas créé de compte Bowli, tu peux ignorer cet email.',
],
'reset_password' => [
'subject' => 'Réinitialise ton mot de passe Bowli',
'title' => 'Réinitialise ton mot de passe',
'greeting' => 'Bonjour :name,',
'default_name' => 'à toi',
'intro' => 'Clique sur le bouton ci-dessous pour créer un nouveau mot de passe. Le lien ouvre une page sécurisée de lAPI Bowli.',
'action' => 'Choisir un nouveau mot de passe',
'expires' => 'Ce lien expire dans :minutes minutes.',
'copy_link' => 'Si le bouton ne fonctionne pas, copie ce lien dans ton navigateur :',
'ignore' => 'Si tu nas pas demandé de réinitialisation, tu peux ignorer cet email.',
],
'reset_password_form' => [
'page_title' => 'Nouveau mot de passe',
'success_title' => 'Mot de passe modifié',
'title' => 'Nouveau mot de passe',
'intro' => 'Choisis un nouveau mot de passe pour sécuriser ton compte.',
'success' => 'Ton mot de passe a bien été réinitialisé. Tu peux retourner dans lapplication et te connecter.',
'email' => 'Email',
'password' => 'Nouveau mot de passe',
'password_confirmation' => 'Confirmer le mot de passe',
'submit' => 'Modifier le mot de passe',
],
];

View File

@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="{{ $locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nouveau mot de passe</title>
<title>{{ trans('mail.reset_password_form.page_title', [], $locale) }}</title>
</head>
<body style="margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
<main style="width: min(100% - 32px, 520px); background: #ffffff; border-radius: 8px; padding: 32px; box-sizing: border-box;">
@ -12,16 +12,16 @@
</p>
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
{{ $passwordReset ? 'Mot de passe modifié' : 'Nouveau mot de passe' }}
{{ $passwordReset ? trans('mail.reset_password_form.success_title', [], $locale) : trans('mail.reset_password_form.title', [], $locale) }}
</h1>
@if ($passwordReset)
<p style="margin: 0; color: #232323; font-size: 16px; line-height: 24px;">
Ton mot de passe a bien été réinitialisé. Tu peux retourner dans lapplication et te connecter.
{{ trans('mail.reset_password_form.success', [], $locale) }}
</p>
@else
<p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;">
Choisis un nouveau mot de passe pour sécuriser ton compte.
{{ trans('mail.reset_password_form.intro', [], $locale) }}
</p>
@if ($errors->any())
@ -36,7 +36,7 @@
<input type="hidden" name="token" value="{{ $token }}">
<label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;">
Email
{{ trans('mail.reset_password_form.email', [], $locale) }}
<input
type="email"
name="email"
@ -48,7 +48,7 @@
</label>
<label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;">
Nouveau mot de passe
{{ trans('mail.reset_password_form.password', [], $locale) }}
<input
type="password"
name="password"
@ -60,7 +60,7 @@
</label>
<label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;">
Confirmer le mot de passe
{{ trans('mail.reset_password_form.password_confirmation', [], $locale) }}
<input
type="password"
name="password_confirmation"
@ -72,7 +72,7 @@
</label>
<button type="submit" style="min-height: 48px; border: 0; border-radius: 8px; background: #007f3a; color: #ffffff; font-size: 16px; font-weight: 700; cursor: pointer;">
Modifier le mot de passe
{{ trans('mail.reset_password_form.submit', [], $locale) }}
</button>
</form>
@endif

View File

@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="{{ $locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Réinitialise ton mot de passe Bowli</title>
<title>{{ trans('mail.reset_password.subject', [], $locale) }}</title>
</head>
<body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;">
@ -17,33 +17,33 @@
</p>
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
Réinitialise ton mot de passe
{{ trans('mail.reset_password.title', [], $locale) }}
</h1>
<p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;">
Bonjour {{ $userName ?: 'à toi' }},
{{ trans('mail.reset_password.greeting', ['name' => $userName ?: trans('mail.reset_password.default_name', [], $locale)], $locale) }}
</p>
<p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;">
Clique sur le bouton ci-dessous pour créer un nouveau mot de passe. Le lien ouvre une page sécurisée de lAPI Bowli.
{{ trans('mail.reset_password.intro', [], $locale) }}
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
<tr>
<td style="background: #007f3a; border-radius: 999px;">
<a href="{{ $resetUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
Choisir un nouveau mot de passe
{{ trans('mail.reset_password.action', [], $locale) }}
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
Ce lien expire dans {{ $expiresInMinutes }} minutes.
{{ trans('mail.reset_password.expires', ['minutes' => $expiresInMinutes], $locale) }}
</p>
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
Si le bouton ne fonctionne pas, copie ce lien dans ton navigateur :
{{ trans('mail.reset_password.copy_link', [], $locale) }}
</p>
<p style="margin: 0 0 16px; word-break: break-all; color: #007f3a; font-size: 14px; line-height: 22px;">
@ -51,7 +51,7 @@
</p>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
Si tu nas pas demandé de réinitialisation, tu peux ignorer cet email.
{{ trans('mail.reset_password.ignore', [], $locale) }}
</p>
</td>
</tr>

View File

@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="{{ $locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vérifie ton compte Daily Meal</title>
<title>{{ trans('mail.verify_account.subject', [], $locale) }}</title>
</head>
<body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;">
@ -13,37 +13,37 @@
<tr>
<td style="padding: 32px;">
<p style="margin: 0 0 8px; color: #426fb3; font-size: 14px; font-weight: 700; text-transform: uppercase;">
Daily Meal
Bowli
</p>
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
Vérifie ton compte
{{ trans('mail.verify_account.title', [], $locale) }}
</h1>
<p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;">
Bonjour {{ $userName ?: 'à toi' }},
{{ trans('mail.verify_account.greeting', ['name' => $userName ?: trans('mail.verify_account.default_name', [], $locale)], $locale) }}
</p>
<p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;">
Confirme ton adresse email pour activer ton compte et accéder à Daily Meal.
{{ trans('mail.verify_account.intro', [], $locale) }}
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
<tr>
<td style="background: #000000; border-radius: 999px;">
<a href="{{ $verificationUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
Vérifier mon compte
{{ trans('mail.verify_account.action', [], $locale) }}
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
Ce lien expire dans {{ $expiresInMinutes }} minutes.
{{ trans('mail.verify_account.expires', ['minutes' => $expiresInMinutes], $locale) }}
</p>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
Si tu nas pas créé de compte Daily Meal, tu peux ignorer cet email.
{{ trans('mail.verify_account.ignore', [], $locale) }}
</p>
</td>
</tr>

View File

@ -21,6 +21,7 @@ it('sends a verification email when a user registers', function () {
'name' => 'Leon',
'email' => 'leon@example.com',
'password' => 'password',
'locale' => 'fr-FR',
'physicalActivityLevel' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value,
'weightGoal' => WeightGoal::MAINTAIN_WEIGHT->value,
'pacePreference' => PacePreference::NORMAL->value,
@ -29,6 +30,7 @@ it('sends a verification email when a user registers', function () {
])
->assertCreated()
->assertJsonPath('user.email', 'leon@example.com')
->assertJsonPath('user.locale', 'fr')
->assertJsonPath('user.emailVerified', false)
->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value)
->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
@ -42,6 +44,7 @@ it('sends a verification email when a user registers', function () {
$this->assertDatabaseHas('users', [
'id' => $user->id,
'locale' => 'fr',
'physical_activity_level' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value,
'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value,
'pace_preference' => PacePreference::NORMAL->value,
@ -54,7 +57,9 @@ it('sends a verification email when a user registers', function () {
});
it('uses the custom account verification mailable', function () {
$user = User::factory()->unverified()->create();
$user = User::factory()->unverified()->create([
'locale' => 'fr',
]);
$mailable = (new VerifyEmail)->toMail($user);
@ -65,6 +70,21 @@ it('uses the custom account verification mailable', function () {
->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();
@ -132,17 +152,22 @@ it('blocks login for unverified users', function () {
it('allows login for verified users', 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)
->assertCookie('token');
expect($user->fresh()->locale)->toBe('fr');
});
it('resends a verification email for an unverified user without authentication', function () {

View File

@ -22,6 +22,7 @@ it('sends a password reset email with a clickable api web url', function () {
$user = User::factory()->create([
'email' => 'leon@example.com',
'locale' => 'fr',
]);
$this
@ -58,6 +59,38 @@ it('sends a password reset email with a clickable api web url', function () {
);
});
it('uses the user locale for password reset emails', function () {
Notification::fake();
$user = User::factory()->create([
'email' => 'leon@example.com',
'locale' => 'en',
]);
$this
->postJson('/api/auth/forgot-password', [
'email' => $user->email,
])
->assertOk();
Notification::assertSentTo(
$user,
ResetPassword::class,
function (ResetPassword $notification) use ($user): bool {
$mail = $notification->toMail($user);
expect($mail)->toBeInstanceOf(ResetPasswordMail::class);
$mail
->assertSeeInHtml('Reset your password')
->assertSeeInHtml('Choose a new password')
->assertDontSeeInHtml('Réinitialise ton mot de passe');
return true;
}
);
});
it('does not reveal whether a reset email can be sent', function () {
Notification::fake();
@ -100,6 +133,7 @@ it('resets the password and invalidates existing api tokens', function () {
it('shows the api password reset form from the email link', function () {
$user = User::factory()->create([
'email' => 'leon@example.com',
'locale' => 'fr',
]);
$token = Password::broker()->createToken($user);
@ -114,9 +148,29 @@ it('shows the api password reset form from the email link', function () {
->assertSee($user->email);
});
it('shows the api password reset form in the user locale', function () {
$user = User::factory()->create([
'email' => 'leon@example.com',
'locale' => 'en',
]);
$token = Password::broker()->createToken($user);
$this
->get(route('password.reset', [
'email' => $user->email,
'token' => $token,
], absolute: false))
->assertOk()
->assertViewIs('auth.reset-password')
->assertSee('New password')
->assertSee('Update password')
->assertDontSee('Nouveau mot de passe');
});
it('resets the password from the api web form', function () {
$user = User::factory()->create([
'email' => 'leon@example.com',
'locale' => 'fr',
'password' => 'old-password',
]);
$token = Password::broker()->createToken($user);

View File

@ -22,6 +22,7 @@ it('returns nutrition goals with the authenticated user', function () {
$this->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('data.locale', 'en')
->assertJsonPath('data.nutritionGoals.calories', 2400)
->assertJsonPath('data.nutritionGoals.proteins', 140)
->assertJsonPath('data.nutritionGoals.carbs', 280)
@ -103,16 +104,19 @@ it('updates profile preferences for the authenticated user', function () {
'pacePreference' => PacePreference::NORMAL->value,
'sex' => UserSex::MAN->value,
'dateOfBirth' => '2003-04-24',
'locale' => 'fr-FR',
])
->assertOk()
->assertJsonPath('data.physicalActivityLevel', PhysicalActivityLevel::MODERATELY_ACTIVE->value)
->assertJsonPath('data.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
->assertJsonPath('data.pacePreference', PacePreference::NORMAL->value)
->assertJsonPath('data.sex', UserSex::MAN->value)
->assertJsonPath('data.locale', 'fr')
->assertJsonPath('data.dateOfBirth', '2003-04-24');
$this->assertDatabaseHas('users', [
'id' => $user->id,
'locale' => 'fr',
'physical_activity_level' => PhysicalActivityLevel::MODERATELY_ACTIVE->value,
'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value,
'pace_preference' => PacePreference::NORMAL->value,
@ -130,6 +134,7 @@ it('validates profile preferences', function () {
'weightGoal' => 'bulk',
'pacePreference' => 'urgent',
'sex' => 'x',
'locale' => 'es',
'dateOfBirth' => 'tomorrow',
])
->assertUnprocessable()
@ -138,6 +143,7 @@ it('validates profile preferences', function () {
'weightGoal',
'pacePreference',
'sex',
'locale',
'dateOfBirth',
]);
});