AdminUserController refactoring & testing - 11/05/2026
1. Dead code et refactor
Section titled “1. Dead code et refactor”1.1 banUserAdmin (worker) — cascade unready incomplète
Section titled “1.1 banUserAdmin (worker) — cascade unready incomplète”Quand un worker est banni, la route force aujourd’hui uniquement WorkerProfile.ready_to_work = 0 en BDD. La cascade unready complète n’est pas appliquée : pas de cleanup Redis (removeWorkerOnline), pas de broadcast WorkerStatusChanged ni AdminInstantWorkerOffline. Conséquence : le worker banni reste visible dans les pools de matching côté Redis et son client mobile ne reçoit aucun event de déconnexion jusqu’à son prochain heartbeat.
À fixer après le refactor du UserController (qui centralisera la logique unready dans un service dédié — type WorkerProfileService::unready($user) ou équivalent). banUserAdmin devra alors déléguer à ce service plutôt que de faire un update BDD direct, pour bénéficier automatiquement de :
- la mise à jour BDD (ready_to_work + ready_to_work_updated_at)
- le retrait du worker des index Redis online
- les broadcasts WorkerStatusChanged (worker) et AdminInstantWorkerOffline (admin)
1.2 banUserAdmin (owner) — cascade missions manquante
Section titled “1.2 banUserAdmin (owner) — cascade missions manquante”Quand un owner est banni, la route ne fait actuellement que toggle is_banned. Ses missions actives (status open/matched/in_progress) restent vivantes : les workers continuent à voir/matcher dessus, les assignments en cours ne sont pas clos. Conséquence : un owner banni peut continuer à mobiliser le réseau worker sans être présent.
À fixer : banUserAdmin doit, pour un owner banni, itérer sur ses missions actives et appeler MissionService::cleanMissionBeforeDelete($mission) pour chacune (même pipeline que deleteAccount côté owner, cf UserControllerTest 2.8). Ça déclenche cancel propre + retrait des MissionAssignments + broadcasts + cleanup Redis pour chaque mission. Non implémenté actuellement.
2. Tests de chaque route — approche features-first (non-unit)
Section titled “2. Tests de chaque route — approche features-first (non-unit)”2.1 Tests cross-cutting
Section titled “2.1 Tests cross-cutting”Provider 1 — toutes les routes admin user
- utilisateur non authentifié -> 401
- utilisateur authentifié avec role !== admin -> 403
Provider 2 — routes avec user_id en input (toutes sauf getUsersAdmin)
- user_id manquant -> 422
- user_id non integer -> 422
- user_id valide en type mais aucun user en BDD pour cet id -> 422
2.2 getUsersAdmin
Section titled “2.2 getUsersAdmin”- couvert par Provider 1
- doit retourner un tableau d’users avec les champs communs : [id, profile_url, first_name, last_name, role, address, is_banned]
- si l’user listé est worker -> doit inclure en plus ready_to_work (depuis WorkerProfile)
- si l’user listé est owner -> doit inclure en plus business_name (depuis EnterpriseProfile.name)
- ne doit jamais retourner les champs privés (email, phone, token_balance, password, etc.)
- doit retourner l’agrégat User + données role-spécifiques en un seul payload
2.3 getUserDetailsAdmin
Section titled “2.3 getUserDetailsAdmin”- couvert par Provider 1 et Provider 2
- doit retourner les champs communs : [first_name, last_name, email, phone, role, email_verified, address, created_at, is_banned, profile_photo]
- si la cible est worker -> doit inclure en plus [latitude, longitude, ready_to_work] depuis WorkerProfile
- si la cible est owner -> doit inclure en plus ‘enterprises’ (tableau de toutes les EnterpriseProfile de l’owner)
- si la cible est admin -> ne contient ni bloc worker ni bloc enterprises (uniquement les champs communs)
- ne doit jamais retourner les champs internes (password, remember_token, google_id, apple_id, oauth tokens, etc.)
- le champ
email_verifiedest mappé depuisemail_verified_atdu model User (timestamp si verifié, null sinon)
2.4 updateUserDetailsAdmin
Section titled “2.4 updateUserDetailsAdmin”- couvert par Provider 1 et Provider 2
Validation des champs (en plus du user_id)
- first_name / last_name / address / business_name / manager_firstname / manager_lastname > 255 chars -> 422
- email avec format invalide -> 422
- email déjà utilisé par un AUTRE user -> 422
- email identique à l’email actuel du user ciblé -> OK (la règle unique exclut le user lui-même via
unique:users,email,{user_id}) - phone > 20 chars -> 422
- role hors de [admin, owner, worker] -> 422
- latitude / longitude non numeric -> 422
- enterprise_id qui n’existe pas en BDD -> 422
Update partiel des champs User
- chaque champ [first_name, last_name, email, phone, role, address] fourni -> doit être persisté en BDD (data provider sur les 6 champs)
- un champ NON fourni dans le payload -> doit rester à sa valeur d’origine (pas écrasé en null)
Gestion du flag email_verified (champ hors validator, parsé via FILTER_VALIDATE_BOOLEAN)
- email_verified = true sur user avec email_verified_at = null -> doit set email_verified_at = now()
- email_verified = true sur user avec email_verified_at déjà set -> email_verified_at inchangé (pas écrasé)
- email_verified = false -> doit set email_verified_at = null
Update EnterpriseProfile (uniquement si user.role = owner après save)
- si user est owner et business_name fourni -> doit set EnterpriseProfile.name avec la valeur
- si user est owner et [manager_firstname, manager_lastname, latitude, longitude, tension_flag] fournis -> doit les persister sur EnterpriseProfile
- si owner sans enterprise_id -> doit cibler la PREMIÈRE EnterpriseProfile de l’owner (legacy multi-enterprise, cf 1.2 du UserControllerTest)
- si owner avec enterprise_id valide et lui appartenant -> doit cibler cette EnterpriseProfile spécifique
- si owner avec enterprise_id ne lui appartenant pas -> ne doit modifier AUCUNE EnterpriseProfile (le
where('owner_id', $user->id)protège la mutation, mais 200 silencieux côté response) - si user.role = worker ou admin (même après update) et champs enterprise fournis -> doit être ignorés (le block enterprise ne s’exécute pas)
Effets observables
- doit retourner 200 avec le User fresh (champs mis à jour reflétés)
- ne doit jamais retourner les champs internes (password, remember_token, google_id, apple_id, oauth tokens)
2.5 deleteUserAdmin
Section titled “2.5 deleteUserAdmin”- couvert par Provider 1 et Provider 2
- contrairement à deleteAccount (soft delete 30j), cette route effectue un HARD delete immédiat via UserDeletionService::hardDelete -> le User disparait complètement de la BDD (pas de deleted_at), son historique est réassigné au placeholder
- si user cible.email == ‘deleted@neeko.app’ -> 403 (“can’t delete ghost”)
- si le placeholder deleted@neeko.app n’existe pas en BDD -> 500 (message “Placeholder user (deleted@neeko.app) not found. Migration required.”)
- happy path : doit appeler UserDeletionService::hardDelete($user, $placeholder) (mocké)
- si UserDeletionService::hardDelete jette une exception -> 500 + log d’erreur, le User cible reste intact en BDD
- succès -> 200 avec data:[] et message “User deleted successfully (Hard Delete). History has been anonymized.”
2.6 banUserAdmin
Section titled “2.6 banUserAdmin”- couvert par Provider 1 et Provider 2
- si user cible.email == ‘deleted@neeko.app’ -> 403 (“can’t ban ghost”)
- la route est un TOGGLE : inverse l’état actuel de is_banned
- target user.is_banned = false -> après appel : is_banned = true
- target user.is_banned = true -> après appel : is_banned = false
- si le user banni est worker -> doit déclencher la cascade unready complète (WorkerProfile.ready_to_work = 0 + retrait Redis online via RedisGeoService::removeWorkerOnline + broadcasts WorkerStatusChanged et AdminInstantWorkerOffline) via WorkerProfileService::unready ou équivalent (non implémenté actuellement, cf 1.1)
- si le user unbanni est worker -> ready_to_work conserve sa valeur précédente (PAS force-set à 1)
- si le user banni est owner -> doit annuler toutes ses missions actives (status open/matched/in_progress) via MissionService::cleanMissionBeforeDelete pour chacune, et retirer les MissionAssignments associés (non implémenté actuellement, cf 1.2)
- si le user banni est admin -> aucun side-effect spécifique
- succès -> 200 avec data.is_banned = nouvel état (bool) et message “User has been banned successfully.” si banned, “User has been unbanned successfully.” sinon