diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index 84dade4b..c91bb13e 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -228,6 +228,31 @@ class AuthController extends Controller ]); } + /** + * PATCH /api/auth/me — обновление профиля текущего пользователя + * (имя, фамилия, телефон, тайм-зона). Email менять нельзя (через support). + * + * Audit J6/D1 (ProfileTab). Зеркалит updateNotificationPreferences: + * та же группа auth:sanctum, тот же inline-validate, тот же userResource. + */ + public function updateProfile(Request $request): JsonResponse + { + $validated = $request->validate([ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'phone' => ['nullable', 'string', 'max:20'], + 'timezone' => ['required', 'timezone'], + ]); + + /** @var User $user */ + $user = $request->user(); + $user->update($validated); + + return response()->json([ + 'user' => $this->userResource($user->fresh()), + ]); + } + /** * Ключ throttle для login: email|ip — защищает email от брутфорса даже * за NAT'ом, и IP от перебора емейлов с одного источника. @@ -333,6 +358,8 @@ class AuthController extends Controller 'email' => $user->email, 'first_name' => $user->first_name, 'last_name' => $user->last_name, + 'phone' => $user->phone, + 'timezone' => $user->timezone, 'tenant_id' => $user->tenant_id, 'totp_enabled' => $user->totp_enabled, 'last_login_at' => $user->last_login_at, diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index c5282685..8ffb0df5 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -462,6 +462,36 @@ parameters: count: 11 path: tests/Feature/Auth/TwoFactorTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Auth/UpdateProfileTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 4 + path: tests/Feature/Auth/UpdateProfileTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Auth/UpdateProfileTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Auth/UpdateProfileTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/Auth/UpdateProfileTest.php + - message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#' identifier: property.notFound diff --git a/app/routes/web.php b/app/routes/web.php index cde54378..358353ab 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -35,6 +35,7 @@ Route::prefix('/api/auth')->group(function () { Route::post('/reset-password', 'App\Http\Controllers\Api\PasswordResetController@resetPassword'); Route::middleware('auth:sanctum')->group(function () { Route::get('/me', 'App\Http\Controllers\Api\AuthController@me'); + Route::patch('/me', 'App\Http\Controllers\Api\AuthController@updateProfile'); Route::post('/logout', 'App\Http\Controllers\Api\AuthController@logout'); Route::patch('/me/notification-preferences', 'App\Http\Controllers\Api\AuthController@updateNotificationPreferences'); }); diff --git a/app/tests/Feature/Auth/UpdateProfileTest.php b/app/tests/Feature/Auth/UpdateProfileTest.php new file mode 100644 index 00000000..922ed297 --- /dev/null +++ b/app/tests/Feature/Auth/UpdateProfileTest.php @@ -0,0 +1,81 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'first_name' => 'Новый', + 'last_name' => 'Пользователь', + 'phone' => null, + 'timezone' => 'Europe/Moscow', + ]); + $this->actingAs($this->user); +}); + +test('PATCH /api/auth/me обновляет профиль и возвращает user', function () { + $response = $this->patchJson('/api/auth/me', [ + 'first_name' => 'Иван', + 'last_name' => 'Петров', + 'phone' => '+7 916 000-00-00', + 'timezone' => 'Asia/Yekaterinburg', + ]); + + $response->assertOk(); + expect($response->json('user.first_name'))->toBe('Иван'); + expect($response->json('user.last_name'))->toBe('Петров'); + expect($response->json('user.phone'))->toBe('+7 916 000-00-00'); + expect($response->json('user.timezone'))->toBe('Asia/Yekaterinburg'); + + $this->user->refresh(); + expect($this->user->first_name)->toBe('Иван'); + expect($this->user->timezone)->toBe('Asia/Yekaterinburg'); +}); + +test('PATCH /api/auth/me без auth: 401', function () { + auth()->logout(); + $this->patchJson('/api/auth/me', [ + 'first_name' => 'Иван', + 'last_name' => 'Петров', + 'timezone' => 'Europe/Moscow', + ])->assertStatus(401); +}); + +test('PATCH /api/auth/me: 422 при пустом first_name', function () { + $this->patchJson('/api/auth/me', [ + 'first_name' => '', + 'last_name' => 'Петров', + 'timezone' => 'Europe/Moscow', + ])->assertStatus(422)->assertJsonValidationErrorFor('first_name'); +}); + +test('PATCH /api/auth/me: 422 при невалидной timezone', function () { + $this->patchJson('/api/auth/me', [ + 'first_name' => 'Иван', + 'last_name' => 'Петров', + 'timezone' => 'Mars/Olympus', + ])->assertStatus(422)->assertJsonValidationErrorFor('timezone'); +}); + +test('PATCH /api/auth/me: phone опционален (nullable)', function () { + $response = $this->patchJson('/api/auth/me', [ + 'first_name' => 'Иван', + 'last_name' => 'Петров', + 'timezone' => 'Europe/Moscow', + ]); + $response->assertOk(); + expect($response->json('user.phone'))->toBeNull(); +}); + +test('GET /api/auth/me возвращает phone и timezone', function () { + $response = $this->getJson('/api/auth/me'); + $response->assertOk(); + expect($response->json('user'))->toHaveKeys(['phone', 'timezone']); +});