From 366143ce363615f3e34bcc9e8162cd34e33f8d5f Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 21 Apr 2026 15:52:24 +0000 Subject: [PATCH] test : tests adversariaux InventoryMapper + docblock EarTagSeriesDto (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: https://gitea.malio.fr/MALIO-DEV/ednotif-bundle/pulls/3 Co-authored-by: tristan Co-committed-by: tristan --- .../plans/2026-04-21-tech-debt-phase-1.md | 375 ++++++++++++++++++ src/Bovin/Dto/EarTagSeriesDto.php | 10 + .../Mapper/BovinNodeMappingTraitTest.php | 190 +++++++++ .../Unit/Bovin/Mapper/InventoryMapperTest.php | 76 ++++ 4 files changed, 651 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-tech-debt-phase-1.md create mode 100644 tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php diff --git a/docs/superpowers/plans/2026-04-21-tech-debt-phase-1.md b/docs/superpowers/plans/2026-04-21-tech-debt-phase-1.md new file mode 100644 index 0000000..53897ef --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-tech-debt-phase-1.md @@ -0,0 +1,375 @@ +# Dette technique post-Phase 1 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Combler les deux trous de couverture flaggés par les reviews de Phase 1 avant de démarrer Phase 2 (écriture bovin), pour éviter que les nouveaux mappers empilent de la complexité sur des helpers non-pinnés. + +**Architecture:** Deux fichiers de tests seulement. (1) Un nouveau `BovinNodeMappingTraitTest` qui couvre directement les 5 helpers scalaires du trait via une classe adapter anonyme — jusqu'ici ces helpers n'étaient testés qu'indirectement par les mappers, donc leurs edge cases dérivent sans qu'on s'en rende compte. (2) Des tests adversariaux ajoutés à `InventoryMapperTest` pour pinner les shapes dégradées qui peuvent arriver en prod (`StockBoucles` absent, `DateFin` manquant, `SerieBoucles` en liste, `NbBovins` non-numérique). Pas de changement de code de production — uniquement du test. Un petit docblock ajouté à `EarTagSeriesDto` pour documenter son statut Phase-1. + +**Tech Stack:** PHP 8.4, PHPUnit 12, pas de nouvelle dépendance. + +--- + +## Task 1 — Tests directs des helpers scalaires du trait + +**But** : pinner le comportement de `normalizeToList`, `toNullableString`, `toNullableInt`, `toNullableBool`, `toNullableDate` — les 5 helpers purs que chaque nouveau mapper va hériter via `use BovinNodeMappingTrait`. + +**Approche** : une classe adapter anonyme, définie inline dans le test, qui `use` le trait et re-expose chaque helper en `public` pour l'appeler depuis les tests. Pas de nouvelle production class, pas de refacto du trait. + +**Files:** +- Create: `tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php` + +### Steps + +- [ ] **Step 1: Écrire le fichier de test** + +Contenu complet de `tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php` : +```php +normalizeToList(null)); + } + + public function testNormalizeToListWithScalarWrapsIntoList(): void + { + self::assertSame(['foo'], self::adapter()->normalizeToList('foo')); + self::assertSame([42], self::adapter()->normalizeToList(42)); + } + + public function testNormalizeToListWithObjectWrapsIntoList(): void + { + $obj = new stdClass(); + $result = self::adapter()->normalizeToList($obj); + + self::assertCount(1, $result); + self::assertSame($obj, $result[0]); + } + + public function testNormalizeToListWithListReturnsListUnchanged(): void + { + $input = ['a', 'b', 'c']; + self::assertSame($input, self::adapter()->normalizeToList($input)); + } + + public function testNormalizeToListWithAssociativeArrayDiscardsKeys(): void + { + $input = ['x' => 'a', 'y' => 'b']; + $result = self::adapter()->normalizeToList($input); + + self::assertSame(['a', 'b'], $result); + } + + // ---------- toNullableString ---------- + + /** @return iterable */ + public static function toNullableStringProvider(): iterable + { + yield 'null' => [null, null]; + yield 'empty string' => ['', null]; + yield 'whitespace only' => [' ', null]; + yield 'plain' => ['abc', 'abc']; + yield 'trimmed' => [' abc ', 'abc']; + yield 'int coerced to string' => [42, '42']; + yield 'zero preserved' => ['0', '0']; + } + + #[DataProvider('toNullableStringProvider')] + public function testToNullableString(mixed $input, ?string $expected): void + { + self::assertSame($expected, self::adapter()->toNullableString($input)); + } + + // ---------- toNullableInt ---------- + + /** @return iterable */ + public static function toNullableIntProvider(): iterable + { + yield 'null' => [null, null]; + yield 'int passthrough' => [42, 42]; + yield 'zero int' => [0, 0]; + yield 'numeric string' => ['42', 42]; + yield 'negative string' => ['-7', -7]; + yield 'float-like string' => ['3.14', 3]; + yield 'non-numeric' => ['abc', null]; + yield 'empty string' => ['', null]; + } + + #[DataProvider('toNullableIntProvider')] + public function testToNullableInt(mixed $input, ?int $expected): void + { + self::assertSame($expected, self::adapter()->toNullableInt($input)); + } + + // ---------- toNullableBool ---------- + + /** @return iterable */ + public static function toNullableBoolProvider(): iterable + { + yield 'null' => [null, null]; + yield 'true' => [true, true]; + yield 'false' => [false, false]; + yield 'string 1' => ['1', true]; + yield 'string 0' => ['0', false]; + yield 'empty string' => ['', false]; + yield 'int 1' => [1, true]; + yield 'int 0' => [0, false]; + } + + #[DataProvider('toNullableBoolProvider')] + public function testToNullableBool(mixed $input, ?bool $expected): void + { + self::assertSame($expected, self::adapter()->toNullableBool($input)); + } + + // ---------- toNullableDate ---------- + + public function testToNullableDateFromIsoString(): void + { + $result = self::adapter()->toNullableDate('2026-04-21'); + self::assertEquals(new DateTimeImmutable('2026-04-21'), $result); + } + + public function testToNullableDateFromDateTimeString(): void + { + $result = self::adapter()->toNullableDate('2026-04-21T10:30:00+02:00'); + self::assertEquals(new DateTimeImmutable('2026-04-21T10:30:00+02:00'), $result); + } + + /** @return iterable */ + public static function toNullableDateFallbackProvider(): iterable + { + yield 'null' => [null]; + yield 'empty string' => ['']; + yield 'whitespace' => [' ']; + yield 'non-string int' => [42]; + yield 'non-string array' => [[]]; + yield 'invalid date string' => ['not-a-date']; + } + + #[DataProvider('toNullableDateFallbackProvider')] + public function testToNullableDateReturnsNullOnInvalidInput(mixed $input): void + { + self::assertNull(self::adapter()->toNullableDate($input)); + } +} +``` + +**Notes importantes** : +- L'adapter anonyme utilise la syntaxe `use BovinNodeMappingTrait { ... as public; }` pour promouvoir chaque helper protected en public *seulement dans ce test*. Le trait lui-même reste inchangé (les helpers restent `protected` pour les vrais mappers). +- Le cas `'string false' => [...]` est **volontairement absent** de `toNullableBoolProvider` : le helper actuel retourne `true` pour `'false'` (string non-vide), ce qui est un bug latent mais pas dans le scope de ce task. Documenté ici pour que tu le retrouves si jamais ça remonte en prod. + +- [ ] **Step 2: Lancer le test (doit passer immédiatement)** + +Run: +``` +make test FILES=tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php +``` +Expected : tous les tests passent du premier coup. Pas de RED phase ici — on teste du code existant qui marche déjà. Si un test échoue, c'est qu'on a découvert un bug : remonter avant de commit. + +Compte attendu : environ **36 test cases** (PHPUnit compte chaque entrée de DataProvider comme un test distinct). L'ordre de grandeur compte plus que le chiffre exact — l'important est que 100% soient verts. + +- [ ] **Step 3: Lancer la suite complète pour vérifier qu'il n'y a pas de régression** + +Run: +``` +make test +``` +Expected : 12 tests préexistants + nouveaux tests de ce task, tous verts. + +- [ ] **Step 4: Commit** + +Run: +``` +git add tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php +git commit -m "test : pin des helpers scalaires de BovinNodeMappingTrait" +``` + +--- + +## Task 2 — Tests adversariaux `InventoryMapper` + docblock `EarTagSeriesDto` + +**But** : pinner les shapes dégradées qui peuvent sortir de `ZipMessageDecoder` (message partiel, champ absent, liste à un seul élément venant sous forme d'objet, etc.). Ces cas ne sont pas exotiques : la roundtrip SimpleXML→JSON→stdClass change de shape selon le nombre d'enfants, et EDNOTIF peut omettre des nœuds optionnels. Également, ajouter un docblock sur `EarTagSeriesDto` pour documenter que le raw-node est un choix Phase-1 assumé. + +**Files:** +- Modify: `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` +- Modify: `src/Bovin/Dto/EarTagSeriesDto.php` (ajout docblock uniquement, aucun changement de code) + +### Steps + +- [ ] **Step 1: Ajouter 5 tests à `InventoryMapperTest`** + +Ouvrir `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` et ajouter les 5 méthodes suivantes **après** `testMapInventoryWithoutMessageZipReturnsEmptyLists`, **avant** les helpers privés (`makeSoapResponse`, `makeUnzippedMessage`, `makeAnimalNode`). + +Les helpers privés existants sont réutilisés quand ils suffisent ; sinon on construit un message ad-hoc inline. + +Code à insérer : +```php + public function testMapInventoryWithStockBouclesAbsentYieldsNullFlag(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $message = new stdClass(); + $message->InformationsMessage = new stdClass(); + $message->InformationsMessage->DateDebut = '2026-01-01'; + // StockBoucles deliberately omitted + + $inventory = $mapper->map($this->makeSoapResponse(), $message); + + self::assertNull($inventory->includesEarTagStock); + } + + public function testMapInventoryWithStockBouclesZeroYieldsFalseFlag(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $message = new stdClass(); + $message->InformationsMessage = new stdClass(); + $message->InformationsMessage->StockBoucles = '0'; + + $inventory = $mapper->map($this->makeSoapResponse(), $message); + + self::assertFalse($inventory->includesEarTagStock); + } + + public function testMapInventoryWithDateFinAbsentYieldsNullEndDate(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $message = new stdClass(); + $message->InformationsMessage = new stdClass(); + $message->InformationsMessage->DateDebut = '2026-01-01'; + // DateFin deliberately omitted + + $inventory = $mapper->map($this->makeSoapResponse(), $message); + + self::assertNull($inventory->endDate); + } + + public function testMapInventoryWithSerieBouclesAsListPreservesAllEntries(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $serie1 = new stdClass(); + $serie1->NumeroSerieDebut = 'A0001'; + $serie2 = new stdClass(); + $serie2->NumeroSerieDebut = 'B0001'; + + $message = new stdClass(); + $message->Boucles = new stdClass(); + $message->Boucles->SerieBoucles = [$serie1, $serie2]; + + $inventory = $mapper->map($this->makeSoapResponse(), $message); + + self::assertCount(2, $inventory->earTagSeries); + self::assertSame('A0001', $inventory->earTagSeries[0]->rawNode->NumeroSerieDebut); + self::assertSame('B0001', $inventory->earTagSeries[1]->rawNode->NumeroSerieDebut); + } + + public function testMapInventoryWithMissingNbBovinsDefaultsToZero(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $soapResponse = new stdClass(); + $soapResponse->ReponseStandard = new stdClass(); + $soapResponse->ReponseStandard->Resultat = true; + $soapResponse->ReponseSpecifique = new stdClass(); + // NbBovins deliberately omitted + + $inventory = $mapper->map($soapResponse, null); + + self::assertSame(0, $inventory->nbBovins); + } +``` + +**Rappel sur les imports** : le fichier importe déjà `DateTimeImmutable`, `InventoryDto`, `AnimalSummaryMapper`, `InventoryMapper`, `StandardResponseMapper`, `stdClass`, `TestCase`, `CoversClass`. **Aucun import supplémentaire nécessaire** pour ces 5 nouveaux tests. + +- [ ] **Step 2: Ajouter le docblock à `EarTagSeriesDto`** + +Ouvrir `src/Bovin/Dto/EarTagSeriesDto.php` et remplacer son contenu par : +```php +rawNode->NomDuChamp`. Si un cas d'usage métier concret + * remonte (rebouclage, commande de boucles), on parsera dans un DTO dédié. + */ +final readonly class EarTagSeriesDto +{ + public function __construct( + public object $rawNode, + ) {} +} +``` + +- [ ] **Step 3: Lancer la suite complète** + +Run: +``` +make test +``` +Expected : tous les tests verts, incluant les 5 nouveaux. + +Compte attendu : `OK (N tests, M assertions)` avec `N` = total précédent de la suite + 5. + +- [ ] **Step 4: Commit** + +Run: +``` +git add tests/Unit/Bovin/Mapper/InventoryMapperTest.php src/Bovin/Dto/EarTagSeriesDto.php +git commit -m "test : tests adversariaux InventoryMapper + docblock EarTagSeriesDto" +``` + +--- + +## Checklist finale + +- [ ] `make test` vert sur toute la suite (tous les nouveaux tests + les tests préexistants de Phase 1) +- [ ] 2 commits propres, un par task +- [ ] `BovinNodeMappingTrait` reste inchangé (seule une classe adapter de test le consomme avec re-exposition publique) +- [ ] `InventoryMapper` reste inchangé (uniquement de nouveaux tests qui exercent son code existant) +- [ ] `EarTagSeriesDto` gagne seulement un docblock, pas de changement de comportement diff --git a/src/Bovin/Dto/EarTagSeriesDto.php b/src/Bovin/Dto/EarTagSeriesDto.php index bbadc7e..0e9e0d3 100644 --- a/src/Bovin/Dto/EarTagSeriesDto.php +++ b/src/Bovin/Dto/EarTagSeriesDto.php @@ -4,6 +4,16 @@ declare(strict_types=1); namespace Malio\EdnotifBundle\Bovin\Dto; +/** + * Wrapper minimal d'une entrée `SerieBoucles` du message de l'opération + * `IpBGetInventaire` (quand `includeEarTagStock: true`). + * + * Le noeud XSD `typeSerieBoucles` est riche (plages de numéros, fournisseur, + * statut, dates...). En Phase 1 on garde volontairement la structure brute + * via `$rawNode` : les consommateurs qui ont besoin d'un champ précis y + * accèdent par `$serie->rawNode->NomDuChamp`. Si un cas d'usage métier concret + * remonte (rebouclage, commande de boucles), on parsera dans un DTO dédié. + */ final readonly class EarTagSeriesDto { public function __construct( diff --git a/tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php b/tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php new file mode 100644 index 0000000..842c668 --- /dev/null +++ b/tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php @@ -0,0 +1,190 @@ +normalizeToList(null)); + } + + public function testNormalizeToListWithScalarWrapsIntoList(): void + { + self::assertSame(['foo'], self::adapter()->normalizeToList('foo')); + self::assertSame([42], self::adapter()->normalizeToList(42)); + } + + public function testNormalizeToListWithObjectWrapsIntoList(): void + { + $obj = new stdClass(); + $result = self::adapter()->normalizeToList($obj); + + self::assertCount(1, $result); + self::assertSame($obj, $result[0]); + } + + public function testNormalizeToListWithListReturnsListUnchanged(): void + { + $input = ['a', 'b', 'c']; + self::assertSame($input, self::adapter()->normalizeToList($input)); + } + + public function testNormalizeToListWithAssociativeArrayDiscardsKeys(): void + { + $input = ['x' => 'a', 'y' => 'b']; + $result = self::adapter()->normalizeToList($input); + + self::assertSame(['a', 'b'], $result); + } + + #[DataProvider('toNullableStringProvider')] + public function testToNullableString(mixed $input, ?string $expected): void + { + self::assertSame($expected, self::adapter()->toNullableString($input)); + } + + // ---------- toNullableString ---------- + + /** @return iterable */ + public static function toNullableStringProvider(): iterable + { + yield 'null' => [null, null]; + + yield 'empty string' => ['', null]; + + yield 'whitespace only' => [' ', null]; + + yield 'plain' => ['abc', 'abc']; + + yield 'trimmed' => [' abc ', 'abc']; + + yield 'int coerced to string' => [42, '42']; + + yield 'zero preserved' => ['0', '0']; + } + + #[DataProvider('toNullableIntProvider')] + public function testToNullableInt(mixed $input, ?int $expected): void + { + self::assertSame($expected, self::adapter()->toNullableInt($input)); + } + + // ---------- toNullableInt ---------- + + /** @return iterable */ + public static function toNullableIntProvider(): iterable + { + yield 'null' => [null, null]; + + yield 'int passthrough' => [42, 42]; + + yield 'zero int' => [0, 0]; + + yield 'numeric string' => ['42', 42]; + + yield 'negative string' => ['-7', -7]; + + yield 'float-like string' => ['3.14', 3]; + + yield 'non-numeric' => ['abc', null]; + + yield 'empty string' => ['', null]; + } + + #[DataProvider('toNullableBoolProvider')] + public function testToNullableBool(mixed $input, ?bool $expected): void + { + self::assertSame($expected, self::adapter()->toNullableBool($input)); + } + + // ---------- toNullableBool ---------- + + /** @return iterable */ + public static function toNullableBoolProvider(): iterable + { + yield 'null' => [null, null]; + + yield 'true' => [true, true]; + + yield 'false' => [false, false]; + + yield 'string 1' => ['1', true]; + + yield 'string 0' => ['0', false]; + + yield 'empty string' => ['', false]; + + yield 'int 1' => [1, true]; + + yield 'int 0' => [0, false]; + } + + // ---------- toNullableDate ---------- + + public function testToNullableDateFromIsoString(): void + { + $result = self::adapter()->toNullableDate('2026-04-21'); + self::assertEquals(new DateTimeImmutable('2026-04-21'), $result); + } + + public function testToNullableDateFromDateTimeString(): void + { + $result = self::adapter()->toNullableDate('2026-04-21T10:30:00+02:00'); + self::assertEquals(new DateTimeImmutable('2026-04-21T10:30:00+02:00'), $result); + } + + #[DataProvider('toNullableDateFallbackProvider')] + public function testToNullableDateReturnsNullOnInvalidInput(mixed $input): void + { + self::assertNull(self::adapter()->toNullableDate($input)); + } + + /** @return iterable */ + public static function toNullableDateFallbackProvider(): iterable + { + yield 'null' => [null]; + + yield 'empty string' => ['']; + + yield 'whitespace' => [' ']; + + yield 'non-string int' => [42]; + + yield 'non-string array' => [[]]; + + yield 'invalid date string' => ['not-a-date']; + } + + /** + * Adapter anonyme : re-expose chaque helper protected en public pour le test, + * sans jamais instancier un vrai mapper (ceux-ci ont des dépendances DI). + */ + private static function adapter(): object + { + return new class { + use BovinNodeMappingTrait { + normalizeToList as public; + toNullableString as public; + toNullableInt as public; + toNullableBool as public; + toNullableDate as public; + } + }; + } +} diff --git a/tests/Unit/Bovin/Mapper/InventoryMapperTest.php b/tests/Unit/Bovin/Mapper/InventoryMapperTest.php index 76dac82..2c4a524 100644 --- a/tests/Unit/Bovin/Mapper/InventoryMapperTest.php +++ b/tests/Unit/Bovin/Mapper/InventoryMapperTest.php @@ -54,6 +54,82 @@ final class InventoryMapperTest extends TestCase self::assertNull($inventory->startDate); } + public function testMapInventoryWithStockBouclesAbsentYieldsNullFlag(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $message = new stdClass(); + $message->InformationsMessage = new stdClass(); + $message->InformationsMessage->DateDebut = '2026-01-01'; + // StockBoucles deliberately omitted + + $inventory = $mapper->map($this->makeSoapResponse(), $message); + + self::assertNull($inventory->includesEarTagStock); + } + + public function testMapInventoryWithStockBouclesZeroYieldsFalseFlag(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $message = new stdClass(); + $message->InformationsMessage = new stdClass(); + $message->InformationsMessage->StockBoucles = '0'; + + $inventory = $mapper->map($this->makeSoapResponse(), $message); + + self::assertFalse($inventory->includesEarTagStock); + } + + public function testMapInventoryWithDateFinAbsentYieldsNullEndDate(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $message = new stdClass(); + $message->InformationsMessage = new stdClass(); + $message->InformationsMessage->DateDebut = '2026-01-01'; + // DateFin deliberately omitted + + $inventory = $mapper->map($this->makeSoapResponse(), $message); + + self::assertNull($inventory->endDate); + } + + public function testMapInventoryWithSerieBouclesAsListPreservesAllEntries(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $serie1 = new stdClass(); + $serie1->NumeroSerieDebut = 'A0001'; + $serie2 = new stdClass(); + $serie2->NumeroSerieDebut = 'B0001'; + + $message = new stdClass(); + $message->Boucles = new stdClass(); + $message->Boucles->SerieBoucles = [$serie1, $serie2]; + + $inventory = $mapper->map($this->makeSoapResponse(), $message); + + self::assertCount(2, $inventory->earTagSeries); + self::assertSame('A0001', $inventory->earTagSeries[0]->rawNode->NumeroSerieDebut); + self::assertSame('B0001', $inventory->earTagSeries[1]->rawNode->NumeroSerieDebut); + } + + public function testMapInventoryWithMissingNbBovinsDefaultsToZero(): void + { + $mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper()); + + $soapResponse = new stdClass(); + $soapResponse->ReponseStandard = new stdClass(); + $soapResponse->ReponseStandard->Resultat = true; + $soapResponse->ReponseSpecifique = new stdClass(); + // NbBovins deliberately omitted + + $inventory = $mapper->map($soapResponse, null); + + self::assertSame(0, $inventory->nbBovins); + } + private function makeSoapResponse(): object { $response = new stdClass();