Skip to content

AdminUserController refactoring & testing - 11/05/2026

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)”

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
  • 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
  • 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_verified est mappé depuis email_verified_at du model User (timestamp si verifié, null sinon)
  • 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)
  • 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.”
  • 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