Compare commits

..

3 Commits

Author SHA1 Message Date
Matthieu 027e80cc4b test(commercial) : cover RG-1.01..1.29 except role-gated (M1) 2026-06-01 16:50:05 +02:00
Matthieu 9f3037c5c4 feat(commercial) : add clients XLSX export endpoint 2026-06-01 16:18:23 +02:00
Matthieu 58ef41434b feat(shared) : add reusable XLSX spreadsheet exporter 2026-06-01 16:18:23 +02:00
19 changed files with 1851 additions and 138 deletions
+1
View File
@@ -16,6 +16,7 @@
"nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpoffice/phpspreadsheet": "^5.7",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
Generated
+425 -80
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2410dcfdb94553f520e1186a73fa98c5",
"content-hash": "aada2e60fd7563f1498b5505b37e3f4b",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -1160,6 +1160,85 @@
},
"time": "2026-03-17T15:23:21+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
@@ -2630,6 +2709,191 @@
],
"time": "2025-12-20T17:47:00+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -3052,6 +3316,115 @@
},
"time": "2026-01-06T21:53:42+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.7.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
},
"time": "2026-04-20T02:42:17+00:00"
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.2",
@@ -3513,6 +3886,57 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "symfony/asset",
"version": "v8.0.8",
@@ -8352,85 +8776,6 @@
],
"time": "2022-12-23T10:58:28+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/xdebug-handler",
"version": "3.0.5",
@@ -0,0 +1,79 @@
# Cahier de test back — M1 Répertoire clients (ticket ERP-60 / #478)
Mapping **toutes les RG (§ 7) → test(s) PHPUnit**, à jour après ERP-60.
Légende source : `ERP-55` `ERP-56` `ERP-57` `ERP-58` = tests écrits par les wagons
précédents ; **`ERP-60`** = tests ajoutés par ce ticket (stratégie « combler les
trous, zéro duplication »).
## Stratégie
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
merge de la stack.
## Mapping RG → test
| RG | Intitulé | Test(s) | Source |
|----|----------|---------|--------|
| RG-1.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 |
| RG-1.02 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** |
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation (CHECK) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | **ERP-60** |
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 |
| RG-1.11 | billingEmail obligatoire ssi isBilling (CHECK) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | **ERP-60** |
| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 |
| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 |
| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 |
| RG-1.15 | ~~Unicité SIREN~~ supprimée (Q4) — SIREN partageable | `ClientUniquenessTest::testDuplicateSirenIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
| RG-1.16 | companyName unique (case-insensitive) parmi actifs → 409 | `ClientApiTest::testPostDuplicateCompanyNameReturns409` ; `ClientMigrationTest::testCompanyNameActivePartialIndexExistsExactlyOnce` | ERP-55 / **ERP-60** |
| RG-1.17 | ~~Unicité email~~ supprimée (Q4) — email partageable | `ClientUniquenessTest::testDuplicateEmailIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
| RG-1.18 | companyName upper-cased serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testCompanyNameIsUppercased` (unit) | ERP-55 |
| RG-1.19 | firstName/lastName capitalize serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPersonNameIsTitleCased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` | ERP-55 / ERP-57 |
| RG-1.20 | Téléphones chiffres-seuls serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPhoneKeepsOnlyDigits` (unit) ; `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` (secondary) | ERP-55 / **ERP-60** |
| RG-1.21 | email lowercase serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testEmailIsLowercased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` / `::testPostAddressNormalizesBillingEmail` | ERP-55 / ERP-57 |
| RG-1.22 | Archive : permission `archive` + archivedAt + aucun autre champ | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` ; `::testPatchArchiveWithOtherFieldReturns422` ; `ClientProcessorTest` (unit, gating archive) | ERP-55 |
| RG-1.23 | Restauration : archivedAt=null ; **409 si conflit d'unicité** | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` (cas nominal) ; **`ClientArchiveTest::testRestoreConflictReturns409`** (409 restauration, gap P1) | ERP-55 / **ERP-60** |
| RG-1.24 | Liste exclut les archivés par défaut | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
| RG-1.25 | `?includeArchived=true` inclut les archivés | `ClientApiTest::testListIncludeArchivedReturnsArchived` | ERP-55 |
| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** |
| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** |
| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE (POST/PATPH catégorie DISTRIBUTEUR/COURTIER → 422) NON IMPLÉMENTÉE côté back au M1** (absente du `ClientAddressProcessor` et de la liste § 8.1). → voir « Gaps & suivi » | — (gap) |
## Couvertures transverses
| Sujet | Test(s) | Source |
|-------|---------|--------|
| Audit iban/bic présents dans le diff (pas d'`#[AuditIgnore]`) | `ClientAuditTest::testRibCreateAuditIncludesIbanAndBic` | **ERP-60** |
| Sécurité générique : 401 anonyme + 403 sans `commercial.clients.view` | `ClientSecurityTest` (collection + détail) ; `ClientExportControllerTest::testForbiddenWithoutClientsViewPermission` / `::testUnauthorizedWhenAnonymous` | **ERP-60** / ERP-58 |
| Migration : index partiel unique présent (1 seul), pas de siren/email unique | `ClientMigrationTest` | **ERP-60** |
| Référentiels comptables read-only (405 écriture, 401/403) | `ReferentialApiTest` | ERP-56 |
| Export XLSX (colonnes accounting selon permission, 401/403) | `ClientExportControllerTest` | ERP-58 |
## Délégué à ERP-74 (#493) — NE PAS faire dans ERP-60
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
Usine) : 200/403 par verbe et par onglet selon le rôle.
- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
## Gaps & suivi
- **RG-1.29 (validation écriture)** : refuser une catégorie de type
`DISTRIBUTEUR`/`COURTIER` sur une `ClientAddress` (→ 422, violation
`categories`) n'est pas implémenté au M1. La spec § 8.1 ne le liste pas comme
cas de test back ; le filtrage de lecture est front-driven. **Suggestion** :
ouvrir un follow-up (durcissement `ClientAddressProcessor`) ou l'intégrer à
ERP-74. Aucune invention de feature dans ERP-60 (ticket test-only).
- **Violations CHECK → statut HTTP** : les CHECK d'adresse (RG-1.06/07/08/11)
sont aujourd'hui rejetées par la base (statut ≥ 400) mais sans mapping fin
vers 422 (pas d'`exception_to_status` ni de listener DBAL→HTTP). Les tests
ERP-60 assertent donc le **rejet** (≥ 400). Un mapping explicite vers 422
serait une amélioration UX d'API (follow-up possible).
@@ -18,6 +18,18 @@ interface ClientRepositoryInterface
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
* - Tri par defaut : companyName ASC (RG-1.26).
* - $search : recherche fuzzy insensible a la casse sur companyName +
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
* - $categoryType : restreint aux clients possedant au moins une categorie
* du type donne (code). Ignore si null/vide.
*
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
* la liste paginee (ClientProvider) et l'export (ClientExportController)
* partagent strictement la meme logique de selection.
*/
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
?string $categoryType = null,
): QueryBuilder;
}
@@ -11,8 +11,6 @@ use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -46,7 +44,6 @@ final class ClientProvider implements ProviderInterface
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
private readonly ClientRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly EntityManagerInterface $em,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
@@ -67,10 +64,15 @@ final class ClientProvider implements ProviderInterface
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$search = $filters['search'] ?? null;
$categoryType = $filters['categoryType'] ?? null;
$qb = $this->repository->createListQueryBuilder($includeArchived);
$this->applySearch($qb, $filters['search'] ?? null);
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
is_string($categoryType) ? $categoryType : null,
);
// Echappatoire ?pagination=false : collection complete sans Paginator
// (cf. convention ERP-72 — utile pour un <select> cote front).
@@ -114,55 +116,6 @@ final class ClientProvider implements ProviderInterface
return $client;
}
/**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
* litteraux.
*/
private function applySearch(QueryBuilder $qb, mixed $search): void
{
if (!is_string($search) || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere(
'LOWER(c.companyName) LIKE :search '
.'OR LOWER(c.lastName) LIKE :search '
.'OR LOWER(c.email) LIKE :search',
)->setParameter('search', $pattern);
}
/**
* Restreint aux clients possedant au moins une categorie du type donne.
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
*/
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
{
if (!is_string($categoryType) || '' === trim($categoryType)) {
return;
}
// Sous-requete construite via l'EntityManager (et non
// $repository->createQueryBuilder()) : createQueryBuilder() n'est pas
// declaree sur ClientRepositoryInterface, l'appeler exposerait un detail
// d'implementation Doctrine hors du contrat (fuite d'abstraction).
$sub = $this->em->createQueryBuilder()
->select('c2.id')
->from(Client::class, 'c2')
->join('c2.categories', 'cat2')
->join('cat2.categoryType', 'ct2')
->where('ct2.code = :categoryType')
;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('categoryType', trim($categoryType))
;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Controller;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire clients (M1, spec-back § 4.6).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est
* OBLIGATOIRE sur la route : sans cela API Platform capterait
* `/api/clients/export.xlsx` comme l'item `GET /api/clients/{id}.{_format}`
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des clients (memes filtres que
* `GET /api/clients`, via {@see ClientRepositoryInterface::createListQueryBuilder()})
* et mapping metier des colonnes.
*
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
* `commercial.clients.accounting.view` (gating identique a la lecture).
*/
#[AsController]
final class ClientExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
private readonly ClientRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
) {}
#[Route('/api/clients/export.xlsx', name: 'commercial_clients_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('commercial.clients.view')]
public function __invoke(Request $request): Response
{
$includeArchived = $this->readBool($request->query->get('includeArchived'));
$search = $request->query->getString('search') ?: null;
$categoryType = $request->query->getString('categoryType') ?: null;
/** @var list<Client> $clients */
$clients = $this->repository
->createListQueryBuilder($includeArchived, $search, $categoryType)
->getQuery()
->getResult()
;
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
$binary = $this->exporter->export(
'Répertoire clients',
$this->buildHeaders($withSiren),
$this->buildRows($clients, $withSiren),
);
return $this->buildResponse($binary);
}
/**
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la
* date de creation, uniquement si l'utilisateur a accounting.view.
*
* @return list<string>
*/
private function buildHeaders(bool $withSiren): array
{
$headers = [
'Nom entreprise',
'Nom contact principal',
'Prénom',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories',
'Sites',
];
if ($withSiren) {
$headers[] = 'SIREN';
}
$headers[] = 'Date de création';
return $headers;
}
/**
* @param list<Client> $clients
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $clients, bool $withSiren): iterable
{
foreach ($clients as $client) {
$row = [
$client->getCompanyName(),
$client->getLastName(),
$client->getFirstName(),
$client->getPhonePrimary(),
$client->getPhoneSecondary(),
$client->getEmail(),
$this->formatCategories($client),
$this->formatSites($client),
];
if ($withSiren) {
$row[] = $client->getSiren();
}
$row[] = $client->getCreatedAt()?->format('d/m/Y');
yield $row;
}
}
/**
* Libelles des categories du client, dedupliques, tries, joints par virgule.
*/
private function formatCategories(Client $client): string
{
$names = [];
foreach ($client->getCategories() as $category) {
// @var CategoryInterface $category
$name = $category->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Le Client ne porte pas de sites en propre : ils sont rattaches aux
* adresses (RG-1.10). La colonne « Sites » agrege donc l'union distincte des
* sites de toutes les adresses du client (decision validee 01/06).
*/
private function formatSites(Client $client): string
{
$names = [];
foreach ($client->getAddresses() as $address) {
foreach ($address->getSites() as $site) {
// @var SiteInterface $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
}
return $this->joinSorted($names);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('repertoire-clients-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
* Aligne sur ClientProvider pour un comportement identique a la liste.
*/
private function readBool(mixed $raw): bool
{
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
}
@@ -31,8 +31,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
{
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
?string $categoryType = null,
): QueryBuilder {
$qb = $this->createQueryBuilder('c')
->andWhere('c.deletedAt IS NULL')
->orderBy('c.companyName', 'ASC')
@@ -42,6 +45,54 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
$qb->andWhere('c.isArchived = false');
}
$this->applySearch($qb, $search);
$this->applyCategoryType($qb, $categoryType);
return $qb;
}
/**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
* litteraux.
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
if (null === $search || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere(
'LOWER(c.companyName) LIKE :search '
.'OR LOWER(c.lastName) LIKE :search '
.'OR LOWER(c.email) LIKE :search',
)->setParameter('search', $pattern);
}
/**
* Restreint aux clients possedant au moins une categorie du type donne.
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
* perturber le DISTINCT / ORDER BY de la requete principale.
*/
private function applyCategoryType(QueryBuilder $qb, ?string $categoryType): void
{
if (null === $categoryType || '' === trim($categoryType)) {
return;
}
$sub = $this->getEntityManager()->createQueryBuilder()
->select('c2.id')
->from(Client::class, 'c2')
->join('c2.categories', 'cat2')
->join('cat2.categoryType', 'ct2')
->where('ct2.code = :categoryType')
;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('categoryType', trim($categoryType))
;
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat d'export d'une feuille de calcul tabulaire vers un binaire XLSX.
*
* Service GENERIQUE et reutilisable : il ne connait aucune entite metier. Le
* module appelant decide QUOI exporter (en-tetes + lignes deja mappees) ; cette
* interface decrit seulement COMMENT produire le fichier. Aucun module n'est
* couple a une implementation concrete : on depend de ce contrat (dans Shared),
* jamais l'inverse (regle ABSOLUE n°1).
*
* Implementee par App\Shared\Infrastructure\Export\PhpSpreadsheetExporter (on
* ne la reference pas via @see pour ne pas creer un import Domain -> Infra).
*/
interface SpreadsheetExporterInterface
{
/**
* Genere un classeur XLSX a une feuille et retourne son contenu binaire.
*
* @param string $sheetTitle titre de l'onglet (assaini / tronque par l'implementation si besoin)
* @param list<string> $headers libelles de la ligne d'en-tete (ligne 1)
* @param iterable<list<null|scalar>> $rows lignes de donnees ; chaque ligne est une liste de cellules alignee sur $headers
*
* @return string contenu binaire du fichier XLSX
*/
public function export(string $sheetTitle, array $headers, iterable $rows): string;
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Export;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use RuntimeException;
/**
* Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet.
*
* Strictement technique : ecrit la ligne d'en-tete puis les lignes de donnees
* dans l'unique feuille du classeur, et retourne le binaire. Aucune logique
* metier, aucune reference a une entite d'un module — le mapping des colonnes
* est de la responsabilite de l'appelant.
*/
final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface
{
// Excel limite le titre d'un onglet a 31 caracteres et interdit certains
// caracteres ; on assainit pour ne jamais faire echouer setTitle().
private const int MAX_SHEET_TITLE_LENGTH = 31;
private const string INVALID_TITLE_CHARS = '*:/\?[]';
public function export(string $sheetTitle, array $headers, iterable $rows): string
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle($this->sanitizeSheetTitle($sheetTitle));
// Ligne 1 : en-tete.
$sheet->fromArray($headers, null, 'A1');
// Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable
// paresseux (generator) sans tout materialiser en memoire.
$rowNumber = 2;
foreach ($rows as $row) {
$sheet->fromArray($row, null, 'A'.$rowNumber);
++$rowNumber;
}
return $this->toBinary($spreadsheet);
}
private function toBinary(Spreadsheet $spreadsheet): string
{
$writer = new Xlsx($spreadsheet);
// Le writer ecrit vers un chemin de fichier : on passe par un fichier
// temporaire puis on lit son contenu binaire.
$tmpFile = tempnam(sys_get_temp_dir(), 'xlsx_export_');
if (false === $tmpFile) {
throw new RuntimeException('Impossible de creer un fichier temporaire pour l\'export XLSX.');
}
try {
$writer->save($tmpFile);
$binary = file_get_contents($tmpFile);
if (false === $binary) {
throw new RuntimeException('Lecture du fichier XLSX temporaire impossible.');
}
return $binary;
} finally {
// Libere les references internes de PhpSpreadsheet puis supprime le
// fichier temporaire, meme en cas d'exception.
$spreadsheet->disconnectWorksheets();
@unlink($tmpFile);
}
}
/**
* Retire les caracteres interdits et tronque a 31 caracteres ; renvoie un
* titre par defaut si la chaine resultante est vide.
*/
private function sanitizeSheetTitle(string $title): string
{
$clean = str_replace(str_split(self::INVALID_TITLE_CHARS), '', $title);
$clean = mb_substr($clean, 0, self::MAX_SHEET_TITLE_LENGTH);
return '' === $clean ? 'Export' : $clean;
}
}
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Sites\Domain\Entity\Site;
/**
* Tests fonctionnels de l'onglet Adresse — combler les trous (ERP-60).
*
* RG-1.09 (code postal) et RG-1.10 (>= 1 site) sont DEJA couverts par
* ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier
* cible les contraintes CHECK BDD non encore testees :
* - RG-1.06 / RG-1.07 / RG-1.08 : `chk_client_address_prospect_exclusive`
* (is_prospect exclusif de is_delivery / is_billing) ;
* - RG-1.11 : `chk_client_address_billing_email` (billing_email obligatoire
* ssi is_billing).
*
* Note : ces regles sont portees par des CHECK Postgres (pas d'Assert ni de
* regle Processor au M1). On verifie donc que la combinaison invalide est
* REJETEE par le serveur (statut >= 400), sans coupler le test au code exact :
* une violation CHECK non mappee remonte aujourd'hui en erreur serveur ; un
* mapping fin vers 422 serait une amelioration ulterieure (hors perimetre
* ERP-60, test-only).
*
* RG-1.29 (filtrage du type de categorie SECTEUR/AUTRE sur une adresse) n'est
* PAS testee : la validation d'ecriture correspondante n'est pas implementee
* cote back au M1 (et ne figure pas dans la liste § 8.1). Documentee comme gap
* dans le cahier de test #478.
*
* @internal
*/
final class ClientAddressTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
/**
* RG-1.06 / RG-1.07 : une adresse de prospection ne peut pas etre une
* adresse de livraison (CHECK chk_client_address_prospect_exclusive).
*/
public function testProspectAddressCannotBeDelivery(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Prospect Delivery');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isProspect' => true,
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
}
/**
* RG-1.06 / RG-1.08 : une adresse de prospection ne peut pas etre une
* adresse de facturation (meme CHECK). On fournit billingEmail pour que la
* seule violation possible soit l'exclusivite prospect/billing.
*/
public function testProspectAddressCannotBeBilling(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Prospect Billing');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isProspect' => true,
'isBilling' => true,
'billingEmail' => 'facturation@test.fr',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
}
/**
* RG-1.11 : une adresse de facturation exige un billingEmail
* (CHECK chk_client_address_billing_email).
*/
public function testBillingAddressRequiresBillingEmail(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Billing No Email');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isBilling' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
}
/**
* RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un
* billingEmail (meme CHECK).
*/
public function testNonBillingAddressRejectsBillingEmail(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Non Billing With Email');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isBilling' => false,
'billingEmail' => 'parasite@test.fr',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
}
/**
* Retourne l'IRI du premier site seede (fixtures Sites).
*/
private function firstSiteIri(): string
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
return '/api/sites/'.$site->getId();
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests d'archivage / restauration — combler les trous (ERP-60).
*
* Le cas nominal RG-1.22 (archive pose archivedAt) + RG-1.23 (restauration
* repasse archivedAt a null) ainsi que le 422 « archive + autre champ » sont
* DEJA couverts par ClientApiTest (ERP-55). Ce fichier cible le trou identifie
* en revue (P1 review ERP-55) : le 409 de RESTAURATION en conflit d'unicite.
*
* @internal
*/
final class ClientArchiveTest extends AbstractCommercialApiTestCase
{
private const string MERGE = 'application/merge-patch+json';
/**
* RG-1.23 : restaurer un client archive dont le nom a ete repris par un
* client actif entre-temps doit echouer en 409 (l'index partiel
* uq_client_company_name_active n'admet qu'un seul actif portant ce nom).
*
* Scenario :
* 1. un client « ACME CONFLICT » est archive (donc hors index partiel) ;
* 2. un autre client actif « ACME CONFLICT » est cree (autorise tant que le
* premier reste archive) ;
* 3. la restauration du premier le rendrait actif -> collision d'unicite
* -> ClientProcessor traduit la UniqueConstraintViolationException en 409.
*/
public function testRestoreConflictReturns409(): void
{
$client = $this->createAdminClient();
$archived = $this->seedClient('Acme Conflict', true);
$this->seedClient('Acme Conflict', false);
$client->request('PATCH', '/api/clients/'.$archived->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
self::assertResponseStatusCodeSame(409);
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
/**
* Tests Audit + Timestampable/Blamable du repertoire clients (ERP-60).
*
* Couvre :
* - RG-1.27 : createdAt / createdBy figes au POST, updatedBy reflete bien
* l'auteur de la modification (POST admin puis PATCH par un autre user) ;
* - Audit (§ 6.1) : le RIB est `#[Auditable]` SANS `#[AuditIgnore]` sur iban /
* bic — ces champs sensibles DOIVENT donc apparaitre dans le diff audite
* (decision Matthieu, revue MR 29/05/2026).
*
* @internal
*/
final class ClientAuditTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
private const string MERGE = 'application/merge-patch+json';
private const string RIB_TYPE = 'commercial.ClientRib';
private const string VALID_IBAN = 'FR1420041010050500013M02606';
private const string VALID_BIC = 'BNPAFRPPXXX';
private ?Connection $auditConnection = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
}
protected function tearDown(): void
{
if (null !== $this->auditConnection) {
$this->auditConnection->close();
}
parent::tearDown();
}
/**
* RG-1.27 : createdAt / createdBy sont poses au POST puis figes ; updatedBy
* suit l'auteur de la derniere modification. On cree en admin puis on
* modifie avec un user `commercial.clients.manage` distinct : createdBy reste
* l'admin, updatedBy devient le manager, createdAt ne bouge pas.
*/
public function testCreatedFrozenAndUpdatedByReflectsModifier(): void
{
// 1. User modificateur (non-admin) cree AVANT le reboot de kernel induit
// par les clients authentifies suivants ; il est persiste en base.
$manageCreds = $this->createUserWithPermission('commercial.clients.manage');
// 2. Creation en admin (createdBy = admin).
$admin = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$created = $admin->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Blamable Co',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'blamable@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$id = (int) $created['id'];
$createdAtTs = new DateTimeImmutable((string) $created['createdAt'])->getTimestamp();
// 3. Modification par le manager (updatedBy = manager).
$manage = $this->authenticatedClient($manageCreds['username'], $manageCreds['password']);
$manage->request('PATCH', '/api/clients/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Blamable Renamed'],
]);
self::assertResponseStatusCodeSame(200);
// 4. Verification cote base (etat re-charge depuis la BDD).
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(ClientEntity::class)->find($id);
self::assertNotNull($reloaded);
self::assertSame('admin', $reloaded->getCreatedBy()?->getUserIdentifier(), 'createdBy doit rester l\'admin createur.');
self::assertSame(
$manageCreds['username'],
$reloaded->getUpdatedBy()?->getUserIdentifier(),
'updatedBy doit refleter le dernier modificateur.',
);
self::assertSame($createdAtTs, $reloaded->getCreatedAt()?->getTimestamp(), 'createdAt doit etre fige au POST.');
self::assertNotNull($reloaded->getUpdatedAt());
self::assertGreaterThanOrEqual($createdAtTs, $reloaded->getUpdatedAt()->getTimestamp());
}
/**
* Audit § 6.1 : la creation d'un RIB produit une ligne audit_log
* `commercial.ClientRib` / `create` dont le snapshot contient iban et bic
* (champs volontairement NON ignores).
*/
public function testRibCreateAuditIncludesIbanAndBic(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedClient('Rib Audit Host');
$rib = $admin->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte audite',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log '
.'WHERE entity_type = :type AND entity_id = :id AND action = :action '
.'ORDER BY performed_at DESC',
['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'],
);
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.');
/** @var array<string, mixed> $changes */
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertSame(self::VALID_IBAN, $changes['iban']);
self::assertSame(self::VALID_BIC, $changes['bic']);
}
}
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire clients (M1, § 4.6).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect des filtres ?search / ?categoryType, gating de
* la colonne SIREN selon commercial.clients.accounting.view, 403 sans
* commercial.clients.view, 401 anonyme.
*
* @internal
*/
final class ClientExportControllerTest extends AbstractCommercialApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/clients/export.xlsx';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
$this->seedClient('Export Alpha');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="repertoire-clients-', $disposition);
self::assertMatchesRegularExpression(
'/filename="repertoire-clients-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headers = $grid[0];
self::assertSame('Nom entreprise', $headers[0]);
self::assertContains('Catégories', $headers);
self::assertContains('Sites', $headers);
self::assertContains('Date de création', $headers);
}
public function testExportExcludesArchivedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedClient('Active One');
$this->seedClient('Archived One', true);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('ACTIVE ONE', $names);
self::assertNotContains('ARCHIVED ONE', $names);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedClient('Searchable Alpha');
$this->seedClient('Other Beta');
$names = $this->companyNames(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('SEARCHABLE ALPHA', $names);
self::assertNotContains('OTHER BETA', $names);
}
public function testExportRespectsCategoryTypeFilter(): void
{
$client = $this->createAdminClient();
$this->seedClient('Distrib Co', false, 'DISTRIBUTEUR');
$this->seedClient('Secteur Co', false, 'SECTEUR');
$names = $this->companyNames(
$client->request('GET', self::EXPORT_URL.'?categoryType=DISTRIBUTEUR')->getContent(),
);
self::assertContains('DISTRIB CO', $names);
self::assertNotContains('SECTEUR CO', $names);
}
public function testSirenColumnPresentWithAccountingView(): void
{
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
$client = $this->createAdminClient();
$seed = $this->seedClient('Siren Co');
$em = $this->getEm();
$seed->setSiren('123456789');
$em->flush();
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('123456789', $this->flatten($grid));
}
public function testSirenColumnAbsentWithoutAccountingView(): void
{
// Seed via admin, puis relecture par un user qui n'a QUE clients.view.
$admin = $this->createAdminClient();
$seed = $this->seedClient('No Siren Co');
$em = $this->getEm();
$seed->setSiren('987654321');
$em->flush();
$creds = $this->createUserWithPermission('commercial.clients.view');
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertNotContains('SIREN', $grid[0]);
self::assertStringNotContainsString('987654321', $this->flatten($grid));
}
public function testForbiddenWithoutClientsViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Nom entreprise » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function companyNames(string $binary): array
{
$grid = $this->gridFromResponse($binary);
$rows = array_slice($grid, 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Aplatit toute la grille en une chaine, pour les assertions de presence.
*
* @param array<int, array<int, mixed>> $grid
*/
private function flatten(array $grid): string
{
return implode('|', array_map(
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
$grid,
));
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
/**
* Tests fonctionnels du formulaire principal — combler les trous (ERP-60).
*
* RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs
* + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les
* reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire),
* non encore testee.
*
* @internal
*/
final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
/**
* RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes
* distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur
* la colonne secondaire.
*/
public function testPostPersistsSecondaryPhoneNormalized(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Two Phones SARL',
'firstName' => 'A',
'phonePrimary' => '06.12.34.56.78',
'phoneSecondary' => '05 49 00 11 22',
'email' => 'twophones@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('0549001122', $data['phoneSecondary']);
}
/**
* RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero.
*/
public function testThirdPhoneFieldIsIgnored(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Third Phone SARL',
'firstName' => 'A',
'phonePrimary' => '0612345678',
'phoneSecondary' => '0549001122',
'phoneTertiary' => '0700000000',
'email' => 'thirdphone@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas
// dans la representation et n'a pas ete persiste.
self::assertArrayNotHasKey('phoneTertiary', $data);
// Confirmation cote base : seules les 2 colonnes telephone existent.
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
self::assertNotNull($persisted);
self::assertSame('0612345678', $persisted->getPhonePrimary());
self::assertSame('0549001122', $persisted->getPhoneSecondary());
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests de structure / migration M1 (ERP-60).
*
* Verifie la decision Q4 (29/05/2026) au niveau du schema Postgres :
* - l'unique index partiel fonctionnel uq_client_company_name_active existe
* (un seul, sur LOWER(company_name), partiel sur les actifs non archives /
* non supprimes) — seule unicite metier conservee (RG-1.16) ;
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active
* (RG-1.17) ont ete supprimes / ne sont jamais crees.
*
* @internal
*/
final class ClientMigrationTest extends AbstractCommercialApiTestCase
{
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
{
$rows = $this->clientIndexes();
$companyNameIndexes = array_filter(
$rows,
static fn (array $r): bool => 'uq_client_company_name_active' === $r['indexname'],
);
self::assertCount(
1,
$companyNameIndexes,
'Il doit exister exactement UN index uq_client_company_name_active.',
);
// Confirme la nature fonctionnelle (LOWER) + partielle (WHERE) de l'index.
// Postgres serialise l'expression sous la forme `lower((company_name)::text)`,
// d'ou des verifications de sous-chaines distinctes.
$def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']);
self::assertStringContainsString('unique', $def);
self::assertStringContainsString('lower', $def);
self::assertStringContainsString('company_name', $def);
self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).');
}
public function testNoSirenOrEmailUniqueIndex(): void
{
$names = array_map(static fn (array $r): string => $r['indexname'], $this->clientIndexes());
// RG-1.15 / RG-1.17 supprimees (Q4) : aucun index unique siren / email.
self::assertNotContains('uq_client_siren_active', $names);
self::assertNotContains('uq_client_email_active', $names);
}
/**
* @return list<array{indexname: string, indexdef: string}>
*/
private function clientIndexes(): array
{
self::bootKernel();
/** @var list<array{indexname: string, indexdef: string}> $rows */
return $this->getEm()->getConnection()->fetchAllAssociative(
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'client'",
);
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
/**
* Test fonctionnel du mode strict PATCH multi-groupes (RG-1.28) — ERP-60.
*
* Le cas est deja couvert en unitaire (ClientProcessorTest) ; on en ajoute la
* preuve fonctionnelle HTTP, SANS dependre d'un role metier : un utilisateur
* portant `commercial.clients.manage` mais PAS `commercial.clients.accounting.manage`
* qui envoie un PATCH melant un champ principal (companyName) et un champ
* comptable (siren) recoit un 403 sur l'ENSEMBLE du payload — aucun champ n'est
* applique (pas de filtrage silencieux).
*
* ⚠ La matrice differenciee par role metier (Bureau / Compta / Commerciale) est
* DELEGUEE a ERP-74 (#493). Ici on n'utilise qu'un user mono-permission.
*
* @internal
*/
final class ClientPatchStrictTest extends AbstractCommercialApiTestCase
{
private const string MERGE = 'application/merge-patch+json';
public function testMixedGroupsPatchWithoutAccountingPermissionIsForbidden(): void
{
$seed = $this->seedClient('Strict Mix');
$credentials = $this->createUserWithPermission('commercial.clients.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'companyName' => 'Renamed Strict',
'siren' => '123456789',
],
]);
// RG-1.28 : 403 strict (le champ comptable siren exige accounting.manage).
self::assertResponseStatusCodeSame(403);
// Aucun champ applique : le companyName d'origine est intact.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(ClientEntity::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertSame('STRICT MIX', $reloaded->getCompanyName());
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests de securite GENERIQUE de /api/clients (ERP-60).
*
* Couvre les garde-fous non dependants des roles metier :
* - 401 si requete anonyme (firewall JWT) ;
* - 403 si l'utilisateur authentifie ne porte pas `commercial.clients.view`.
*
* ⚠ La matrice RBAC differenciee par role metier (bureau / compta / commerciale
* / usine) et le test fonctionnel RG-1.04 sont DELEGUES a ERP-74 (#493) : ils
* exigent les roles seedes apres le merge de la stack. NE PAS les ajouter ici.
*
* @internal
*/
final class ClientSecurityTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
public function testAnonymousGetCollectionReturns401(): void
{
$client = self::createClient();
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
public function testAnonymousGetItemReturns401(): void
{
$seed = $this->seedClient('Anon Item');
$client = self::createClient();
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
public function testForbiddenWithoutClientsViewPermission(): void
{
// User authentifie portant une permission SANS rapport avec les clients.
$seed = $this->seedClient('Forbidden Target');
$credentials = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
// Collection.
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
// Detail.
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
/**
* Tests d'unicite — combler les trous (ERP-60).
*
* RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par
* ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier
* verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee)
* et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques.
*
* @internal
*/
final class ClientUniquenessTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
/**
* RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme
* email principal — aucune contrainte d'unicite (un email peut servir
* plusieurs clients).
*/
public function testDuplicateEmailIsAllowed(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$iri = '/api/categories/'.$cat->getId();
$payload = static fn (string $name): array => [
'companyName' => $name,
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'partage@test.fr',
'categories' => [$iri],
];
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]);
self::assertResponseStatusCodeSame(201);
// Meme email, nom different -> doit passer (pas d'index unique email).
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]);
self::assertResponseStatusCodeSame(201);
}
/**
* RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements
* multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on
* seede donc directement via l'ORM et on prouve que le flush ne leve aucune
* violation d'unicite.
*/
public function testDuplicateSirenIsAllowed(): void
{
// Boot kernel pour disposer de l'EM (pas d'appel HTTP necessaire ici).
self::bootKernel();
$em = $this->getEm();
$one = $this->seedClient('Siren Share One');
$two = $this->seedClient('Siren Share Two');
$one->setSiren('123456789');
$two->setSiren('123456789');
$em->flush();
// Aucune exception : preuve qu'il n'existe pas d'index unique sur siren.
self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($one->getId())->getSiren());
self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($two->getId())->getSiren());
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Infrastructure\Export;
use App\Shared\Infrastructure\Export\PhpSpreadsheetExporter;
use Generator;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\TestCase;
/**
* Test unitaire du service Shared d'export XLSX. Verifie que le binaire produit
* est un vrai fichier XLSX relisible, que l'en-tete et les lignes sont ecrits
* dans le bon ordre, qu'un iterable paresseux (generator) est accepte et que le
* titre d'onglet est assaini.
*
* @internal
*/
final class PhpSpreadsheetExporterTest extends TestCase
{
public function testExportProducesReadableXlsxWithHeadersAndRows(): void
{
$binary = new PhpSpreadsheetExporter()->export(
'Feuille test',
['Nom', 'Email'],
[
['Alpha', 'alpha@test.fr'],
['Beta', null],
],
);
self::assertNotSame('', $binary);
// Un fichier XLSX (OOXML) est une archive ZIP : signature "PK\x03\x04".
self::assertStringStartsWith("PK\x03\x04", $binary);
$grid = $this->grid($binary);
self::assertSame(['Nom', 'Email'], $grid[0]);
self::assertSame('Alpha', $grid[1][0]);
self::assertSame('alpha@test.fr', $grid[1][1]);
self::assertSame('Beta', $grid[2][0]);
// Cellule null a l'ecriture -> vide a la relecture.
self::assertNull($grid[2][1]);
}
public function testExportAcceptsGeneratorRows(): void
{
$rows = (static function (): Generator {
yield ['L1'];
yield ['L2'];
})();
$grid = $this->grid(new PhpSpreadsheetExporter()->export('Gen', ['H'], $rows));
self::assertSame('H', $grid[0][0]);
self::assertSame('L1', $grid[1][0]);
self::assertSame('L2', $grid[2][0]);
}
public function testLongOrInvalidSheetTitleIsSanitized(): void
{
// Titre > 31 caracteres + caracteres interdits par Excel ([ ] : * etc.).
$binary = new PhpSpreadsheetExporter()->export(
str_repeat('A', 50).'[]:*?/\\',
['H'],
[['x']],
);
$title = $this->load($binary)->getActiveSheet()->getTitle();
self::assertLessThanOrEqual(31, mb_strlen($title));
self::assertStringNotContainsString('[', $title);
self::assertStringNotContainsString(':', $title);
}
/**
* Relit le binaire XLSX et renvoie la grille de cellules (ligne 0 = entete).
*
* @return array<int, array<int, mixed>>
*/
private function grid(string $binary): array
{
return $this->load($binary)->getActiveSheet()->toArray();
}
private function load(string $binary): Spreadsheet
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp);
} finally {
@unlink($tmp);
}
}
}