From 5c436fd65bfa738832ccd1916eb6ed562d76b0cc Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Mon, 18 May 2026 12:41:02 +0200 Subject: [PATCH] feat: delete account --- app/Http/Controllers/AuthController.php | 41 +++++++++++++ routes/api.php | 1 + tests/Feature/AccountDeletionTest.php | 81 +++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 tests/Feature/AccountDeletionTest.php diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 0045e8e..f81b344 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -8,9 +8,12 @@ use App\Http\Requests\UpdateUserRequest; use App\Http\Resources\UserResource; use App\Models\User; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class AuthController extends Controller { @@ -108,4 +111,42 @@ class AuthController extends Controller return new UserResource($user->fresh()); } + + public function destroy(Request $request): JsonResponse + { + $user = $request->user(); + $storagePaths = collect([$user->avatar_url]) + ->merge($user->mealPosts()->withTrashed()->pluck('image_url')) + ->filter(fn (?string $path): bool => $this->isPublicStoragePath($path)) + ->values() + ->all(); + + DB::transaction(function () use ($user): void { + $user->tokens()->delete(); + $user->notifications()->delete(); + + DB::table('sessions') + ->where('user_id', $user->getKey()) + ->delete(); + + DB::table('password_reset_tokens') + ->where('email', $user->email) + ->delete(); + + $user->delete(); + }); + + if (! empty($storagePaths)) { + Storage::disk('public')->delete($storagePaths); + } + + return response()->json([ + 'message' => 'Compte supprimé avec succès', + ])->withoutCookie('token'); + } + + private function isPublicStoragePath(?string $path): bool + { + return filled($path) && ! Str::startsWith($path, ['http://', 'https://']); + } } diff --git a/routes/api.php b/routes/api.php index 5304475..e961857 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,7 @@ Route::prefix('auth')->group(function (): void { Route::post('logout', [AuthController::class, 'logout']); Route::get('me', [AuthController::class, 'me']); Route::patch('me', [AuthController::class, 'update']); + Route::delete('me', [AuthController::class, 'destroy']); }); }); diff --git a/tests/Feature/AccountDeletionTest.php b/tests/Feature/AccountDeletionTest.php new file mode 100644 index 0000000..9a9dd51 --- /dev/null +++ b/tests/Feature/AccountDeletionTest.php @@ -0,0 +1,81 @@ +create([ + 'avatar_url' => 'avatars/profile.jpg', + ]); + + Storage::disk('public')->put('avatars/profile.jpg', 'avatar'); + Storage::disk('public')->put('meal-posts/local.jpg', 'meal'); + + $localMealPost = MealPosts::factory()->for($user, 'user')->create([ + 'image_url' => 'meal-posts/local.jpg', + ]); + $remoteMealPost = MealPosts::factory()->for($user, 'user')->create([ + 'image_url' => 'https://example.com/meal.jpg', + ]); + $deviceToken = $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[account-deletion]', + 'platform' => 'ios', + ]); + + $user->createToken('api'); + + DB::table('notifications')->insert([ + 'id' => (string) Str::uuid(), + 'type' => 'test', + 'notifiable_type' => User::class, + 'notifiable_id' => $user->getKey(), + 'data' => json_encode(['message' => 'test'], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('sessions')->insert([ + 'id' => 'account-deletion-session', + 'user_id' => $user->getKey(), + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Pest', + 'payload' => 'payload', + 'last_activity' => now()->timestamp, + ]); + DB::table('password_reset_tokens')->insert([ + 'email' => $user->email, + 'token' => 'reset-token', + 'created_at' => now(), + ]); + + Sanctum::actingAs($user); + + $this->deleteJson('/api/auth/me') + ->assertOk() + ->assertJsonPath('message', 'Compte supprimé avec succès'); + + $this->assertDatabaseMissing('users', ['id' => $user->getKey()]); + $this->assertDatabaseMissing('meal_posts', ['id' => $localMealPost->getKey()]); + $this->assertDatabaseMissing('meal_posts', ['id' => $remoteMealPost->getKey()]); + $this->assertDatabaseMissing('device_tokens', ['id' => $deviceToken->getKey()]); + $this->assertDatabaseMissing('personal_access_tokens', ['tokenable_id' => $user->getKey()]); + $this->assertDatabaseMissing('notifications', ['notifiable_id' => $user->getKey()]); + $this->assertDatabaseMissing('sessions', ['user_id' => $user->getKey()]); + $this->assertDatabaseMissing('password_reset_tokens', ['email' => $user->email]); + + Storage::disk('public')->assertMissing('avatars/profile.jpg'); + Storage::disk('public')->assertMissing('meal-posts/local.jpg'); +}); + +it('requires authentication to delete an account', function () { + $this->deleteJson('/api/auth/me') + ->assertUnauthorized(); +});