Files
ednotif-bundle/docs/superpowers/plans/2026-04-21-tech-debt-phase-1.md
tristan 366143ce36
Some checks failed
Auto Tag Develop / tag (push) Failing after 16s
test : tests adversariaux InventoryMapper + docblock EarTagSeriesDto (#3)
| 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>
2026-04-21 15:52:24 +00:00

15 KiB

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

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 :

    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

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