3 Commits

Author SHA1 Message Date
a60a6e46de feat : parser SerieBoucles dans EarTagSeriesDto typé (#4)
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build Release Artefact / build (push) Successful in 41s
| 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: #4
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-22 09:11:53 +00:00
366143ce36 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>
2026-04-21 15:52:24 +00:00
f757822f36 [#ED-1] Ajout des API de lecture bovin (#2)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 39s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #2
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-21 08:14:37 +00:00
34 changed files with 4580 additions and 208 deletions

View File

@@ -33,3 +33,36 @@ Dans le docker-composer.yaml
volumes:
- ../ednotif-bundle:/var/www/html/ednotif-bundle
```
## Utilisation
Le bundle expose `Malio\EdnotifBundle\Bovin\Api\BovinApiInterface`. Injection standard par autowiring.
```php
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
final class MyController
{
public function __construct(private BovinApiInterface $ednotif) {}
public function example(): void
{
// Dossier d'un bovin
$file = $this->ednotif->getAnimalFile('FR1234567890');
// Inventaire du cheptel à une date
$inventory = $this->ednotif->getInventory(
startDate: new \DateTimeImmutable('2026-01-01'),
includeEarTagStock: true,
);
// Retours de notifications depuis une date
$returns = $this->ednotif->getReturnedDossiers(new \DateTimeImmutable('2026-03-01'));
// Sorties présumées par l'IPG (flux de rapprochement)
$presumed = $this->ednotif->getPresumedExits();
}
}
```
Toutes les méthodes lèvent `Malio\EdnotifBundle\Shared\Exception\EdnotifException` en cas de `Resultat=false` côté EDNOTIF.

View File

@@ -6,7 +6,13 @@ use Malio\EdnotifBundle\Auth\TokenProvider;
use Malio\EdnotifBundle\Bovin\Api\BovinApi;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalFileMapper;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;
use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use Malio\EdnotifBundle\Shared\Soap\SoapClientFactory;
use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
@@ -22,6 +28,8 @@ return static function (ContainerConfigurator $container): void {
->arg('$soapOptions', '%ednotif.soap_options%')
;
$services->set(StandardResponseMapper::class);
$services->set('ednotif.soap.guichet', SoapClient::class)
->factory([service(SoapClientFactory::class), 'create'])
->args(['%ednotif.guichet_wsdl%'])
@@ -32,7 +40,29 @@ return static function (ContainerConfigurator $container): void {
->args(['%ednotif.metier_wsdl%'])
;
$services->set(AnimalFileMapper::class);
$services->set(AnimalFileMapper::class)->args([service(StandardResponseMapper::class)]);
$services->set(ZipMessageDecoder::class);
$services->set(AnimalSummaryMapper::class);
$services->set(InventoryMapper::class)
->args([
service(AnimalSummaryMapper::class),
service(StandardResponseMapper::class),
])
;
$services->set(ReturnedDossiersMapper::class)
->args([
service(AnimalSummaryMapper::class),
service(StandardResponseMapper::class),
])
;
$services->set(PresumedExitsMapper::class)
->args([
service(StandardResponseMapper::class),
])
;
$services->set(TokenProvider::class)
->args([
@@ -52,6 +82,10 @@ return static function (ContainerConfigurator $container): void {
service(TokenProvider::class),
service('ednotif.soap.business'),
service(AnimalFileMapper::class),
service(InventoryMapper::class),
service(ReturnedDossiersMapper::class),
service(PresumedExitsMapper::class),
service(ZipMessageDecoder::class),
'%ednotif.exploitation_country_code%',
'%ednotif.exploitation_number%',
])

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,297 @@
# Parsing de `EarTagSeriesDto` — Implementation Plan
> **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:** Transformer `EarTagSeriesDto` d'un wrapper `rawNode` vers un DTO plat à 3 champs (`countryCode`, `startNumber`, `quantity`) avec un helper calculé `endNumber()`, et adapter `InventoryMapper` pour peupler ces champs.
**Architecture:** Single commit atomique — on casse et on rétablit la compat des tests dans la même opération. Un nouveau fichier `EarTagSeriesDtoTest` pin le comportement de `endNumber()` ; les deux tests existants de `InventoryMapperTest` sont adaptés pour consommer les nouveaux champs au lieu de `rawNode`. Aucun autre fichier touché.
**Tech Stack:** PHP 8.4, PHPUnit 12, pas de nouvelle dépendance.
Spec associée : `docs/superpowers/specs/2026-04-22-eartag-series-parsing-design.md`.
---
## File Structure
### À créer
```
tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php 3 tests sur endNumber()
```
### À modifier
```
src/Bovin/Dto/EarTagSeriesDto.php réécriture complète (3 fields + helper)
src/Bovin/Mapper/InventoryMapper.php 1 bloc modifié (instanciation du DTO)
tests/Unit/Bovin/Mapper/InventoryMapperTest.php 2 méthodes adaptées + helpers ajustés
```
---
## Task 1 — DTO typé + mapper adapté + tests
**Exécution en une seule tâche** parce que DTO et mapper sont liés — les modifier séparément laisserait la suite rouge entre commits. On fait un seul commit atomique.
**Files:**
- Create: `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php`
- Modify: `src/Bovin/Dto/EarTagSeriesDto.php` (réécriture)
- Modify: `src/Bovin/Mapper/InventoryMapper.php` (bloc d'instanciation `SerieBoucles`)
- Modify: `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` (2 tests + 1 helper)
### Steps
- [ ] **Step 1: Écrire les 3 nouveaux tests sur le DTO (RED phase)**
Contenu complet de `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php` :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Dto;
use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(EarTagSeriesDto::class)]
final class EarTagSeriesDtoTest extends TestCase
{
public function testEndNumberComputesStartPlusQuantityMinusOne(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0012345678',
quantity: 50,
);
self::assertSame('0012345727', $series->endNumber());
}
public function testEndNumberPreservesLeadingZeroPadding(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0000000001',
quantity: 5,
);
self::assertSame('0000000005', $series->endNumber());
}
public function testEndNumberWithQuantityOneEqualsStartNumber(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0012345678',
quantity: 1,
);
self::assertSame('0012345678', $series->endNumber());
}
}
```
- [ ] **Step 2: Lancer ce test seul (il doit échouer)**
Run :
```
make test FILES=tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php
```
Expected : les 3 tests échouent parce que le constructeur actuel de `EarTagSeriesDto` attend `rawNode: object` et pas les 3 nouveaux champs. Message d'erreur typique :
```
ArgumentCountError: Too few arguments to function ... 0 passed and exactly 1 expected
```
ou
```
TypeError: Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto::__construct(): Argument #1 ($rawNode) must be of type object, string given
```
L'important : on voit bien une erreur liée à la signature du constructeur, pas un problème de syntax/autoload. Si erreur différente, investiguer avant de continuer.
- [ ] **Step 3: Réécrire `EarTagSeriesDto`**
Remplacer intégralement le contenu de `src/Bovin/Dto/EarTagSeriesDto.php` par :
```php
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
/**
* Série de boucles (ear tags) en stock chez l'exploitation, retournée dans
* la réponse de `IpBGetInventaire` quand `includeEarTagStock: true`.
*
* Correspond au type XSD `typeSerieBoucles` (resources/ednotif-ws/IpBNotif_v1.xsd) :
* une plage contigüe de `quantity` boucles à partir du numéro `startNumber`
* dans le pays `countryCode`.
*
* Les numéros sont stockés en string pour préserver le zero-padding du XSD
* (10 chiffres, pattern `0[1-9][0-9]{8}|[1-9][0-9]{9}`).
*/
final readonly class EarTagSeriesDto
{
public function __construct(
public string $countryCode,
public string $startNumber,
public int $quantity,
) {}
public function endNumber(): string
{
return str_pad(
(string) ((int) $this->startNumber + $this->quantity - 1),
10,
'0',
STR_PAD_LEFT,
);
}
}
```
- [ ] **Step 4: Vérifier les tests DTO (GREEN phase partielle)**
Run :
```
make test FILES=tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php
```
Expected : 3 tests passent, 3 assertions.
- [ ] **Step 5: Lancer la suite complète (la suite DOIT être rouge sur InventoryMapperTest)**
Run :
```
make test
```
Expected : au moins 2 tests en échec dans `InventoryMapperTest` (`testMapFullInventory` et `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`) à cause de l'incompatibilité entre le mapper actuel qui fait `new EarTagSeriesDto(rawNode: $serieNode)` et la nouvelle signature. C'est attendu et temporaire — on va le corriger dans les steps suivants.
Si d'autres tests échouent, s'arrêter et investiguer avant de continuer.
- [ ] **Step 6: Adapter `InventoryMapper`**
Dans `src/Bovin/Mapper/InventoryMapper.php`, localiser la boucle qui construit `$earTagSeries`. Elle ressemble à :
```php
$seriesNode = $unzippedMessage->Boucles->SerieBoucles ?? null;
foreach ($this->normalizeToList($seriesNode) as $serieNode) {
if (!is_object($serieNode)) {
continue;
}
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
}
```
Remplacer la ligne d'instanciation par :
```php
$earTagSeries[] = new EarTagSeriesDto(
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
);
```
Reste du mapper inchangé. Les imports actuels couvrent déjà `EarTagSeriesDto` et le trait fournit déjà `toNullableString` / `toNullableInt`.
- [ ] **Step 7: Lire `InventoryMapperTest` pour repérer les fixtures à adapter**
Run :
```
cat tests/Unit/Bovin/Mapper/InventoryMapperTest.php
```
Deux endroits utilisent aujourd'hui le champ `NumeroSerieDebut` (nom fictif, non-XSD — hérité d'une fixture approximative) :
1. La méthode privée `makeUnzippedMessage()` qui construit `$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';` (série unique en objet, pas en liste).
2. Le test `testMapInventoryWithSerieBouclesAsListPreservesAllEntries` qui construit 2 objets avec `NumeroSerieDebut = 'A0001'` et `'B0001'`.
Noter les lignes exactes avant de modifier.
- [ ] **Step 8: Adapter `makeUnzippedMessage()` — série unique**
Dans le helper privé `makeUnzippedMessage()` de `InventoryMapperTest`, remplacer :
```php
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = new stdClass();
$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';
```
par :
```php
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = new stdClass();
$message->Boucles->SerieBoucles->CodePays = 'FR';
$message->Boucles->SerieBoucles->DebutSerie = '0012345678';
$message->Boucles->SerieBoucles->Quantite = 50;
```
- [ ] **Step 9: Adapter `testMapFullInventory` — assertion sur la série**
Dans `testMapFullInventory`, trouver la ligne qui asserte une valeur sur le `rawNode` (s'il y en a une ; sinon juste `assertCount(1, $inventory->earTagSeries)` suffit). Si le test avait une assertion comme `$inventory->earTagSeries[0]->rawNode->NumeroSerieDebut`, la remplacer par :
```php
self::assertSame('FR', $inventory->earTagSeries[0]->countryCode);
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
self::assertSame(50, $inventory->earTagSeries[0]->quantity);
```
Si le test se contentait de `assertCount(1, ...)`, **ajouter** les 3 assertions ci-dessus pour renforcer la couverture post-refactor.
- [ ] **Step 10: Adapter `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`**
Localiser les 2 blocs qui construisent `$serie1` et `$serie2`. Les remplacer par :
```php
$serie1 = new stdClass();
$serie1->CodePays = 'FR';
$serie1->DebutSerie = '0012345678';
$serie1->Quantite = 10;
$serie2 = new stdClass();
$serie2->CodePays = 'FR';
$serie2->DebutSerie = '0055500000';
$serie2->Quantite = 25;
```
Puis remplacer les assertions qui lisaient `rawNode->NumeroSerieDebut` par :
```php
self::assertCount(2, $inventory->earTagSeries);
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
self::assertSame(10, $inventory->earTagSeries[0]->quantity);
self::assertSame('0055500000', $inventory->earTagSeries[1]->startNumber);
self::assertSame(25, $inventory->earTagSeries[1]->quantity);
```
(Le `assertCount(2, …)` doit être conservé si présent ; sinon l'ajouter au-dessus.)
- [ ] **Step 11: Lancer la suite complète (GREEN phase finale)**
Run :
```
make test
```
Expected : tous les tests verts. Compte attendu : **56 tests** (53 précédents + 3 nouveaux sur le DTO). Les 2 tests de `InventoryMapperTest` sont modifiés, pas ajoutés.
Si un test est rouge, investiguer. Causes possibles :
- `endNumber()` calcule mal le padding → vérifier le `str_pad` et la conversion `(int) $this->startNumber`.
- Une assertion de la fixture a été mal recopiée → vérifier les noms de champs XSD.
- Un import manquant dans un fichier modifié → vérifier les `use` statements.
- [ ] **Step 12: Commit**
```
git add src/Bovin/Dto/EarTagSeriesDto.php \
src/Bovin/Mapper/InventoryMapper.php \
tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php \
tests/Unit/Bovin/Mapper/InventoryMapperTest.php
git commit -m "feat : parser SerieBoucles dans EarTagSeriesDto typé"
```
---
## Checklist finale
- [ ] `make test` vert, 56 tests au total
- [ ] Un seul commit atomique
- [ ] `EarTagSeriesDto` n'a plus de propriété `$rawNode`
- [ ] `InventoryMapper` instancie le DTO avec les 3 champs XSD (`CodePays`, `DebutSerie`, `Quantite`)
- [ ] Aucun autre fichier n'a été touché (pas de changement sur `InventoryDto`, `BovinApi`, `BovinApiInterface`, `config/services.php`)

View File

@@ -0,0 +1,138 @@
# Parsing de `SerieBoucles` → `EarTagSeriesDto` typé
## Contexte
`EarTagSeriesDto` a été introduit en Phase 1 comme wrapper minimal (`$rawNode: object`) autour du noeud `SerieBoucles` retourné par `IpBGetInventaire` quand le consommateur passe `includeEarTagStock: true`. Le docblock actuel dit que le XSD `typeSerieBoucles` est « riche » et qu'on différera le parsing — c'est **faux**. Le XSD (`resources/ednotif-ws/IpBNotif_v1.xsd:1181-1197`) ne contient que 3 champs :
```xml
<xsd:complexType name="typeSerieBoucles">
<xsd:sequence>
<xsd:element name="CodePays" type="tns:typeCodePaysFr"/>
<xsd:element name="DebutSerie"> <!-- string, 10 digits, pattern 0[1-9][0-9]{8}|[1-9][0-9]{9} -->
<xsd:element name="Quantite" type="xsd:unsignedInt"/>
</xsd:sequence>
</xsd:complexType>
```
Sémantique : « j'ai `Quantite` boucles consécutives à partir du numéro `DebutSerie` dans le pays `CodePays` ». Une série = une plage de boucles physiques non utilisées, en stock chez l'éleveur.
## But
Remplacer le wrapper `rawNode` par un DTO typé à 3 champs, plus un helper calculé `endNumber()` qui rend l'usage UI immédiat (afficher « 50 boucles de 0012345678 à 0012345727 »).
## Scope
### Inclus
- `EarTagSeriesDto` plat : `countryCode: string`, `startNumber: string`, `quantity: int`.
- Une méthode `endNumber(): string` qui renvoie `startNumber + quantity - 1` avec zéro-padding à 10 chiffres.
- Adaptation du `InventoryMapper` : instancier le DTO avec les 3 champs lus depuis `$serieNode->CodePays`/`DebutSerie`/`Quantite` au lieu de passer le noeud brut.
- Tests unitaires sur le DTO (helper) et sur le mapper (les 2 tests existants pour `SerieBoucles` — full inventory + list — passent à travers les nouveaux champs).
- Mise à jour du docblock sur `EarTagSeriesDto` (le texte Phase 1 est à refaire).
### Exclus (YAGNI)
- `contains(string $tagNumber): bool` et `iterableNumbers(): Generator<string>` — à ajouter le jour où `IpBCreateRebouclage` ou une UI de sélection de boucle en aura vraiment besoin.
- Validation métier (vérifier que `startNumber` matche le pattern XSD `0[1-9][0-9]{8}|[1-9][0-9]{9}`) — EDNOTIF garantit déjà la forme, on n'ajoute pas une seconde ligne de défense.
- Changement du type de `$earTagSeries` dans `InventoryDto` — la liste reste `list<EarTagSeriesDto>`.
## Changement non-rétro-compatible assumé
Le remplacement de `$rawNode` par 3 champs casse l'API publique de `EarTagSeriesDto`. Comme cette DTO n'a **aucun consommateur connu à ce jour** (Ferme n'utilise pas `includeEarTagStock: true`), on fait le changement direct, sans transition. Si un consommateur tiers existait, il faudrait un cycle de dépréciation ; ce n'est pas le cas.
## Architecture
### DTO
```php
final readonly class EarTagSeriesDto
{
public function __construct(
public string $countryCode,
public string $startNumber,
public int $quantity,
) {}
public function endNumber(): string
{
return str_pad(
(string) ((int) $this->startNumber + $this->quantity - 1),
10,
'0',
STR_PAD_LEFT,
);
}
}
```
Décisions à documenter dans le code :
- **`startNumber` en string**, pas int : le XSD impose 10 chiffres avec zéro-padding possible. Convertir en int ferait perdre le format (`'0012345678'``12345678`). Laisser en string préserve le format d'affichage.
- **`endNumber()` renvoie aussi une string** zéro-paddée à 10 chiffres, via `str_pad`. Cohérent avec `startNumber`.
- **`quantity` en int** : xsd:unsignedInt se mappe naturellement sur int PHP (les 10 milliards de boucles possibles tiennent dans int64 sans problème).
### Mapper
Dans `InventoryMapper::map()`, remplacer :
```php
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
```
par :
```php
$earTagSeries[] = new EarTagSeriesDto(
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
);
```
Valeurs par défaut (`''`, `0`) pour les cas où EDNOTIF renverrait un noeud partiel — plus simple qu'une DTO avec des nullable partout, et `quantity = 0` / `startNumber = ''` sont des valeurs sentinelles immédiatement repérables en debug.
### Docblock actualisé
Remplacer le texte Phase 1 sur `EarTagSeriesDto` par une description factuelle de la structure XSD et de la sémantique (plage de boucles en stock).
## Edge cases
- **Noeud `SerieBoucles` partiel** (un des 3 champs manque) : le DTO est construit avec les valeurs sentinelles `''` ou `0`. Pas de crash, pas d'exception. Responsabilité du consommateur de filtrer si besoin.
- **`quantity = 0`** : `endNumber()` renvoie `startNumber - 1` (zéro-padded). Techniquement absurde côté métier mais techniquement déterministe. Ne vaut pas la peine de documenter un `throw`.
- **`startNumber` vide** : `(int) '' === 0`, `endNumber()` renvoie `str_pad((string)(-1), ...)` = `'00000000-1'`. Déjà en terrain « shouldn't happen ». Pas de garde défensive ajoutée.
- **`CodePays` vide** : `endNumber()` ne dépend pas du pays, aucun impact.
## Tests
### DTO
Un fichier `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php` :
- `testEndNumberComputesStartPlusQuantityMinusOne` — cas standard, ex : start `0012345678`, quantity 50, end `0012345727`.
- `testEndNumberPreservesLeadingZeroPadding` — cas avec leading zero, ex : start `0000000001`, quantity 5, end `0000000005`.
- `testEndNumberWithQuantityOne` — une seule boucle dans la série, end === start.
3 tests, ~15 assertions.
### Mapper
Les 2 tests existants (`testMapFullInventory` et `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`) sont **modifiés** pour :
- Peupler `CodePays`, `DebutSerie`, `Quantite` dans les fixtures à la place de `NumeroSerieDebut`.
- Asserter sur `$inventory->earTagSeries[0]->startNumber` et `$inventory->earTagSeries[0]->quantity` au lieu de `rawNode->NumeroSerieDebut`.
Aucun nouveau test sur le mapper — la structure est déjà couverte.
## Impact sur les autres fichiers
- `src/Bovin/Dto/EarTagSeriesDto.php` — réécrit.
- `src/Bovin/Mapper/InventoryMapper.php` — un bloc modifié (instanciation de la DTO).
- `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` — fixtures `SerieBoucles` adaptées (2 tests impactés).
- Nouveau : `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php`.
Aucun autre fichier touché. `InventoryDto`, `BovinApi`, `BovinApiInterface`, `config/services.php` : inchangés.
## Comptage de tests attendu
Avant : 53 tests.
Après : 53 + 3 nouveaux sur le DTO = 56 tests (les 2 du mapper sont modifiés, pas ajoutés).

188
docs/ws-catalog.md Normal file
View File

@@ -0,0 +1,188 @@
# Catalogue des WebServices EDNOTIF
Inventaire des opérations exposées par les WSDL embarqués dans
`resources/ednotif-ws/`, avec le statut d'implémentation et une
recommandation de priorisation.
## Légende statut
- **Implémenté** — opération couverte par le bundle
- **À faire** — opération pertinente non encore implémentée
- **Optionnel** — opération hors périmètre probable, à confirmer selon le consommateur
---
## 1. wsIpBNotif — Notifications IPG Bovin
WS métier principal : déclarations réglementaires d'un cheptel bovin auprès de l'IPG.
### Lecture
| Opération | Statut | Description probable |
|---|---|---|
| `IpBGetDossierAnimal` | Implémenté | Dossier complet d'un bovin (identifications, mouvements, parents…) |
| `IpBGetInventaire` | Implémenté | Inventaire des animaux présents sur l'exploitation |
| `IpBGetRetourDossiers` | Implémenté | Retours de traitement des notifications envoyées |
| `IpBGetSortiesPresumees` | Implémenté | Animaux sortis selon l'IPG mais non déclarés par l'éleveur |
### Écriture
| Opération | Statut | Description probable |
|---|---|---|
| `IpBCreateEntree` | À faire | Déclaration d'entrée d'un bovin sur l'exploitation |
| `IpBCreateSortie` | À faire | Déclaration de sortie (vente, mort, abattage…) |
| `IpBCreateNaissance` | À faire | Déclaration de naissance |
| `IpBCreateMortNe` | À faire | Déclaration de mort-né |
| `IpBCreateAnimalEchange` | À faire | Échange intra-UE |
| `IpBCreateAnimalImporte` | À faire | Import pays tiers |
| `IpBCreateAvisAnimalImporte` | À faire | Avis d'import |
| `IpBCreateRebouclage` | À faire | Rebouclage / remplacement de boucle |
| `IpBCreateCommandeBoucles` | À faire | Commande de boucles |
| `IpBCreateInsemination` | À faire | Déclaration d'insémination |
---
## 2. wsDmB\* — Déclarations de Mouvement Bovin
Famille orientée gestion des mouvements / transporteurs.
### wsDmBConsultation
- `DmBConsultationGetListeDonneesIT` — consultation de données (IT = identifiants traces ?)
- `DmBConsultationGetListeStatutDeplacement` — statuts de déplacement
### wsDmBGestion
- `DmBGestionCreateDroitAccesListeAnimalActeur` — gestion droits d'accès
- `DmBGestionCreateListeICA` — création listes ICA (information chaîne alimentaire)
### wsDmBListe
- `DmBListeCreateListeBovins` / `DmBListeGetListeBovins` — listes de bovins (lots)
### wsDmBTransport
- `DmBTransportCreateChargement` — chargement camion
- `DmBTransportCreateDechargement` — déchargement camion
---
## 3. wsMdBEdel — Maîtrise des Données Bovin (Edel)
Consultation en lecture seule : génétique, lactation, IA, races.
| Opération | Rôle |
|---|---|
| `MdBGetDonneesGenetiquesAnimales` | Données génétiques d'animaux |
| `MdBGetDonneesMalesPublics` | Catalogue mâles reproducteurs publics |
| `MdBGetDonneesOrganismeHabilite` | Référentiel organismes habilités |
| `MdBGetDonneesOrganismeTiers` | Référentiel organismes tiers |
| `ClBGetDonneesCL` | Contrôle laitier |
| `CpBGetDonneesCPB` | Contrôle de performances bouchères |
| `IaBGetDonneesIA` | Données d'insémination |
| `OsBGetDonneesRAC` | Données race (RAC = race certifiée ?) |
| `TkBGetDonneesTE` | Transfert embryonnaire |
| `VaBGetDonneesCPV` | Contrôle performances veaux (?) |
---
## 4. wsMdCEdel — Maîtrise des Données Caprin
Équivalent caprin : CRUD + reproduction + lactation. 15 opérations (`MdCCreate*` pour l'écriture, `MdCGet*` avec variantes `MAJ` pour les deltas).
| Groupe | Opérations |
|---|---|
| Caprin | `MdCCreateCaprin`, `MdCGetDonneesCaprin`, `MdCGetDonneesCaprinMAJ` |
| Reproduction | `MdCCreateSaillie`, `MdCCreateFinGestation`, `MdCGetFinGestation[/MAJ]`, `MdCGetEvenementReproduction[/MAJ]` |
| Mouvement | `MdCCreateMouvement` |
| Contrôle laitier | `MdCGetCLDonneesBrutes[/MAJ]`, `MdCGetCLDonneesElaborees[/MAJ]` |
| Contrats | `MdCGetContratsExploitation` |
---
## 5. wsIpEdel — Identification Pérenne Edel
- `IpGetDonneesExploitation` — données descriptives de l'exploitation
---
## 6. wsMrAde — Échanges ICAR
Conforme aux standards ICAR (flux laitiers internationaux).
- `GetHerdList`
- `UpdateAnimal`
- `UpdateDevice`
- `UpdateLivestockLocation`
- `UpdateMilkingResults`
---
## 7. WsAnnuaire — Annuaire Guichet
Métadonnées techniques du guichet (pas du métier).
- `tkGetServices` — liste des WS disponibles
- `tkGetVersionsService` — versions d'un WS
- `tkGetOperationsServiceVersion` — opérations d'une version
- `tkGetUrl` — URL d'un service
---
## Recommandation de priorisation
Proposition d'ordre, à valider selon le périmètre réel du consommateur.
### Phase 1 — Compléter le bovin (priorité haute)
Continuer sur **wsIpBNotif**, en commençant par la **lecture** :
1. `IpBGetInventaire` — donne immédiatement la liste du cheptel, utile pour toute UI
2. `IpBGetRetourDossiers` — indispensable pour savoir si les notifs passent côté IPG
3. `IpBGetSortiesPresumees` — flux de rapprochement éleveur ↔ IPG
**Raison** : le dossier animal seul est peu utile sans l'inventaire qui permet de savoir *pour quels animaux* appeler `getAnimalFile`. Et sans `RetourDossiers`, toute écriture future est aveugle.
### Phase 2 — Écriture bovin (notifications obligatoires)
Implémenter les déclarations **dans l'ordre des cycles de vie d'un animal** :
4. `IpBCreateNaissance`
5. `IpBCreateEntree` / `IpBCreateSortie`
6. `IpBCreateMortNe`
7. `IpBCreateRebouclage` / `IpBCreateCommandeBoucles`
8. `IpBCreateAnimalEchange` / `IpBCreateAnimalImporte` / `IpBCreateAvisAnimalImporte` (si imports/échanges dans le périmètre)
9. `IpBCreateInsemination` (si non couvert par un autre outil)
### Phase 3 — Mouvements / transport
Si le consommateur gère du transport ou des lots :
10. `wsDmBListe` (lots bovins)
11. `wsDmBTransport` (chargement/déchargement)
12. `wsDmBConsultation` et `wsDmBGestion` selon besoin
### Phase 4 — Référentiels génétiques (optionnel)
Si le consommateur fait de la sélection / génétique :
13. `wsMdBEdel` — lectures ponctuelles, ne justifient une implémentation que s'il y a un usage métier concret
### Phase 5 — Caprin / ICAR (optionnel)
À activer uniquement si multi-espèces ou conformité ICAR requise.
### Hors priorité
- **wsIpEdel** : 1 op, à implémenter *en passant* si besoin ponctuel
- **WsAnnuaire** : utile pour du diagnostic / supervision, pas pour le métier
---
## Découpage structurel proposé
Pour garder un code cohérent, reproduire le pattern existant (`src/Bovin/`) par domaine :
```
src/
├── Auth/ (existant)
├── Bovin/ (IpBNotif + IpEdel exploitation)
├── Mouvement/ (DmB*)
├── Genetique/ (MdBEdel, optionnel)
├── Caprin/ (MdCEdel, optionnel)
├── Icar/ (MrAde, optionnel)
├── Annuaire/ (WsAnnuaire, optionnel)
└── Shared/ (existant)
```
Chaque domaine expose une `*ApiInterface` publique + une implémentation `readonly`, avec ses DTOs et mappers dédiés. Le `TokenProvider` et `SoapClientFactory` restent partagés via `Shared/`.

View File

@@ -86,4 +86,9 @@ php-cs-fixer-allow-risky:
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES)
wait:
sleep 10
sleep 10
# Lance la suite PHPUnit. Usage : make test (tout)
# make test FILES=<path> (un fichier/dossier)
test:
$(EXEC_PHP) php vendor/bin/phpunit $(FILES)

19
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
failOnWarning="true"
failOnRisky="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View File

@@ -4,10 +4,18 @@ declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Api;
use DateTimeInterface;
use Malio\EdnotifBundle\Auth\TokenProvider;
use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalFileMapper;
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;
use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;
use Malio\EdnotifBundle\Shared\Exception\EdnotifException;
use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder;
use RuntimeException;
use SoapClient;
use SoapFault;
@@ -18,6 +26,10 @@ final readonly class BovinApi implements BovinApiInterface
private TokenProvider $tokenProvider,
private SoapClient $businessClient,
private AnimalFileMapper $bovinDossierMapper,
private InventoryMapper $inventoryMapper,
private ReturnedDossiersMapper $returnedDossiersMapper,
private PresumedExitsMapper $presumedExitsMapper,
private ZipMessageDecoder $zipMessageDecoder,
private string $exploitationCountryCode,
private string $exploitationNumber,
) {}
@@ -45,20 +57,122 @@ final readonly class BovinApi implements BovinApiInterface
throw new RuntimeException('SOAP Fault on IpBGetDossierAnimal: '.$soapFault->getMessage(), 0, $soapFault);
}
// Throw uniquement si Resultat=false (erreur métier)
$standardResponseNode = $soapResponse->ReponseStandard ?? null;
$isOk = is_object($standardResponseNode) && (($standardResponseNode->Resultat ?? false) === true);
if (!$isOk) {
$anomalyNode = is_object($standardResponseNode) ? ($standardResponseNode->Anomalie ?? null) : null;
throw new EdnotifException(
codeAnomalie: (string) ($anomalyNode->Code ?? 'UNKNOWN'),
severite: (int) ($anomalyNode->Severite ?? 1),
message: (string) ($anomalyNode->Message ?? 'EDNOTIF error')
);
}
$this->assertSuccessfulResponse($soapResponse, 'IpBGetDossierAnimal');
return $this->bovinDossierMapper->map($soapResponse);
}
public function getInventory(
DateTimeInterface $startDate,
?DateTimeInterface $endDate = null,
bool $includeEarTagStock = false,
): InventoryDto {
$token = $this->tokenProvider->getToken();
$payload = [
'JetonAuthentification' => $token,
'Exploitation' => [
'CodePays' => $this->exploitationCountryCode,
'NumeroExploitation' => $this->exploitationNumber,
],
'DateDebut' => $startDate->format('Y-m-d'),
'StockBoucles' => $includeEarTagStock,
];
if (null !== $endDate) {
$payload['DateFin'] = $endDate->format('Y-m-d');
}
try {
/** @var object $soapResponse */
$soapResponse = $this->businessClient->__soapCall('IpBGetInventaire', [$payload]);
} catch (SoapFault $soapFault) {
throw new RuntimeException('SOAP Fault on IpBGetInventaire: '.$soapFault->getMessage(), 0, $soapFault);
}
$this->assertSuccessfulResponse($soapResponse, 'IpBGetInventaire');
$messageZip = $soapResponse->ReponseSpecifique->MessageZip ?? null;
$unzippedMessage = is_string($messageZip) && '' !== $messageZip
? $this->zipMessageDecoder->decode($messageZip)
: null;
return $this->inventoryMapper->map($soapResponse, $unzippedMessage);
}
public function getReturnedDossiers(DateTimeInterface $startDate): ReturnedDossiersDto
{
$token = $this->tokenProvider->getToken();
$payload = [
'JetonAuthentification' => $token,
'Exploitation' => [
'CodePays' => $this->exploitationCountryCode,
'NumeroExploitation' => $this->exploitationNumber,
],
'DateDebut' => $startDate->format('Y-m-d'),
];
try {
/** @var object $soapResponse */
$soapResponse = $this->businessClient->__soapCall('IpBGetRetourDossiers', [$payload]);
} catch (SoapFault $soapFault) {
throw new RuntimeException('SOAP Fault on IpBGetRetourDossiers: '.$soapFault->getMessage(), 0, $soapFault);
}
$this->assertSuccessfulResponse($soapResponse, 'IpBGetRetourDossiers');
$messageZip = $soapResponse->ReponseSpecifique->MessageZip ?? null;
$unzippedMessage = is_string($messageZip) && '' !== $messageZip
? $this->zipMessageDecoder->decode($messageZip)
: null;
return $this->returnedDossiersMapper->map($soapResponse, $unzippedMessage);
}
public function getPresumedExits(): PresumedExitsDto
{
$token = $this->tokenProvider->getToken();
$payload = [
'JetonAuthentification' => $token,
'Exploitation' => [
'CodePays' => $this->exploitationCountryCode,
'NumeroExploitation' => $this->exploitationNumber,
],
];
try {
/** @var object $soapResponse */
$soapResponse = $this->businessClient->__soapCall('IpBGetSortiesPresumees', [$payload]);
} catch (SoapFault $soapFault) {
throw new RuntimeException('SOAP Fault on IpBGetSortiesPresumees: '.$soapFault->getMessage(), 0, $soapFault);
}
$this->assertSuccessfulResponse($soapResponse, 'IpBGetSortiesPresumees');
$messageZip = $soapResponse->ReponseSpecifique->MessageZip ?? null;
$unzippedMessage = is_string($messageZip) && '' !== $messageZip
? $this->zipMessageDecoder->decode($messageZip)
: null;
return $this->presumedExitsMapper->map($soapResponse, $unzippedMessage);
}
private function assertSuccessfulResponse(object $soapResponse, string $operation): void
{
$standardResponseNode = $soapResponse->ReponseStandard ?? null;
$isOk = is_object($standardResponseNode) && (($standardResponseNode->Resultat ?? false) === true);
if ($isOk) {
return;
}
$anomalyNode = is_object($standardResponseNode) ? ($standardResponseNode->Anomalie ?? null) : null;
throw new EdnotifException(
codeAnomalie: (string) ($anomalyNode->Code ?? 'UNKNOWN'),
severite: (int) ($anomalyNode->Severite ?? 1),
message: (string) ($anomalyNode->Message ?? $operation.' : EDNOTIF error')
);
}
}

