From e40e0539506e3dceb4ac68e62a1af1f2103219a5 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 9 Jun 2026 10:31:34 +0200 Subject: [PATCH] feat(commercial) : referentiel pays (country) en base + branchement front (ERP-116) Cree la table country (referentiel statique : code ISO alpha-2, name, position) seedee avec 6 pays (France, Allemagne, Belgique, Espagne, Italie, Royaume-Uni), exposee en lecture seule via /api/countries (GetCollection + Get, gating aligne sur Bank). Perimetre minimal volontaire : aucune longueur bancaire/fiscale a ce stade (iteration ulterieure du ticket). Front : la liste des pays jusqu'ici codee en dur dans les 3 ecrans clients est remplacee par le referentiel charge via useClientReferentials (value = nom du pays, l'adresse continuant de stocker country en chaine libre : pas de FK ni de migration de donnees). Consultation : options derivees de l'embed. Garde-fous : country ajoute a ColumnCommentsCatalog + whitelist EntitiesAreTimestampableBlamableTest ; tests API dedies (200/seed/405/403/401). --- .../__tests__/useClientReferentials.spec.ts | 6 + .../composables/useClientReferentials.ts | 15 ++- .../commercial/pages/clients/[id]/edit.vue | 8 +- .../commercial/pages/clients/[id]/index.vue | 16 ++- .../modules/commercial/pages/clients/new.vue | 9 +- migrations/Version20260609100000.php | 101 ++++++++++++++++ .../Commercial/Domain/Entity/Country.php | 109 ++++++++++++++++++ .../Repository/CountryRepositoryInterface.php | 19 +++ .../CommercialReferentialFixtures.php | 46 +++++++- .../Doctrine/DoctrineCountryRepository.php | 36 ++++++ .../Database/ColumnCommentsCatalog.php | 8 ++ .../EntitiesAreTimestampableBlamableTest.php | 4 + .../Commercial/Api/ReferentialApiTest.php | 66 +++++++++++ 13 files changed, 426 insertions(+), 17 deletions(-) create mode 100644 migrations/Version20260609100000.php create mode 100644 src/Module/Commercial/Domain/Entity/Country.php create mode 100644 src/Module/Commercial/Domain/Repository/CountryRepositoryInterface.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrineCountryRepository.php diff --git a/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts index 3d92495..8508cdc 100644 --- a/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts +++ b/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts @@ -30,6 +30,10 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => { if (url === '/sites') { return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] }) } + if (url === '/countries') { + // Pays : value === label === name (l'adresse stocke le nom). + return Promise.resolve({ member: [{ '@id': '/api/countries/1', code: 'FR', name: 'France' }] }) + } return Promise.resolve({ member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }], }) @@ -44,6 +48,8 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => { expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }]) expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) + // Pays : value = nom du pays (et non l'IRI). + expect(refs.countries.value).toEqual([{ value: 'France', label: 'France' }]) // Seul le select en echec reste vide. expect(refs.categories.value).toEqual([]) diff --git a/frontend/modules/commercial/composables/useClientReferentials.ts b/frontend/modules/commercial/composables/useClientReferentials.ts index 139763b..8e90fb6 100644 --- a/frontend/modules/commercial/composables/useClientReferentials.ts +++ b/frontend/modules/commercial/composables/useClientReferentials.ts @@ -3,7 +3,7 @@ import { ref } from 'vue' /** * Charge les referentiels (listes courtes) alimentant les selects de l'ecran * « Ajouter un client » : categories, sites, modes de TVA, delais et types de - * reglement, banques, et les listes distributeurs / courtiers. + * reglement, banques, pays, et les listes distributeurs / courtiers. * * Toutes les collections sont recuperees en entier via l'echappatoire prevue * `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec @@ -57,6 +57,11 @@ interface ClientMember extends HydraMember { companyName: string } +interface CountryMember extends HydraMember { + code: string + name: string +} + const LD_JSON_HEADERS = { Accept: 'application/ld+json' } export function useClientReferentials() { @@ -68,6 +73,7 @@ export function useClientReferentials() { const paymentDelays = ref([]) const paymentTypes = ref([]) const banks = ref([]) + const countries = ref([]) const distributors = ref([]) const brokers = ref([]) @@ -116,6 +122,12 @@ export function useClientReferentials() { .then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }), fetchAll('/banks') .then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }), + // Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI), + // car l'adresse stocke `country` en chaine libre (« France »...). On + // conserve ainsi la compatibilite avec les adresses existantes sans FK + // ni migration de donnees a ce stade. value === label. + fetchAll('/countries') + .then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }), ]) } @@ -144,6 +156,7 @@ export function useClientReferentials() { paymentDelays, paymentTypes, banks, + countries, distributors, brokers, loadCommon, diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 0a2802c..1352a3e 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -554,10 +554,10 @@ const contactOptions = computed(() => })), ) -const countryOptions: RefOption[] = [ - { value: 'France', label: 'France' }, - { value: 'Espagne', label: 'Espagne' }, -] +// Pays : referentiel `country` charge via l'API (ERP-116), en remplacement de +// l'ancienne liste codee en dur. Valeur = nom du pays (l'adresse stocke +// `country` en chaine libre, donc value === label). +const countryOptions = computed(() => referentials.countries.value) const relationOptions = computed(() => [ { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue index a0e3017..51c3925 100644 --- a/frontend/modules/commercial/pages/clients/[id]/index.vue +++ b/frontend/modules/commercial/pages/clients/[id]/index.vue @@ -384,10 +384,18 @@ const relationOptions = computed(() => [ { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, ]) -const countryOptions: SelectOption[] = [ - { value: 'France', label: 'France' }, - { value: 'Espagne', label: 'Espagne' }, -] +// Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via +// GET /countries, sur le meme principe que les autres selects de consultation +// — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel +// quel dans l'adresse, donc value === label ; suffit a afficher le libelle en +// lecture seule. +const countryOptions = computed(() => + [...new Set( + (client.value?.addresses ?? []) + .map(a => a.country) + .filter((c): c is string => !!c), + )].map(c => ({ value: c, label: c })), +) // Selects comptables : libelle issu de l'embed (option unique ou vide). const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode)) diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index feff5c6..b2e9083 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -783,11 +783,10 @@ const contactOptions = computed(() => })), ) -// Pays disponibles (France preselectionnee par defaut sur chaque adresse). -const countryOptions: RefOption[] = [ - { value: 'France', label: 'France' }, - { value: 'Espagne', label: 'Espagne' }, -] +// Pays disponibles : referentiel `country` charge via l'API (ERP-116), en +// remplacement de l'ancienne liste codee en dur. France reste preselectionnee +// par defaut sur chaque adresse (cf. valeur initiale du draft d'adresse). +const countryOptions = computed(() => referentials.countries.value) // Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email // facturation si Facturation) sur chaque adresse. diff --git a/migrations/Version20260609100000.php b/migrations/Version20260609100000.php new file mode 100644 index 0000000..8b1f0b1 --- /dev/null +++ b/migrations/Version20260609100000.php @@ -0,0 +1,101 @@ +addSql(<<<'SQL' + CREATE TABLE country ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + code VARCHAR(2) NOT NULL, + name VARCHAR(80) NOT NULL, + position INT DEFAULT 0 NOT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE UNIQUE INDEX uq_country_code ON country (code)'); + + $this->comment('country', '_table', 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).'); + $this->comment('country', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('country', 'code', 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.'); + $this->comment('country', 'name', 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).'); + $this->comment('country', 'position', 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).'); + + // Seed initial. France en tete (position 10) puis ordre alphabetique. + // Table fraichement creee, mais ON CONFLICT pour rejouabilite en prod. + $this->addSql(<<<'SQL' + INSERT INTO country (code, name, position) VALUES + ('FR', 'France', 10), + ('DE', 'Allemagne', 20), + ('BE', 'Belgique', 30), + ('ES', 'Espagne', 40), + ('IT', 'Italie', 50), + ('GB', 'Royaume-Uni', 60) + ON CONFLICT (code) DO NOTHING + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE country'); + } + + /** + * Pose un `COMMENT ON TABLE` (colonne speciale `_table`) ou + * `COMMENT ON COLUMN`. Quoting defensif des identifiants + delimiteur $_$ + * pour ne pas casser sur les apostrophes des descriptions. + */ + private function comment(string $table, string $column, string $description): void + { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + return; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Module/Commercial/Domain/Entity/Country.php b/src/Module/Commercial/Domain/Entity/Country.php new file mode 100644 index 0000000..8773e3b --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/Country.php @@ -0,0 +1,109 @@ + 405. + * Permission alignee sur Bank (referentiel d'adresse partage clients/fournisseurs). + * Pas de Timestampable/Blamable (referentiel statique whiteliste dans + * EntitiesAreTimestampableBlamableTest::EXCLUDED, comme Bank). + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')", + normalizationContext: ['groups' => ['country:read']], + // Tri par defaut : position ASC (France en tete) puis name ASC. + order: ['position' => 'ASC', 'name' => 'ASC'], + // Toggle ?pagination=false pour alimenter le select (cf. Bank). + paginationClientEnabled: true, + ), + new Get( + security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')", + normalizationContext: ['groups' => ['country:read']], + ), + ], + security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')", +)] +#[ORM\Entity(repositoryClass: DoctrineCountryRepository::class)] +#[ORM\Table(name: 'country')] +#[ORM\UniqueConstraint(name: 'uq_country_code', columns: ['code'])] +class Country +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['country:read'])] + private ?int $id = null; + + #[ORM\Column(length: 2)] + #[Groups(['country:read'])] + private ?string $code = null; + + #[ORM\Column(length: 80)] + #[Groups(['country:read'])] + private ?string $name = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['country:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Repository/CountryRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/CountryRepositoryInterface.php new file mode 100644 index 0000000..3897677 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/CountryRepositoryInterface.php @@ -0,0 +1,19 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php index 41dc6b4..c933f83 100644 --- a/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php +++ b/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\Commercial\Infrastructure\DataFixtures; use App\Module\Commercial\Domain\Entity\Bank; +use App\Module\Commercial\Domain\Entity\Country; use App\Module\Commercial\Domain\Entity\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\TvaMode; @@ -14,10 +15,11 @@ use Doctrine\Persistence\ObjectManager; /** * Fixtures du module Commercial : re-seed des 4 referentiels comptables * (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1 - * (Version20260601000000). + * (Version20260601000000) + du referentiel pays (country) seede par la + * migration ERP-116 (Version20260609100000). * - * Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces - * 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les + * Pourquoi cette fixture EN PLUS du seed de la migration : ces tables sont des + * entites managees par l'ORM, donc le purger Doctrine les * vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les * referentiels seedes par la migration disparaitraient apres `make db-reset` * (0 ligne en dev/test) — cassant les FK Client -> referentiels et les tests @@ -59,15 +61,53 @@ class CommercialReferentialFixtures extends Fixture ], ]; + /** + * Referentiel pays (ERP-116) : code ISO alpha-2 => [name, position]. + * Doit rester aligne sur le seed de la migration Version20260609100000. + * Traite a part car Country porte `name` (et non `label`). + * + * @var array + */ + private const COUNTRIES = [ + 'FR' => ['France', 10], + 'DE' => ['Allemagne', 20], + 'BE' => ['Belgique', 30], + 'ES' => ['Espagne', 40], + 'IT' => ['Italie', 50], + 'GB' => ['Royaume-Uni', 60], + ]; + public function load(ObjectManager $manager): void { foreach (self::REFERENTIALS as $entityClass => $rows) { $this->seedReferential($manager, $entityClass, $rows); } + $this->seedCountries($manager); + $manager->flush(); } + /** + * Upsert idempotent du referentiel pays (lookup par code). Distinct de + * seedReferential car Country utilise setName au lieu de setLabel. + */ + private function seedCountries(ObjectManager $manager): void + { + $existingByCode = []; + foreach ($manager->getRepository(Country::class)->findAll() as $country) { + $existingByCode[$country->getCode()] = $country; + } + + foreach (self::COUNTRIES as $code => [$name, $position]) { + $country = $existingByCode[$code] ?? new Country(); + $country->setCode($code); + $country->setName($name); + $country->setPosition($position); + $manager->persist($country); + } + } + /** * Upsert idempotent d'un referentiel : indexe l'existant par code puis * cree/met a jour chaque entree. Les 4 entites partagent le meme contrat diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineCountryRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineCountryRepository.php new file mode 100644 index 0000000..6d4371f --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineCountryRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrineCountryRepository extends ServiceEntityRepository implements CountryRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Country::class); + } + + public function findById(int $id): ?Country + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('c') + ->orderBy('c.position', 'ASC') + ->addOrderBy('c.name', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 0c85547..3834024 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -170,6 +170,14 @@ final class ColumnCommentsCatalog 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', ], + 'country' => [ + '_table' => 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.', + 'name' => 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).', + 'position' => 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).', + ], + 'client' => [ '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', 'id' => 'Identifiant interne auto-incremente.', diff --git a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php index 0b8fb17..605cf7f 100644 --- a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php +++ b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php @@ -6,6 +6,7 @@ namespace App\Tests\Architecture; use App\Module\Catalog\Domain\Entity\CategoryType; use App\Module\Commercial\Domain\Entity\Bank; +use App\Module\Commercial\Domain\Entity\Country; use App\Module\Commercial\Domain\Entity\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\TvaMode; @@ -58,6 +59,8 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase * CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de * tracabilite user-driven, meme justification que CategoryType. Cf. * spec-back M1 § 2.6 + § 3.5. + * - Country (ERP-116) : referentiel statique des pays (id/code/name/position), + * seede par migration, lecture seule. Meme justification que Bank. * * Les futurs referentiels statiques s'ajoutent ici avec une justification. */ @@ -71,6 +74,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase PaymentDelay::class, PaymentType::class, Bank::class, + Country::class, ]; public function testAllBusinessEntitiesImplementBothInterfaces(): void diff --git a/tests/Module/Commercial/Api/ReferentialApiTest.php b/tests/Module/Commercial/Api/ReferentialApiTest.php index 48f28c9..1da4ef3 100644 --- a/tests/Module/Commercial/Api/ReferentialApiTest.php +++ b/tests/Module/Commercial/Api/ReferentialApiTest.php @@ -241,6 +241,72 @@ final class ReferentialApiTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(401); } + /** + * Referentiel pays (ERP-116) — teste a part des 4 referentiels comptables + * car il expose `name` (et non `label`). Memes garanties : 200 + seed des 6 + * pays, France en tete (position ASC), lecture seule (405), gating (403/401). + */ + public function testCountriesCollectionReturns200WithSeed(): void + { + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]]); + + self::assertResponseStatusCodeSame(200); + + $members = $response->toArray()['member']; + $codes = array_map(static fn (array $m): string => $m['code'], $members); + + foreach (['FR', 'DE', 'BE', 'ES', 'IT', 'GB'] as $expected) { + self::assertContains($expected, $codes, '/api/countries doit exposer le pays seede '.$expected); + } + + // Le DTO de lecture expose id / code / name / position. + $first = $members[0]; + self::assertArrayHasKey('id', $first); + self::assertArrayHasKey('name', $first); + self::assertArrayHasKey('position', $first); + // Tri par defaut position ASC : France (position 10) en tete. + self::assertSame('FR', $first['code'], 'France (FR) doit etre en tete (position 10, tri position ASC).'); + } + + public function testCountriesGetItemReturns200(): void + { + $client = $this->createAdminClient(); + + $first = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]]) + ->toArray()['member'][0] + ; + + $client->request('GET', '/api/countries/'.$first['id'], ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + } + + public function testCountriesPostReturns405(): void + { + $client = $this->createAdminClient(); + $client->request('POST', '/api/countries', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['code' => 'XX', 'name' => 'Pays X', 'position' => 1], + ]); + self::assertResponseStatusCodeSame(405); + } + + public function testCountriesForbiddenWithoutPermission(): void + { + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + } + + public function testCountriesUnauthorizedWhenAnonymous(): void + { + $client = self::createClient(); + $client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(401); + } + /** * @return iterable}> */