feat: add updateProfile, updatePassword, deleteAccount API endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
final class UserController extends Controller
|
final class UserController extends Controller
|
||||||
{
|
{
|
||||||
@@ -59,4 +63,58 @@ final class UserController extends Controller
|
|||||||
|
|
||||||
return response()->noContent();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,8 @@ Route::middleware('auth:sanctum')->group(function (): void {
|
|||||||
Route::get('/user/saved-stations', [UserController::class, 'savedStations']);
|
Route::get('/user/saved-stations', [UserController::class, 'savedStations']);
|
||||||
Route::post('/user/saved-stations', [UserController::class, 'saveStation']);
|
Route::post('/user/saved-stations', [UserController::class, 'saveStation']);
|
||||||
Route::delete('/user/saved-stations/{stationId}', [UserController::class, 'removeStation']);
|
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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
it('returns user preferences for authenticated user', function (): void {
|
it('returns user preferences for authenticated user', function (): void {
|
||||||
@@ -65,3 +66,135 @@ it('rejects unauthenticated requests to user endpoints', function (): void {
|
|||||||
$this->getJson('/api/user/preferences')->assertUnauthorized();
|
$this->getJson('/api/user/preferences')->assertUnauthorized();
|
||||||
$this->getJson('/api/user/saved-stations')->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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user