feat(auth): PATCH /api/auth/me profile update endpoint (closes J6)

Audit J6: ProfileTab needs a full-profile update endpoint. Adds
AuthController::updateProfile (first_name/last_name/phone/timezone),
routed in the existing /api/auth auth:sanctum group; mirrors the
sibling updateNotificationPreferences. userResource() now also returns
phone + timezone so the GET /me round-trip carries them.

phpstan-baseline.neon updated for Pest PendingCalls false positives
in the new test file (same pattern as all other Feature test files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 20:58:00 +03:00
parent a659390dd7
commit 8e06ada8fe
4 changed files with 139 additions and 0 deletions
@@ -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,
+30
View File
@@ -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
+1
View File
@@ -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');
});
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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']);
});