View File

@@ -4,9 +4,23 @@ declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Api;
use DateTimeInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
interface BovinApiInterface
{
public function getAnimalFile(string $nationalNumber, string $countryCode = 'FR'): AnimalFileDto;
public function getInventory(
DateTimeInterface $startDate,
?DateTimeInterface $endDate = null,
bool $includeEarTagStock = false,
): InventoryDto;
public function getReturnedDossiers(DateTimeInterface $startDate): ReturnedDossiersDto;
public function getPresumedExits(): PresumedExitsDto;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
final readonly class AnimalSummaryDto
{
/**
* @param list<PresencePeriodDto> $presencePeriods
*/
public function __construct(
public ?BovinIdentificationDto $identification,
public array $presencePeriods,
) {}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
/**
* Série de boucles (ear tags) en stock chez l'exploitation, retournée dans
* la réponse de `IpBGetInventaire` quand `includeEarTagStock: true`.
*
* Correspond au type XSD `typeSerieBoucles` (resources/ednotif-ws/IpBNotif_v1.xsd) :
* une plage contigüe de `quantity` boucles à partir du numéro `startNumber`
* dans le pays `countryCode`.
*
* Les numéros sont stockés en string pour préserver le zero-padding du XSD
* (10 chiffres, pattern `0[1-9][0-9]{8}|[1-9][0-9]{9}`).
*/
final readonly class EarTagSeriesDto
{
public function __construct(
public string $countryCode,
public string $startNumber,
public int $quantity,
) {}
public function endNumber(): string
{
return str_pad(
(string) ((int) $this->startNumber + $this->quantity - 1),
10,
'0',
STR_PAD_LEFT,
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use DateTimeImmutable;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
final readonly class InventoryDto
{
/**
* @param list<AnimalSummaryDto> $animals
* @param list<EarTagSeriesDto> $earTagSeries
*/
public function __construct(
public StandardResponseDto $standardResponse,
public int $nbBovins,
public ?DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
public ?bool $includesEarTagStock,
public array $animals,
public array $earTagSeries,
public ?object $rawSoapResponse,
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use DateTimeImmutable;
final readonly class PresumedExitDto
{
public function __construct(
public ?BovinRef $bovin,
public ?DateTimeImmutable $exitDate,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
final readonly class PresumedExitsDto
{
/**
* @param list<PresumedExitDto> $presumedExits
*/
public function __construct(
public StandardResponseDto $standardResponse,
public int $nbBovins,
public array $presumedExits,
public ?object $rawSoapResponse,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Dto;
use DateTimeImmutable;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
final readonly class ReturnedDossiersDto
{
/**
* @param list<AnimalSummaryDto> $animals
*/
public function __construct(
public StandardResponseDto $standardResponse,
public int $nbBovins,
public ?DateTimeImmutable $startDate,
public array $animals,
public ?object $rawSoapResponse,
) {}
}

View File

@@ -4,24 +4,20 @@ declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\AnimalFileDto;
use Malio\EdnotifBundle\Bovin\Dto\BovinIdentificationDto;
use Malio\EdnotifBundle\Bovin\Dto\BovinRef;
use Malio\EdnotifBundle\Bovin\Dto\DateValueDto;
use Malio\EdnotifBundle\Bovin\Dto\ExploitationRef;
use Malio\EdnotifBundle\Bovin\Dto\MovementDto;
use Malio\EdnotifBundle\Bovin\Dto\ParentInfoDto;
use Malio\EdnotifBundle\Bovin\Dto\PresencePeriodDto;
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
use Throwable;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
final class AnimalFileMapper
{
use BovinNodeMappingTrait;
public function __construct(
private readonly StandardResponseMapper $standardResponseMapper,
) {}
public function map(object $soapResponse): AnimalFileDto
{
$standardResponse = $this->mapStandardResponse($soapResponse->ReponseStandard ?? null);
$standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null);
$specificResponseNode = $soapResponse->ReponseSpecifique ?? null;
$bovinNode = is_object($specificResponseNode) ? ($specificResponseNode->Bovin ?? null) : null;
@@ -51,185 +47,4 @@ final class AnimalFileMapper
rawSoapResponse: $soapResponse
);
}
private function mapStandardResponse(mixed $standardResponseNode): StandardResponseDto
{
$result = (bool) ($standardResponseNode->Resultat ?? false);
$anomalyNode = $standardResponseNode->Anomalie ?? null;
$anomaly = null;
if (is_object($anomalyNode)) {
$anomaly = new AnomalyDto(
code: $this->toNullableString($anomalyNode->Code ?? null),
severity: $this->toNullableInt($anomalyNode->Severite ?? null),
message: $this->toNullableString($anomalyNode->Message ?? null),
);
}
return new StandardResponseDto($result, $anomaly);
}
private function mapIdentification(object $identificationNode): BovinIdentificationDto
{
$bovinRef = $this->mapBovinRef($identificationNode->Bovin ?? null);
$birthDate = null;
$birthDateNode = $identificationNode->DateNaissance ?? null;
if (is_object($birthDateNode)) {
$birthDate = new DateValueDto(
date: $this->toNullableDate($birthDateNode->Date ?? null),
completenessFlag: $this->toNullableString($birthDateNode->TemoinCompletude ?? null),
);
}
$motherCarrier = $this->mapParentInfo($identificationNode->MerePorteuse ?? null);
$fatherIpg = $this->mapParentInfo($identificationNode->PereIPG ?? null);
$birthExploitation = $this->mapExploitationRef($identificationNode->ExploitationNaissance ?? null);
return new BovinIdentificationDto(
bovin: $bovinRef,
sex: $this->toNullableString($identificationNode->Sexe ?? null),
breedType: $this->toNullableString($identificationNode->TypeRacial ?? null),
birthDate: $birthDate,
workNumber: $this->toNullableString($identificationNode->NumeroTravail ?? null),
isFilie: $this->toNullableBool($identificationNode->StatutFilie ?? null),
motherCarrier: $motherCarrier,
fatherIpg: $fatherIpg,
birthExploitation: $birthExploitation,
);
}
private function mapPresencePeriod(object $presencePeriodNode): PresencePeriodDto
{
$entryNode = $presencePeriodNode->Entree ?? null;
$exitNode = $presencePeriodNode->Sortie ?? null;
$entryMovement = is_object($entryNode) ? $this->mapMovement($entryNode, 'entry') : null;
$exitMovement = is_object($exitNode) ? $this->mapMovement($exitNode, 'exit') : null;
return new PresencePeriodDto(
entry: $entryMovement,
exit: $exitMovement,
);
}
private function mapMovement(object $movementNode, string $direction): MovementDto
{
$dateValue = null;
$causeValue = null;
if ('entry' === $direction) {
// SOAP: DateEntree / CauseEntree
$dateValue = $movementNode->DateEntree ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null));
$causeValue = $movementNode->CauseEntree ?? null;
} else {
// SOAP (souvent): DateSortie / CauseSortie
$dateValue = $movementNode->DateSortie ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null));
$causeValue = $movementNode->CauseSortie ?? null;
}
$exploitationRef = $this->mapExploitationRef($movementNode->Exploitation ?? null);
return new MovementDto(
date: $this->toNullableDate($dateValue),
cause: $this->toNullableString($causeValue),
exploitation: $exploitationRef,
);
}
private function mapParentInfo(mixed $parentNode): ?ParentInfoDto
{
if (!is_object($parentNode)) {
return null;
}
$bovinRef = $this->mapBovinRef($parentNode->Bovin ?? null);
return new ParentInfoDto(
bovin: $bovinRef,
breedType: $this->toNullableString($parentNode->TypeRacial ?? null),
);
}
private function mapBovinRef(mixed $bovinNode): ?BovinRef
{
if (!is_object($bovinNode)) {
return null;
}
return new BovinRef(
countryCode: $this->toNullableString($bovinNode->CodePays ?? null),
nationalNumber: $this->toNullableString($bovinNode->NumeroNational ?? null),
);
}
private function mapExploitationRef(mixed $exploitationNode): ?ExploitationRef
{
if (!is_object($exploitationNode)) {
return null;
}
return new ExploitationRef(
countryCode: $this->toNullableString($exploitationNode->CodePays ?? null),
exploitationNumber: $this->toNullableString($exploitationNode->NumeroExploitation ?? null),
);
}
/** @return list<mixed> */
private function normalizeToList(mixed $value): array
{
if (null === $value) {
return [];
}
return is_array($value) ? $value : [$value];
}
private function toNullableString(mixed $value): ?string
{
if (null === $value) {
return null;
}
$stringValue = trim((string) $value);
return '' === $stringValue ? null : $stringValue;
}
private function toNullableInt(mixed $value): ?int
{
if (null === $value) {
return null;
}
if (is_int($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value;
}
return null;
}
private function toNullableBool(mixed $value): ?bool
{
if (null === $value) {
return null;
}
return (bool) $value;
}
private function toNullableDate(mixed $value): ?DateTimeImmutable
{
if (!is_string($value) || '' === trim($value)) {
return null;
}
try {
return new DateTimeImmutable($value);
} catch (Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
final class AnimalSummaryMapper
{
use BovinNodeMappingTrait;
public function map(object $bovinNode): AnimalSummaryDto
{
$identificationNode = $bovinNode->IdentiteBovin ?? null;
$identification = is_object($identificationNode) ? $this->mapIdentification($identificationNode) : null;
$presencePeriods = [];
$presencePeriodsNode = $bovinNode->PeriodesPresences->PeriodePresence ?? null;
foreach ($this->normalizeToList($presencePeriodsNode) as $presencePeriodNode) {
if (!is_object($presencePeriodNode)) {
continue;
}
$presencePeriods[] = $this->mapPresencePeriod($presencePeriodNode);
}
return new AnimalSummaryDto(
identification: $identification,
presencePeriods: $presencePeriods,
);
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\BovinIdentificationDto;
use Malio\EdnotifBundle\Bovin\Dto\BovinRef;
use Malio\EdnotifBundle\Bovin\Dto\DateValueDto;
use Malio\EdnotifBundle\Bovin\Dto\ExploitationRef;
use Malio\EdnotifBundle\Bovin\Dto\MovementDto;
use Malio\EdnotifBundle\Bovin\Dto\ParentInfoDto;
use Malio\EdnotifBundle\Bovin\Dto\PresencePeriodDto;
use Throwable;
/**
* Helpers partagés par les mappers travaillant sur des noeuds `Bovin` issus
* d'EDNOTIF, que la source soit une réponse SOAP directe (stdClass via ext-soap)
* ou un message XML zippé (stdClass via ZipMessageDecoder).
*/
trait BovinNodeMappingTrait
{
protected function mapIdentification(object $identificationNode): BovinIdentificationDto
{
$birthDate = null;
$birthDateNode = $identificationNode->DateNaissance ?? null;
if (is_object($birthDateNode)) {
$birthDate = new DateValueDto(
date: $this->toNullableDate($birthDateNode->Date ?? null),
completenessFlag: $this->toNullableString($birthDateNode->TemoinCompletude ?? null),
);
}
return new BovinIdentificationDto(
bovin: $this->mapBovinRef($identificationNode->Bovin ?? null),
sex: $this->toNullableString($identificationNode->Sexe ?? null),
breedType: $this->toNullableString($identificationNode->TypeRacial ?? null),
birthDate: $birthDate,
workNumber: $this->toNullableString($identificationNode->NumeroTravail ?? null),
isFilie: $this->toNullableBool($identificationNode->StatutFilie ?? null),
motherCarrier: $this->mapParentInfo($identificationNode->MerePorteuse ?? null),
fatherIpg: $this->mapParentInfo($identificationNode->PereIPG ?? null),
birthExploitation: $this->mapExploitationRef($identificationNode->ExploitationNaissance ?? null),
);
}
protected function mapPresencePeriod(object $presencePeriodNode): PresencePeriodDto
{
$entryNode = $presencePeriodNode->Entree ?? null;
$exitNode = $presencePeriodNode->Sortie ?? null;
return new PresencePeriodDto(
entry: is_object($entryNode) ? $this->mapMovement($entryNode, 'entry') : null,
exit: is_object($exitNode) ? $this->mapMovement($exitNode, 'exit') : null,
);
}
protected function mapMovement(object $movementNode, string $direction): MovementDto
{
if ('entry' === $direction) {
$dateValue = $movementNode->DateEntree ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null));
$causeValue = $movementNode->CauseEntree ?? null;
} else {
$dateValue = $movementNode->DateSortie ?? ($movementNode->Date ?? ($movementNode->DateMouvement ?? null));
$causeValue = $movementNode->CauseSortie ?? null;
}
return new MovementDto(
date: $this->toNullableDate($dateValue),
cause: $this->toNullableString($causeValue),
exploitation: $this->mapExploitationRef($movementNode->Exploitation ?? null),
);
}
protected function mapParentInfo(mixed $parentNode): ?ParentInfoDto
{
if (!is_object($parentNode)) {
return null;
}
return new ParentInfoDto(
bovin: $this->mapBovinRef($parentNode->Bovin ?? null),
breedType: $this->toNullableString($parentNode->TypeRacial ?? null),
);
}
protected function mapBovinRef(mixed $bovinNode): ?BovinRef
{
if (!is_object($bovinNode)) {
return null;
}
return new BovinRef(
countryCode: $this->toNullableString($bovinNode->CodePays ?? null),
nationalNumber: $this->toNullableString($bovinNode->NumeroNational ?? null),
);
}
protected function mapExploitationRef(mixed $exploitationNode): ?ExploitationRef
{
if (!is_object($exploitationNode)) {
return null;
}
return new ExploitationRef(
countryCode: $this->toNullableString($exploitationNode->CodePays ?? null),
exploitationNumber: $this->toNullableString($exploitationNode->NumeroExploitation ?? null),
);
}
/** @return list<mixed> */
protected function normalizeToList(mixed $value): array
{
if (null === $value) {
return [];
}
return is_array($value) ? array_values($value) : [$value];
}
protected function toNullableString(mixed $value): ?string
{
if (null === $value) {
return null;
}
$stringValue = trim((string) $value);
return '' === $stringValue ? null : $stringValue;
}
protected function toNullableInt(mixed $value): ?int
{
if (null === $value) {
return null;
}
if (is_int($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value;
}
return null;
}
protected function toNullableBool(mixed $value): ?bool
{
if (null === $value) {
return null;
}
return (bool) $value;
}
protected function toNullableDate(mixed $value): ?DateTimeImmutable
{
if (!is_string($value) || '' === trim($value)) {
return null;
}
try {
return new DateTimeImmutable($value);
} catch (Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
final class InventoryMapper
{
use BovinNodeMappingTrait;
public function __construct(
private readonly AnimalSummaryMapper $animalSummaryMapper,
private readonly StandardResponseMapper $standardResponseMapper,
) {}
public function map(object $soapResponse, ?object $unzippedMessage): InventoryDto
{
$standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null);
$nbBovins = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;
$startDate = null;
$endDate = null;
$includesEarTagStock = null;
$animals = [];
$earTagSeries = [];
if (is_object($unzippedMessage)) {
$infoNode = $unzippedMessage->InformationsMessage ?? null;
if (is_object($infoNode)) {
$startDate = $this->toNullableDate($infoNode->DateDebut ?? null);
$endDate = $this->toNullableDate($infoNode->DateFin ?? null);
$includesEarTagStock = $this->toNullableBool($infoNode->StockBoucles ?? null);
}
$bovinsNode = $unzippedMessage->Bovins->Bovin ?? null;
foreach ($this->normalizeToList($bovinsNode) as $bovinNode) {
if (!is_object($bovinNode)) {
continue;
}
$animals[] = $this->animalSummaryMapper->map($bovinNode);
}
$seriesNode = $unzippedMessage->Boucles->SerieBoucles ?? null;
foreach ($this->normalizeToList($seriesNode) as $serieNode) {
if (!is_object($serieNode)) {
continue;
}
$earTagSeries[] = new EarTagSeriesDto(
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
);
}
}
return new InventoryDto(
standardResponse: $standardResponse,
nbBovins: $nbBovins,
startDate: $startDate,
endDate: $endDate,
includesEarTagStock: $includesEarTagStock,
animals: $animals,
earTagSeries: $earTagSeries,
rawSoapResponse: $soapResponse,
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitDto;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
final class PresumedExitsMapper
{
use BovinNodeMappingTrait;
public function __construct(
private readonly StandardResponseMapper $standardResponseMapper,
) {}
public function map(object $soapResponse, ?object $unzippedMessage): PresumedExitsDto
{
$standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null);
$nbBovins = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;
$presumedExits = [];
if (is_object($unzippedMessage)) {
$exitsNode = $unzippedMessage->SortiesPresumees->SortiePresumee ?? null;
foreach ($this->normalizeToList($exitsNode) as $exitNode) {
if (!is_object($exitNode)) {
continue;
}
$presumedExits[] = new PresumedExitDto(
bovin: $this->mapBovinRef($exitNode->Bovin ?? null),
exitDate: $this->toNullableDate($exitNode->DateSortie ?? null),
);
}
}
return new PresumedExitsDto(
standardResponse: $standardResponse,
nbBovins: $nbBovins,
presumedExits: $presumedExits,
rawSoapResponse: $soapResponse,
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
final class ReturnedDossiersMapper
{
use BovinNodeMappingTrait;
public function __construct(
private readonly AnimalSummaryMapper $animalSummaryMapper,
private readonly StandardResponseMapper $standardResponseMapper,
) {}
public function map(object $soapResponse, ?object $unzippedMessage): ReturnedDossiersDto
{
$standardResponse = $this->standardResponseMapper->map($soapResponse->ReponseStandard ?? null);
$nbBovins = $this->toNullableInt($soapResponse->ReponseSpecifique->NbBovins ?? null) ?? 0;
$startDate = null;
$animals = [];
if (is_object($unzippedMessage)) {
$infoNode = $unzippedMessage->InformationsMessage ?? null;
if (is_object($infoNode)) {
$startDate = $this->toNullableDate($infoNode->DateDebut ?? null);
}
$bovinsNode = $unzippedMessage->Bovins->Bovin ?? null;
foreach ($this->normalizeToList($bovinsNode) as $bovinNode) {
if (!is_object($bovinNode)) {
continue;
}
$animals[] = $this->animalSummaryMapper->map($bovinNode);
}
}
return new ReturnedDossiersDto(
standardResponse: $standardResponse,
nbBovins: $nbBovins,
startDate: $startDate,
animals: $animals,
rawSoapResponse: $soapResponse,
);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Shared\Mapper;
use Malio\EdnotifBundle\Shared\Dto\AnomalyDto;
use Malio\EdnotifBundle\Shared\Dto\StandardResponseDto;
final class StandardResponseMapper
{
public function map(mixed $standardResponseNode): StandardResponseDto
{
$result = (bool) ($standardResponseNode->Resultat ?? false);
$anomalyNode = is_object($standardResponseNode) ? ($standardResponseNode->Anomalie ?? null) : null;
$anomaly = null;
if (is_object($anomalyNode)) {
$anomaly = new AnomalyDto(
code: $this->toNullableString($anomalyNode->Code ?? null),
severity: $this->toNullableInt($anomalyNode->Severite ?? null),
message: $this->toNullableString($anomalyNode->Message ?? null),
);
}
return new StandardResponseDto($result, $anomaly);
}
private function toNullableString(mixed $value): ?string
{
if (null === $value) {
return null;
}
$stringValue = trim((string) $value);
return '' === $stringValue ? null : $stringValue;
}
private function toNullableInt(mixed $value): ?int
{
if (null === $value) {
return null;
}
if (is_int($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value;
}
return null;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Shared\Soap;
use RuntimeException;
use ZipArchive;
final class ZipMessageDecoder
{
/**
* Décode le binaire `MessageZip` retourné par les opérations EDNOTIF de type
* `Get*` (Inventaire / RetourDossiers / SortiesPresumees) : le contenu est déjà
* décodé par ext-soap depuis base64, il reste à dézipper et parser le XML.
*/
public function decode(string $zipBinary): object
{
if ('' === $zipBinary) {
throw new RuntimeException('ZipMessageDecoder: binaire ZIP vide.');
}
$tempFile = tempnam(sys_get_temp_dir(), 'ednotif_zip_');
if (false === $tempFile) {
throw new RuntimeException('ZipMessageDecoder: impossible de créer un fichier temporaire.');
}
try {
if (false === file_put_contents($tempFile, $zipBinary)) {
throw new RuntimeException('ZipMessageDecoder: impossible d\'écrire le fichier temporaire.');
}
$xml = $this->readFirstEntry($tempFile);
} finally {
@unlink($tempFile);
}
$previous = libxml_use_internal_errors(true);
try {
$simpleXml = simplexml_load_string($xml);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
if (false === $simpleXml) {
throw new RuntimeException('ZipMessageDecoder: XML invalide dans l\'archive ZIP.');
}
$json = json_encode($simpleXml);
if (false === $json) {
throw new RuntimeException('ZipMessageDecoder: échec de l\'encodage JSON intermédiaire.');
}
$decoded = json_decode($json, false);
if (!is_object($decoded)) {
throw new RuntimeException('ZipMessageDecoder: décodage JSON non objet.');
}
return $decoded;
}
private function readFirstEntry(string $filePath): string
{
$zip = new ZipArchive();
$openResult = $zip->open($filePath, ZipArchive::RDONLY);
if (true !== $openResult) {
throw new RuntimeException(sprintf('ZipMessageDecoder: ouverture ZIP impossible (code %s).', (string) $openResult));
}
try {
if (0 === $zip->numFiles) {
throw new RuntimeException('ZipMessageDecoder: archive ZIP vide.');
}
$xml = $zip->getFromIndex(0);
if (false === $xml) {
throw new RuntimeException('ZipMessageDecoder: lecture de l\'entrée ZIP impossible.');
}
} finally {
$zip->close();
}
return $xml;
}
}

0
tests/Unit/.gitkeep Normal file
View File

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Dto;
use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
#[CoversClass(EarTagSeriesDto::class)]
final class EarTagSeriesDtoTest extends TestCase
{
public function testEndNumberComputesStartPlusQuantityMinusOne(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0012345678',
quantity: 50,
);
self::assertSame('0012345727', $series->endNumber());
}
public function testEndNumberPreservesLeadingZeroPadding(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0000000001',
quantity: 5,
);
self::assertSame('0000000005', $series->endNumber());
}
public function testEndNumberWithQuantityOneEqualsStartNumber(): void
{
$series = new EarTagSeriesDto(
countryCode: 'FR',
startNumber: '0012345678',
quantity: 1,
);
self::assertSame('0012345678', $series->endNumber());
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;
/**
* @internal
*/
#[CoversClass(AnimalSummaryMapper::class)]
final class AnimalSummaryMapperTest extends TestCase
{
public function testMapReturnsIdentificationAndPresencePeriods(): void
{
$node = $this->makeBovinNode();
$summary = new AnimalSummaryMapper()->map($node);
self::assertInstanceOf(AnimalSummaryDto::class, $summary);
self::assertNotNull($summary->identification);
self::assertSame('FR1234567890', $summary->identification->bovin?->nationalNumber);
self::assertSame('F', $summary->identification->sex);
self::assertCount(2, $summary->presencePeriods);
}
public function testMapHandlesMissingOptionalNodes(): void
{
$summary = new AnimalSummaryMapper()->map(new stdClass());
self::assertNull($summary->identification);
self::assertSame([], $summary->presencePeriods);
}
private function makeBovinNode(): object
{
$node = new stdClass();
$node->IdentiteBovin = new stdClass();
$node->IdentiteBovin->Sexe = 'F';
$node->IdentiteBovin->Bovin = new stdClass();
$node->IdentiteBovin->Bovin->CodePays = 'FR';
$node->IdentiteBovin->Bovin->NumeroNational = 'FR1234567890';
$node->PeriodesPresences = new stdClass();
$node->PeriodesPresences->PeriodePresence = [
$this->makePresencePeriod('2024-01-10', '2024-06-01'),
$this->makePresencePeriod('2024-06-02', null),
];
return $node;
}
private function makePresencePeriod(string $entryDate, ?string $exitDate): object
{
$period = new stdClass();
$period->Entree = new stdClass();
$period->Entree->DateEntree = $entryDate;
if (null !== $exitDate) {
$period->Sortie = new stdClass();
$period->Sortie->DateSortie = $exitDate;
}
return $period;
}
}

View File

@@ -0,0 +1,190 @@
<?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;
/**
* @internal
*/
#[CoversTrait(BovinNodeMappingTrait::class)]
final class BovinNodeMappingTraitTest extends TestCase
{
// ---------- 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);
}
#[DataProvider('toNullableStringProvider')]
public function testToNullableString(mixed $input, ?string $expected): void
{
self::assertSame($expected, self::adapter()->toNullableString($input));
}
// ---------- 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('toNullableIntProvider')]
public function testToNullableInt(mixed $input, ?int $expected): void
{
self::assertSame($expected, self::adapter()->toNullableInt($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('toNullableBoolProvider')]
public function testToNullableBool(mixed $input, ?bool $expected): void
{
self::assertSame($expected, self::adapter()->toNullableBool($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];
}
// ---------- 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<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'];
}
/**
* 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;
}
};
}
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\InventoryDto;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\InventoryMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;
/**
* @internal
*/
#[CoversClass(InventoryMapper::class)]
final class InventoryMapperTest extends TestCase
{
public function testMapFullInventory(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$inventory = $mapper->map($this->makeSoapResponse(), $this->makeUnzippedMessage());
self::assertInstanceOf(InventoryDto::class, $inventory);
self::assertTrue($inventory->standardResponse->result);
self::assertSame(2, $inventory->nbBovins);
self::assertEquals(new DateTimeImmutable('2026-01-01'), $inventory->startDate);
self::assertEquals(new DateTimeImmutable('2026-01-31'), $inventory->endDate);
self::assertTrue($inventory->includesEarTagStock);
self::assertCount(2, $inventory->animals);
self::assertCount(1, $inventory->earTagSeries);
self::assertSame('FR123', $inventory->animals[0]->identification?->bovin?->nationalNumber);
self::assertSame('FR', $inventory->earTagSeries[0]->countryCode);
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
self::assertSame(50, $inventory->earTagSeries[0]->quantity);
}
public function testMapInventoryWithoutMessageZipReturnsEmptyLists(): void
{
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->NbBovins = 0;
$inventory = $mapper->map($soapResponse, null);
self::assertSame(0, $inventory->nbBovins);
self::assertSame([], $inventory->animals);
self::assertSame([], $inventory->earTagSeries);
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->CodePays = 'FR';
$serie1->DebutSerie = '0012345678';
$serie1->Quantite = 10;
$serie2 = new stdClass();
$serie2->CodePays = 'FR';
$serie2->DebutSerie = '0055500000';
$serie2->Quantite = 25;
$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('0012345678', $inventory->earTagSeries[0]->startNumber);
self::assertSame(10, $inventory->earTagSeries[0]->quantity);
self::assertSame('0055500000', $inventory->earTagSeries[1]->startNumber);
self::assertSame(25, $inventory->earTagSeries[1]->quantity);
}
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();
$response->ReponseStandard = new stdClass();
$response->ReponseStandard->Resultat = true;
$response->ReponseSpecifique = new stdClass();
$response->ReponseSpecifique->NbBovins = 2;
return $response;
}
private function makeUnzippedMessage(): object
{
$message = new stdClass();
$message->InformationsMessage = new stdClass();
$message->InformationsMessage->DateDebut = '2026-01-01';
$message->InformationsMessage->DateFin = '2026-01-31';
$message->InformationsMessage->StockBoucles = '1';
$message->Bovins = new stdClass();
$message->Bovins->Bovin = [
$this->makeAnimalNode('FR123'),
$this->makeAnimalNode('FR456'),
];
$message->Boucles = new stdClass();
$message->Boucles->SerieBoucles = new stdClass();
$message->Boucles->SerieBoucles->CodePays = 'FR';
$message->Boucles->SerieBoucles->DebutSerie = '0012345678';
$message->Boucles->SerieBoucles->Quantite = 50;
return $message;
}
private function makeAnimalNode(string $nationalNumber): object
{
$node = new stdClass();
$node->IdentiteBovin = new stdClass();
$node->IdentiteBovin->Bovin = new stdClass();
$node->IdentiteBovin->Bovin->NumeroNational = $nationalNumber;
$node->PeriodesPresences = new stdClass();
$node->PeriodesPresences->PeriodePresence = new stdClass();
$node->PeriodesPresences->PeriodePresence->Entree = new stdClass();
$node->PeriodesPresences->PeriodePresence->Entree->DateEntree = '2025-05-01';
return $node;
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\PresumedExitsDto;
use Malio\EdnotifBundle\Bovin\Mapper\PresumedExitsMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;
/**
* @internal
*/
#[CoversClass(PresumedExitsMapper::class)]
final class PresumedExitsMapperTest extends TestCase
{
public function testMapWithExits(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->NbBovins = 2;
$message = new stdClass();
$message->SortiesPresumees = new stdClass();
$message->SortiesPresumees->SortiePresumee = [
$this->makeExit('FR111', '2026-02-15'),
$this->makeExit('FR222', null),
];
$dto = new PresumedExitsMapper(new StandardResponseMapper())->map($soapResponse, $message);
self::assertInstanceOf(PresumedExitsDto::class, $dto);
self::assertSame(2, $dto->nbBovins);
self::assertCount(2, $dto->presumedExits);
self::assertSame('FR111', $dto->presumedExits[0]->bovin?->nationalNumber);
self::assertEquals(new DateTimeImmutable('2026-02-15'), $dto->presumedExits[0]->exitDate);
self::assertNull($dto->presumedExits[1]->exitDate);
}
public function testMapWithoutMessageReturnsEmpty(): void
{
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->NbBovins = 0;
$dto = new PresumedExitsMapper(new StandardResponseMapper())->map($soapResponse, null);
self::assertSame(0, $dto->nbBovins);
self::assertSame([], $dto->presumedExits);
}
private function makeExit(string $nationalNumber, ?string $exitDate): object
{
$node = new stdClass();
$node->Bovin = new stdClass();
$node->Bovin->CodePays = 'FR';
$node->Bovin->NumeroNational = $nationalNumber;
if (null !== $exitDate) {
$node->DateSortie = $exitDate;
}
return $node;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
use DateTimeImmutable;
use Malio\EdnotifBundle\Bovin\Dto\ReturnedDossiersDto;
use Malio\EdnotifBundle\Bovin\Mapper\AnimalSummaryMapper;
use Malio\EdnotifBundle\Bovin\Mapper\ReturnedDossiersMapper;
use Malio\EdnotifBundle\Shared\Mapper\StandardResponseMapper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use stdClass;
/**
* @internal
*/
#[CoversClass(ReturnedDossiersMapper::class)]
final class ReturnedDossiersMapperTest extends TestCase
{
public function testMapReturnsAnimalsAndStartDate(): void
{
$mapper = new ReturnedDossiersMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->NbBovins = 1;
$message = new stdClass();
$message->InformationsMessage = new stdClass();
$message->InformationsMessage->DateDebut = '2026-03-01';
$message->Bovins = new stdClass();
$message->Bovins->Bovin = new stdClass();
$message->Bovins->Bovin->IdentiteBovin = new stdClass();
$message->Bovins->Bovin->IdentiteBovin->Bovin = new stdClass();
$message->Bovins->Bovin->IdentiteBovin->Bovin->NumeroNational = 'FR789';
$message->Bovins->Bovin->PeriodesPresences = new stdClass();
$message->Bovins->Bovin->PeriodesPresences->PeriodePresence = new stdClass();
$dto = $mapper->map($soapResponse, $message);
self::assertInstanceOf(ReturnedDossiersDto::class, $dto);
self::assertEquals(new DateTimeImmutable('2026-03-01'), $dto->startDate);
self::assertSame(1, $dto->nbBovins);
self::assertCount(1, $dto->animals);
self::assertSame('FR789', $dto->animals[0]->identification?->bovin?->nationalNumber);
}
public function testMapWithoutMessageReturnsEmptyAnimals(): void
{
$mapper = new ReturnedDossiersMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
$soapResponse = new stdClass();
$soapResponse->ReponseStandard = new stdClass();
$soapResponse->ReponseStandard->Resultat = true;
$soapResponse->ReponseSpecifique = new stdClass();
$soapResponse->ReponseSpecifique->NbBovins = 0;
$dto = $mapper->map($soapResponse, null);
self::assertSame(0, $dto->nbBovins);
self::assertSame([], $dto->animals);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Malio\EdnotifBundle\Tests\Unit\Shared\Soap;
use Malio\EdnotifBundle\Shared\Soap\ZipMessageDecoder;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use ZipArchive;
/**
* @internal
*/
#[CoversClass(ZipMessageDecoder::class)]
final class ZipMessageDecoderTest extends TestCase
{
public function testDecodeReturnsObjectFromZippedXml(): void
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>'
.'<MessageIpBNotifGetInventaire>'
.'<InformationsMessage><DateDebut>2026-01-01</DateDebut></InformationsMessage>'
.'<Bovins><Bovin><IdentiteBovin><Sexe>F</Sexe></IdentiteBovin></Bovin></Bovins>'
.'</MessageIpBNotifGetInventaire>';
$zipBinary = $this->makeZipBinary('message.xml', $xml);
$decoded = new ZipMessageDecoder()->decode($zipBinary);
self::assertIsObject($decoded);
self::assertSame('2026-01-01', (string) $decoded->InformationsMessage->DateDebut);
self::assertSame('F', (string) $decoded->Bovins->Bovin->IdentiteBovin->Sexe);
}
public function testDecodeProducesArrayForMultiChildNodes(): void
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>'
.'<MessageIpBNotifGetInventaire>'
.'<Bovins>'
.'<Bovin><IdentiteBovin><Sexe>F</Sexe></IdentiteBovin></Bovin>'
.'<Bovin><IdentiteBovin><Sexe>M</Sexe></IdentiteBovin></Bovin>'
.'</Bovins>'
.'</MessageIpBNotifGetInventaire>';
$decoded = new ZipMessageDecoder()->decode($this->makeZipBinary('message.xml', $xml));
self::assertIsArray($decoded->Bovins->Bovin);
self::assertCount(2, $decoded->Bovins->Bovin);
self::assertSame('F', (string) $decoded->Bovins->Bovin[0]->IdentiteBovin->Sexe);
self::assertSame('M', (string) $decoded->Bovins->Bovin[1]->IdentiteBovin->Sexe);
}
public function testDecodeThrowsOnEmptyBinary(): void
{
$this->expectException(RuntimeException::class);
new ZipMessageDecoder()->decode('');
}
public function testDecodeThrowsOnInvalidZip(): void
{
$this->expectException(RuntimeException::class);
new ZipMessageDecoder()->decode('not a zip');
}
private function makeZipBinary(string $innerFile, string $content): string
{
$tempFile = tempnam(sys_get_temp_dir(), 'test_zip_');
self::assertIsString($tempFile);
$zip = new ZipArchive();
self::assertTrue(true === $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE));
self::assertTrue($zip->addFromString($innerFile, $content));
self::assertTrue($zip->close());
$binary = file_get_contents($tempFile);
@unlink($tempFile);
self::assertIsString($binary);
return $binary;
}
}

5
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require __DIR__.'/../vendor/autoload.php';