test : tests adversariaux InventoryMapper + docblock EarTagSeriesDto (#3)
Some checks failed
Auto Tag Develop / tag (push) Failing after 16s

| 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: #3
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #3.
This commit is contained in:
2026-04-21 15:52:24 +00:00
committed by Autin
parent f757822f36
commit 366143ce36
4 changed files with 651 additions and 0 deletions

View File

@@ -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
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Mapper\BovinNodeMappingTrait;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use stdClass;
#[CoversTrait(BovinNodeMappingTrait::class)]
final class BovinNodeMappingTraitTest extends TestCase
{
/**
* 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;
}
};
}
// ---------- normalizeToList ----------
public function testNormalizeToListWithNullReturnsEmptyList(): void
{
self::assertSame([], self::adapter()->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<string,array{mixed, ?string}> */
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<string,array{mixed, ?int}> */
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<string,array{mixed, ?bool}> */
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<string,array{mixed}> */
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
<?php
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(
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