AdminMissionController refactoring & testing - 21/05/2026
1. Dead code et refactor
Section titled “1. Dead code et refactor”1.1 deleteMissionAdmin
Section titled “1.1 deleteMissionAdmin”- Il faut centraliser la logique de delete de mission dans un service car utilisé pour deleteMissionAdmin & le closeOwner
2. Tests de chaque route — approche features-first (non-unit)
Section titled “2. Tests de chaque route — approche features-first (non-unit)”2.1 getMissionsAdmin
Section titled “2.1 getMissionsAdmin”Route : GET /api/admin/get-missions — liste paginee des missions pour le back-office admin.
Auth (a terme : Provider cross-cutting de toutes les routes admin mission)
- utilisateur non authentifie -> 401
- utilisateur authentifie avec role !== admin -> 403
Pagination (fixe a 5 missions par page)
- s’il existe plus de 5 missions -> la response ne renvoie au plus que 5 missions dans
data page=2-> renvoie les missions au-dela des 5 premieres (offset = (page-1)*5)nextpage= page courante + 1 (donc 2 par defaut quand aucunpagen’est fourni)
Tri
- les missions sont triees par
created_atdecroissant (plus recente en premier)
Filtre status (optionnel)
status=open-> ne renvoie que les missions ayant ce status ; les autres status sont exclus- aucun
statusfourni -> renvoie toutes les missions (toutes pages confondues)
Enrichissement owner_details
- chaque mission renvoyee porte un bloc
owner_detailsavec exactement les cles : [first_name, last_name, business_name, manager_name, profile_photo, address, email, enterprise_id] business_name,manager_name,enterprise_idsont derives de l’EnterpriseProfilerattachee a la mission- mission sans enterprise (
enterprise_idnull) ->business_nameetenterprise_idvalent null (pas d’erreur)
Compteurs (counts) — agregat global, toutes pages confondues
countsagrege le nombre de missions par status sur l’ensemble de la BDD (independant de la pagination)- les status
matchedetin_progresssont fusionnes dansopen:counts.open= open + matched + in_progress - les cles
matchedetin_progressne sont jamais presentes danscounts - les autres status (ex:
cancel) gardent leur compteur propre
2.2 getMissionsAdminWithIdRole
Section titled “2.2 getMissionsAdminWithIdRole”Route : GET /api/admin/get-missions-with-idrole — missions d’un utilisateur donne, resolues differemment selon son role.
Auth
- utilisateur non authentifie -> 401
- utilisateur authentifie avec role !== admin -> 403
Validation des inputs
user_idmanquant -> 422user_idnon integer -> 422user_idvalide en type mais aucun user en BDD pour cet id -> 422rolemanquant -> 422rolehors de [admin, owner, worker] -> 422statusfourni mais pas un tableau -> 422statusfourni comme tableau d’elements non-string -> 422
Resolution des missions selon le role
role=owner-> renvoie les missions dontowner_id= user_id ; les missions d’autres owners sont excluesrole=worker-> renvoie les missions ou le user a unMissionAssignment(resolution via mission_assignments, PAS via owner_id)role=admin-> meme branche que owner : renvoie les missions dontowner_id= user_id
Filtre status (optionnel)
statusfourni (tableau) -> ne renvoie que les missions dont le status est dans le tableaustatusabsent ou tableau vide -> aucun filtrage par status
Tri
- missions triees par
created_atdecroissant
Forme de chaque mission renvoyee
- chaque element porte exactement les cles : [id, status, created_at, business_name, address, enterprise_id]
- ne renvoie aucun bloc owner_details ni champ prive de l’owner
- bug actuel :
business_name,address,enterprise_idsont toujours null, meme quand la mission a une enterprise. Le controller faitselect('id','status','created_at','owner_id')sansenterprise_id-> la relation belongsToenterprisene se resout jamais. Le test pin ce comportement actuel (assertions sur null) : si leselectest corrige, le test passera au rouge — signal volontaire pour le mettre a jour, pas une regression. A corriger en section 1.
2.3 getAssignmentsAdmin
Section titled “2.3 getAssignmentsAdmin”Route : GET /api/admin/get-assignments — liste des MissionAssignment, filtrable par mission et/ou worker.
Auth
- utilisateur non authentifie -> 401
- utilisateur authentifie avec role !== admin -> 403
Validation des inputs (les deux filtres sont optionnels)
mission_idnon integer -> 422worker_idnon integer -> 422mission_id/worker_idinteger mais inexistant en BDD -> PAS d’erreur (aucune regleexists), renvoie simplement une liste vide
Filtrage
- aucun filtre -> renvoie toutes les MissionAssignment
mission_idfourni -> ne renvoie que les assignments de cette missionworker_idfourni -> ne renvoie que les assignments de ce workermission_id+worker_idfournis -> intersection (assignments de cette mission ET de ce worker)
Tri
- assignments tries par
created_atdecroissant
Forme de la response
- chaque assignment expose ses relations
workeretmissioneager-loaded
2.4 updateMissionDetails
Section titled “2.4 updateMissionDetails”Route : POST /api/admin/update-mission-details — mise a jour des details d’une mission.
Auth (boilerplate)
- utilisateur non authentifie -> 401
- utilisateur authentifie avec role !== admin -> 403
Validation des inputs (boilerplate)
mission_idmanquant -> 422mission_idnon integer -> 422mission_idinteger mais aucune mission en BDD -> 422dateau format invalide -> 422start_timepas au formatH:i:s-> 422end_timepas au formatH:i:s-> 422durationnon numeric -> 422rate_per_hournon numeric -> 422descriptionnon string -> 422looking_idinexistant en BDD -> 422priorities> 255 caracteres -> 422- payload valide (au minimum un
mission_idde mission existante) -> 200 (le validator ne sur-rejette pas)
Logique metier / effets observables
- Quand startTime ou endTime présent et valide appeler missionService->computeDuration pour set les nouvelles valeurs
- Quand requirement_id est présent, vérifier que c bien un string csv et appeler missionService->syncRequirements
- Le recalcul duration utilise les valeurs fusionnees : si seul
start_timeest fourni,end_timereste la valeur existante de la mission (et inversement) - Le sync requirements est un remplacement integral : suppression de TOUS les
mission_requirementsde la mission, puis recreation depuis le CSVrequirement_id(priorites depuis le CSVpriorities, defaut 0 = obligatoire siprioritiesabsent) - Update partiel des champs Mission :
date,start_time,end_time,rate_per_hour,description,looking_idne sont ecrits que s’ils sont presents dans le payload - Un champ non fourni dans le payload garde sa valeur d’origine (pas ecrase a null)
- Quand
looking_idchange ET la mission estopen-> appelerRedisGeoService::moveMissionLooking(mission_id, ancien_looking_id, nouveau_looking_id)pour deplacer la mission de bucket Redis - Quand
looking_idest fourni mais identique a l’actuel -> aucun move Redis (la comparaison old !== new bloque) - Quand la mission n’est PAS
open-> aucun move Redis meme silooking_idchange (seules les missions ouvertes sont indexees dans Redis) - Si
moveMissionLookingjette -> l’erreur est catchee + loguee, la requete reussit quand meme (200) - Response : renvoie la mission fraiche rechargee avec les relations
looking,owner,enterprise,feedbacks - Response : la mission porte un bloc
owner_detailsavec exactement les cles [first_name, last_name, business_name, manager_name, profile_photo, address, enterprise_id] — attention, PAS de cleemailici (contrairement agetMissionsAdmin) owner_details.address= adresse de l’enterprise si presente, sinon adresse de l’owner (fallback)owner_details:business_name,manager_name,enterprise_idderives de l’EnterpriseProfile-> null si la mission n’a pas d’enterprise
2.5 deleteMissionAdmin
Section titled “2.5 deleteMissionAdmin”Route : POST /api/admin/delete-mission — suppression d’une mission.
Etat actuel : le controller fait un
forceDeleteinline. Version cible (cf 1.1) : soft delete delegue aMissionService::purgeMission.
Auth (boilerplate)
- utilisateur non authentifie -> 401
- utilisateur authentifie avec role !== admin -> 403
Validation des inputs (boilerplate)
mission_idmanquant -> 422mission_idnon integer -> 422mission_idinteger mais aucune mission en BDD -> PAS d’erreur : la regle estrequired|integersansexists, leif ($mission)est skippe silencieusement et la route renvoie 200- payload valide (
mission_idd’une mission existante) -> 200
Logique metier / effets observables
La logique de delete (cf 1.1) doit etre extraite dans
MissionService::purgeMission($mission). Note :AdminUserControllerTest.md(1.2) reference deja unMissionService::cleanMissionBeforeDelete— a unifier sous un seul nom de methode.
Les tests pinnent le comportement observable actuel, sans markTestIncomplete : ils doivent
rester verts apres l’extraction de purgeMission (garde anti-regression du refactor).
- la mission n’est plus accessible apres l’appel (
Mission::findrenvoie null) — assertion robuste : valide aussi bien le hard delete actuel qu’un futur soft delete - les donnees liees a la mission sont supprimees :
MissionRequirement,MissionAssignment,MissionWorkerBroadcast mission_idinexistant -> 200 silencieux, aucune donnee touchee
2.6 cancelMissionAdmin
Section titled “2.6 cancelMissionAdmin”Route : POST /api/admin/cancel-mission — annulation d’une mission par un admin.
Auth
- utilisateur non authentifie -> 401
- utilisateur authentifie avec role !== admin -> 403
Validation des inputs
mission_idmanquant -> 422mission_idnon integer -> 422mission_idinteger mais aucune mission en BDD -> 422 (regleexists:missions,id)
Le
if (!$mission) -> 404 "Mission not found"du controller est du code mort : la regleexists:missions,idgarantit deja l’existence avant d’y arriver. A signaler en section 1.
Logique metier / effets observables
- la mission passe en
status = 'cancel' - broadcast
CancelMissiona tous les workers (Userrole=worker) + broadcastAdminInstantMissionClosed - cleanup Redis
removeMissionOpen; si la mission a lat/lng valides -> dispatchRecomputeTensionOnMissionCloseJob - echec du cleanup Redis -> catche + logue, la requete reussit quand meme (200)
- si la mission a une
MissionAssignment-> elle passe enstatus = 'cancel'+ended_at = now() - si la mission n’a pas d’assignment -> aucun effet sur les assignments
- reponse : 200,
datavide
2.7 unmatchMissionAdmin
Section titled “2.7 unmatchMissionAdmin”Route : POST /api/admin/unmatch-mission — defait le matching d’une mission et la rouvre.
Auth
- utilisateur non authentifie -> 401
- utilisateur authentifie avec role !== admin -> 403
Validation des inputs
mission_idmanquant -> 422mission_idnon integer -> 422mission_idinteger mais aucune mission en BDD -> 422 (regleexists:missions,id)
Garde metier
- mission dont le
status !== 'matched'-> 422 (“Mission currently not matched. Can only unmatch currently matched missions.”)
Logique metier / effets observables (mission matched)
- la
MissionAssignmentde la mission est supprimee - la mission repasse en
status = 'open' - l’ensemble (suppression assignment + repassage open) est dans une transaction DB ; toute exception -> rollback + 500 (“Failed to unmatch mission.”)
- si la mission a lat/lng valides ->
RedisGeoService::addMissionOpen+ dispatchRecomputeTensionOnMissionOpenJob - dispatch
SyncMissionMatchingpour relancer le matching sur la mission rouverte - echec Redis/matching -> catche + logue, la requete reussit quand meme
- si un worker etait assigne -> broadcast
WorkerStatusChanged(statut UNREADY) +MissionCancelled - si la mission a une
enterprise-> broadcastAdminInstantMissionOpened - echec d’un broadcast -> catche + logue, la requete reussit quand meme
- reponse : 200 avec la mission rouverte