From e90078d39ecb4b496222514cfb473b5629c3c431 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Sat, 11 Apr 2026 13:02:23 +0100 Subject: [PATCH] feat: add updateProfile, updatePassword, deleteAccount API endpoints Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Controllers/Api/UserController.php | 58 +++++++++ routes/api.php | 4 + tests/Feature/Api/UserControllerTest.php | 133 ++++++++++++++++++++ 3 files changed, 195 insertions(+) diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 34259c3..ed07d52 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -3,10 +3,14 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\ValidationException; final class UserController extends Controller { @@ -59,4 +63,58 @@ final class UserController extends Controller return response()->noContent(); } + + public function updateProfile(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)->ignore($request->user()->id)], + ]); + + $user = $request->user(); + $user->fill($validated); + + if ($user->isDirty('email')) { + $user->email_verified_at = null; + } + + $user->save(); + + return response()->json($user->fresh()); + } + + public function updatePassword(Request $request): JsonResponse + { + $request->validate([ + 'current_password' => ['required', 'string'], + 'password' => ['required', 'string', Password::defaults(), 'confirmed'], + ]); + + if (! Hash::check($request->string('current_password'), $request->user()->password)) { + throw ValidationException::withMessages([ + 'current_password' => [__('The provided password does not match your current password.')], + ]); + } + + $request->user()->update(['password' => $request->string('password')]); + + return response()->json(['message' => 'Password updated.']); + } + + public function deleteAccount(Request $request): Response + { + $request->validate(['password' => ['required', 'string']]); + + if (! Hash::check($request->string('password'), $request->user()->password)) { + throw ValidationException::withMessages([ + 'password' => [__('The provided password does not match your current password.')], + ]); + } + + $user = $request->user(); + $user->tokens()->delete(); + $user->delete(); + + return response()->noContent(); + } } diff --git a/routes/api.php b/routes/api.php index 1bbd4b0..adaed35 100644 --- a/routes/api.php +++ b/routes/api.php @@ -30,4 +30,8 @@ Route::middleware('auth:sanctum')->group(function (): void { Route::get('/user/saved-stations', [UserController::class, 'savedStations']); Route::post('/user/saved-stations', [UserController::class, 'saveStation']); Route::delete('/user/saved-stations/{stationId}', [UserController::class, 'removeStation']); + + Route::put('/user/profile', [UserController::class, 'updateProfile']); + Route::put('/user/password', [UserController::class, 'updatePassword']); + Route::delete('/user', [UserController::class, 'deleteAccount']); }); diff --git a/tests/Feature/Api/UserControllerTest.php b/tests/Feature/Api/UserControllerTest.php index a8180ce..91a0dea 100644 --- a/tests/Feature/Api/UserControllerTest.php +++ b/tests/Feature/Api/UserControllerTest.php @@ -1,6 +1,7 @@ getJson('/api/user/preferences')->assertUnauthorized(); $this->getJson('/api/user/saved-stations')->assertUnauthorized(); }); + +// --- Profile update --- + +it('updates user profile name and email', function (): void { + $user = User::factory()->create(['name' => 'Old Name', 'email' => 'old@example.com']); + Sanctum::actingAs($user); + + $this->putJson('/api/user/profile', ['name' => 'New Name', 'email' => 'new@example.com']) + ->assertOk() + ->assertJsonFragment(['name' => 'New Name', 'email' => 'new@example.com']); + + expect($user->fresh()->name)->toBe('New Name'); + expect($user->fresh()->email)->toBe('new@example.com'); +}); + +it('nulls email_verified_at when email changes', function (): void { + $user = User::factory()->create(['email_verified_at' => now()]); + Sanctum::actingAs($user); + + $this->putJson('/api/user/profile', ['name' => $user->name, 'email' => 'changed@example.com']) + ->assertOk(); + + expect($user->fresh()->email_verified_at)->toBeNull(); +}); + +it('does not null email_verified_at when email is unchanged', function (): void { + $user = User::factory()->create(['email_verified_at' => now()]); + Sanctum::actingAs($user); + + $this->putJson('/api/user/profile', ['name' => 'New Name', 'email' => $user->email]) + ->assertOk(); + + expect($user->fresh()->email_verified_at)->not->toBeNull(); +}); + +it('rejects profile update with duplicate email', function (): void { + User::factory()->create(['email' => 'taken@example.com']); + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $this->putJson('/api/user/profile', ['name' => 'Name', 'email' => 'taken@example.com']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['email']); +}); + +it('rejects profile update with missing name', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $this->putJson('/api/user/profile', ['email' => 'new@example.com']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name']); +}); + +// --- Password update --- + +it('updates user password', function (): void { + $user = User::factory()->create(['password' => Hash::make('old-password')]); + Sanctum::actingAs($user); + + $this->putJson('/api/user/password', [ + 'current_password' => 'old-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ])->assertOk(); + + expect(Hash::check('new-password', $user->fresh()->password))->toBeTrue(); +}); + +it('rejects wrong current password', function (): void { + $user = User::factory()->create(['password' => Hash::make('correct-password')]); + Sanctum::actingAs($user); + + $this->putJson('/api/user/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ])->assertUnprocessable() + ->assertJsonValidationErrors(['current_password']); +}); + +it('rejects mismatched password confirmation', function (): void { + $user = User::factory()->create(['password' => Hash::make('correct-password')]); + Sanctum::actingAs($user); + + $this->putJson('/api/user/password', [ + 'current_password' => 'correct-password', + 'password' => 'new-password', + 'password_confirmation' => 'different', + ])->assertUnprocessable() + ->assertJsonValidationErrors(['password']); +}); + +// --- Delete account --- + +it('deletes user account with correct password', function (): void { + $user = User::factory()->create(['password' => Hash::make('my-password')]); + Sanctum::actingAs($user); + + $this->deleteJson('/api/user', ['password' => 'my-password']) + ->assertNoContent(); + + expect(User::find($user->id))->toBeNull(); +}); + +it('revokes sanctum tokens on account deletion', function (): void { + $user = User::factory()->create(['password' => Hash::make('my-password')]); + $user->createToken('test'); + Sanctum::actingAs($user); + + $this->deleteJson('/api/user', ['password' => 'my-password']) + ->assertNoContent(); + + expect($user->tokens()->count())->toBe(0); +}); + +it('rejects account deletion with wrong password', function (): void { + $user = User::factory()->create(['password' => Hash::make('correct-password')]); + Sanctum::actingAs($user); + + $this->deleteJson('/api/user', ['password' => 'wrong-password']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['password']); + + expect(User::find($user->id))->not->toBeNull(); +}); + +it('rejects unauthenticated requests to profile, password, and delete endpoints', function (): void { + $this->putJson('/api/user/profile', [])->assertUnauthorized(); + $this->putJson('/api/user/password', [])->assertUnauthorized(); + $this->deleteJson('/api/user', [])->assertUnauthorized(); +});