authenticatedClient('admin', 'admin'); $response = $client->request('POST', '/api/tours', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'label' => 'Tournée Loire', 'tourDate' => '2026-07-15', ], ]); self::assertResponseStatusCodeSame(201); $body = $response->toArray(); self::assertSame('Tournée Loire', $body['label']); self::assertSame('draft', $body['status'], 'RG-6.02 : une tournee est creee en draft.'); // RG-6.01 : owner = utilisateur courant (admin), pose par le processor. $reloaded = $this->getEm()->getRepository(Tour::class)->find($body['id']); self::assertInstanceOf(Tour::class, $reloaded); self::assertSame('admin', $reloaded->getOwner()?->getUserIdentifier(), 'owner = utilisateur courant (RG-6.01).'); } public function testCollectionIsPaginated(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); for ($i = 0; $i < 12; ++$i) { $this->seedTour($admin, 'Tournée '.$i); } $data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray(); self::assertSame(12, $data['totalItems'], 'Les 12 tournees sont comptees.'); self::assertCount(10, $data['member'], 'Page par defaut = 10 items (regle ABSOLUE n°13).'); self::assertArrayHasKey('view', $data, 'Enveloppe Hydra paginee (view present).'); } public function testListRequiresViewPermission(): void { $creds = $this->createUserWithPermission('core.users.view'); $client = $this->authenticatedClient($creds['username'], $creds['password']); $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(403, 'Sans field_sales.tours.view -> 403.'); } /** * RG-6.01 : la Commerciale ne voit que ses propres tournees. */ public function testOwnerFilterHidesOthersTours(): void { $credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS); $credsB = $this->createUserWithPermissions(self::TOUR_PERMISSIONS); $client = $this->authenticatedClient($credsA['username'], $credsA['password']); $userA = $this->getUserByUsername($credsA['username']); $userB = $this->getUserByUsername($credsB['username']); $this->seedTour($userA, 'À moi'); $this->seedTour($userB, "À l'autre"); $data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray(); self::assertSame(1, $data['totalItems'], 'A ne voit que sa tournee (RG-6.01).'); self::assertSame('À moi', $data['member'][0]['label']); } public function testAdminSeesAllTours(): void { $credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS); $userA = $this->getUserByUsername($credsA['username']); $this->seedTour($userA, 'Tournée de A'); $client = $this->authenticatedClient('admin', 'admin'); $data = $client->request('GET', '/api/tours', ['headers' => ['Accept' => self::LD]])->toArray(); self::assertSame(1, $data['totalItems'], "L'admin voit toutes les tournees, y compris celles d'autrui."); } /** * RG-6.01 : une Commerciale ne peut pas acceder a la tournee d'un autre * commercial (404 via le provider). */ public function testCommercialeCannotAccessOthersTour(): void { $credsA = $this->createUserWithPermissions(self::TOUR_PERMISSIONS); $credsB = $this->createUserWithPermissions(self::TOUR_PERMISSIONS); $client = $this->authenticatedClient($credsA['username'], $credsA['password']); $userB = $this->getUserByUsername($credsB['username']); $tourB = $this->seedTour($userB, 'Privée B'); $client->request('GET', '/api/tours/'.$tourB->getId(), ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(404); } public function testDeleteSoftDeletesTour(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin, 'À supprimer'); $tourId = $tour->getId(); $client->request('DELETE', '/api/tours/'.$tourId, ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(204); // Plus accessible via l'API... $client->request('GET', '/api/tours/'.$tourId, ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(404, 'DELETE = soft delete -> 404 ensuite.'); // ...mais la ligne existe toujours avec deletedAt pose (soft delete). $em = $this->getEm(); $reloaded = $em->getRepository(Tour::class)->find($tourId); self::assertInstanceOf(Tour::class, $reloaded); self::assertNotNull($reloaded->getDeletedAt(), 'deletedAt doit etre pose (pas de suppression physique).'); } // ================================================================= // Etapes : sous-ressource + regles de gestion // ================================================================= public function testValidTierStopIsCreated(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); $tier = $this->seedClient('Ferme A'); $address = $this->seedClientAddress($tier); $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'tierType' => 'client', 'tierId' => $tier->getId(), 'addressId' => $address->getId(), 'position' => 0, ], ]); self::assertResponseStatusCodeSame(201); } public function testValidCustomStopIsCreated(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'tierType' => 'custom', 'customLabel' => 'RDV prospect', 'customAddress' => '5 place du Marché, 44000 Nantes', 'customLatitude' => '47.2184000', 'customLongitude' => '-1.5536000', 'position' => 0, ], ]); self::assertResponseStatusCodeSame(201); } /** * RG-6.12 : un point custom exige un libelle (et des coordonnees). */ public function testCustomStopRequiresLabel(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); $response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'tierType' => 'custom', 'customLatitude' => '47.2184000', 'customLongitude' => '-1.5536000', 'position' => 0, ], ]); self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('customLabel', $this->violationsByPath($response->toArray(false))); } /** * RG-6.12 : une etape sur Tiers exige une adresse precise. */ public function testTierStopRequiresAddress(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); $tier = $this->seedClient('Ferme B'); $response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'tierType' => 'client', 'tierId' => $tier->getId(), 'position' => 0, ], ]); self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('addressId', $this->violationsByPath($response->toArray(false))); } /** * RG-6.03 : l'adresse d'une etape doit appartenir au Tiers vise -> 422 sinon. */ public function testAddressMustBelongToTier(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); $tierA = $this->seedClient('Ferme A'); $tierB = $this->seedClient('Ferme B'); $addressB = $this->seedClientAddress($tierB); // tier = A mais adresse = celle de B -> incoherent (RG-6.03). $response = $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'tierType' => 'client', 'tierId' => $tierA->getId(), 'addressId' => $addressB->getId(), 'position' => 0, ], ]); self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('addressId', $this->violationsByPath($response->toArray(false))); } /** * RG-6.07 : deux etapes peuvent viser le meme Tiers (positions distinctes). */ public function testTwoStopsSameTierAccepted(): void { $client = $this->authenticatedClient('admin', 'admin'); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); $tier = $this->seedClient('Ferme A'); $address = $this->seedClientAddress($tier); foreach ([0, 1] as $position) { $client->request('POST', '/api/tours/'.$tour->getId().'/stops', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'tierType' => 'client', 'tierId' => $tier->getId(), 'addressId' => $address->getId(), 'position' => $position, ], ]); self::assertResponseStatusCodeSame(201, 'RG-6.07 : meme Tiers accepte sur deux etapes.'); } } /** * Unicite (tour_id, position) : deux etapes au meme rang sont refusees par * l'index unique. Teste au niveau DBAL (sans casser l'EM de l'ORM). */ public function testPositionUniquenessIsEnforced(): void { $em = $this->getEm(); $admin = $this->getUserByUsername('admin'); $tour = $this->seedTour($admin); $stop = new TourStop(); $stop->setTour($tour); $stop->setTierType(TourStop::TIER_TYPE_CUSTOM); $stop->setCustomLabel('Étape 1'); $stop->setCustomLatitude(47.2184); $stop->setCustomLongitude(-1.5536); $stop->setPosition(0); $em->persist($stop); $em->flush(); // Insertion brute d'une 2e etape au meme (tour_id, position) -> viole // uq_tour_stop_position. Passage par DBAL pour ne pas fermer l'EM ORM. $now = (new DateTimeImmutable())->format('Y-m-d H:i:s'); $this->expectException(UniqueConstraintViolationException::class); $em->getConnection()->insert('tour_stop', [ 'tour_id' => $tour->getId(), 'tier_type' => TourStop::TIER_TYPE_CUSTOM, 'position' => 0, 'created_at' => $now, 'updated_at' => $now, ]); } }