Files
Starseed/tests/Module/FieldSales/Api/AbstractFieldSalesApiTestCase.php
T
Matthieu 0052eab1fe
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 29m14s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 11s
feat(field_sales) : entités et API Tournée + Étape (Tour/TourStop) (ERP-124)
Modèle et API CRUD du module Tournées (M6.3, scope réduit V0.2 : pas de
rapport de visite, donc TourStop sans report_id ni check-in).

- Entités Tour (table tour) + TourStop (table tour_stop) : #[Auditable],
  Timestampable/Blamable, enum TourStatus (draft|planned|in_progress|done),
  soft delete sur Tour.
- API Platform : GET/POST/GET/PATCH/DELETE /api/tours (DELETE = soft delete),
  sous-ressource POST /api/tours/{tourId}/stops + PATCH/DELETE /api/tour_stops/{id}.
- RG-6.01 : tournée personnelle (TourProvider filtre owner ; admin/Bureau
  voient tout). RG-6.03 : adresse appartient au Tiers (TourStopProcessor +
  TierAddressResolver DBAL, sans import inter-module). RG-6.07 : pas d'unicité
  tier_id. RG-6.12 : cohérence custom/Tiers (Assert\Callback).
- Migration racine : tables + COMMENT ON COLUMN FR + index unique
  (tour_id, position) + FK CASCADE ; mirror dans ColumnCommentsCatalog.
- i18n audit (fieldsales_tour / _tourstop), mappings Doctrine + API Platform.
- Tests fonctionnels : owner, RG-6.03/6.07/6.12, pagination, unicité position,
  soft delete, RBAC (17 tests).

Co-Authored-By: Matthieu <mtholot19@gmail.com>
2026-06-11 15:54:10 +02:00

147 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\FieldSales\Api;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
/**
* Base des tests fonctionnels du module FieldSales (M6 — tournees).
*
* Mutualise :
* - des factories EM (sans API) pour seeder vite un Client + une adresse
* geolocalisee (cible d'etape), et une tournee appartenant a un user donne ;
* - un helper d'indexation des violations 422 par propertyPath ;
* - le cleanup des donnees jetables (tournees, clients de test, users/roles test_*).
*
* Les imports cross-module (Commercial / Core) sont autorises dans les TESTS
* (la regle ABSOLUE n°1 vise le code de production).
*
* @internal
*/
abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
{
protected const string TEST_CLIENT_PREFIX = 'TEST_FS_CLIENT_';
protected function tearDown(): void
{
$this->cleanupFieldSalesTestData();
parent::tearDown();
}
/**
* Seede un Client minimal (companyName uniquement — les categories sont une
* contrainte Assert non rejouee hors API).
*/
protected function seedClient(string $companyName): Client
{
$em = $this->getEm();
$client = new Client();
$client->setCompanyName(self::TEST_CLIENT_PREFIX.mb_strtoupper($companyName, 'UTF-8'));
$em->persist($client);
$em->flush();
return $client;
}
/**
* Seede une adresse de prospection geolocalisee rattachee a $client. Les
* sites/categories (Assert\Count min 1) ne sont pas rejoues hors API : on
* persiste directement les colonnes NOT NULL + des coordonnees.
*/
protected function seedClientAddress(Client $client, float $lat = 47.218, float $lng = -1.553): ClientAddress
{
$em = $this->getEm();
$address = new ClientAddress();
$address->setClient($client);
$address->setIsProspect(true);
$address->setPostalCode('44000');
$address->setCity('NANTES');
$address->setStreet('1 rue de Test');
$address->setLatitude($lat);
$address->setLongitude($lng);
$em->persist($address);
$em->flush();
return $address;
}
/**
* Seede une tournee appartenant a $owner (sans passer par l'API).
*/
protected function seedTour(User $owner, string $label = 'Tournée test'): Tour
{
$em = $this->getEm();
$tour = new Tour();
$tour->setOwner($owner);
$tour->setLabel($label);
$tour->setTourDate(new DateTimeImmutable('2026-07-01'));
$em->persist($tour);
$em->flush();
return $tour;
}
/**
* Recupere un User par username (ex: 'admin', ou un username jetable cree par
* createUserWithPermission).
*/
protected function getUserByUsername(string $username): User
{
$user = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user, sprintf('User "%s" introuvable.', $username));
return $user;
}
/**
* Indexe les violations d'un corps 422 par propertyPath.
*
* @param array<string, mixed> $body
*
* @return array<string, string>
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
private function cleanupFieldSalesTestData(): void
{
$em = $this->getEm();
// Etapes purgees par CASCADE a la suppression des tournees.
$em->createQuery('DELETE FROM '.Tour::class)->execute();
// Adresses puis clients de test (FK client_address.client_id CASCADE).
$em->createQuery(
'DELETE FROM '.ClientAddress::class.' a WHERE a.client IN ('
.'SELECT c.id FROM '.Client::class.' c WHERE c.companyName LIKE :prefix)',
)->setParameter('prefix', self::TEST_CLIENT_PREFIX.'%')->execute();
$em->createQuery(
'DELETE FROM '.Client::class.' c WHERE c.companyName LIKE :prefix',
)->setParameter('prefix', self::TEST_CLIENT_PREFIX.'%')->execute();
$em->createQuery(
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix',
)->setParameter('prefix', 'testuser_%')->execute();
$em->createQuery(
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix',
)->setParameter('prefix', 'test_%')->execute();
}
}