From 220c0ece82fde874ed5647262c79ac308ee3a51b Mon Sep 17 00:00:00 2001 From: oversu <95a7002b-481b-4ef3-b578-f9c421550aa4@localhost> Date: Mon, 7 Sep 2026 23:55:59 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20traiter=20le=20deuxi=C3=A8me=20lot=20de?= =?UTF-8?q?=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 #2 #3 #13 #17 --- CHANGELOG.md | 10 ++++++++++ HISTORIQUE.md | 4 ++++ VERSION | 2 +- app/Controllers/AnimalRecordActions.php | 4 ++-- app/Controllers/AuthController.php | 4 ++++ app/Controllers/SettingsController.php | 15 +++++++++++++++ app/Services/Ids.php | 23 +++++++++++++++++++++++ app/Services/ImageService.php | 8 ++++++++ app/Services/InstallationService.php | 9 +++------ app/Services/SystemHealthService.php | 2 +- app/Views/animal_form.php | 2 +- app/Views/auth.php | 2 ++ app/Views/evolutions.php | 8 ++++---- app/Views/settings_general.php | 5 +++-- app/Views/settings_rooms.php | 8 ++++---- resources/lang/en.php | 1 + resources/lang/fr.php | 3 ++- tests/run.php | 7 +++++++ 18 files changed, 95 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c99eebc..ae62e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). +## [1.0.0-rc.8] — 2026-09-07 + +### Deuxième lot de tickets + +- détection correcte d’ImageMagick installé comme exécutable, même sans extension PHP (`#1`) ; +- validation du format et de la clé de contrôle des numéros RNA et SIRET (`#2`) ; +- classement de l’absence de sauvegarde comme avertissement et non comme erreur bloquante (`#3`) ; +- limites cohérentes pour les noms d’animaux, de salles et de box, côté formulaire et serveur (`#13`) ; +- reformulation de l’explication sur la revue RGPD (`#17`). + ## [1.0.0-rc.7] — 2026-09-07 ### Premier lot de tickets diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 2012319..43924b4 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -37,3 +37,7 @@ Remplacement de la police emoji par un format vectoriel accepté par les navigat ## `1.0.0-rc.7` — Premier lot de tickets Fiabilisation du géocodage et des formulaires de configuration, amélioration des tournées de soins et publication d’animaux adoptables dans le jeu de démonstration. + +## `1.0.0-rc.8` — Deuxième lot de tickets + +Clarification du diagnostic système et de la santé des sauvegardes, validation des identifiants administratifs et encadrement des longueurs de noms. diff --git a/VERSION b/VERSION index 54ed7b5..e9c62cf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.7 +1.0.0-rc.8 diff --git a/app/Controllers/AnimalRecordActions.php b/app/Controllers/AnimalRecordActions.php index 4e87a4c..450360c 100644 --- a/app/Controllers/AnimalRecordActions.php +++ b/app/Controllers/AnimalRecordActions.php @@ -491,9 +491,9 @@ trait AnimalRecordActions $felv = array_key_exists('felv', $_POST) ? (isset($_POST['felv']) ? 1 : 0) : null; // Validations minimales - if ($name === '') { + if ($name === '' || mb_strlen($name) > 150) { http_response_code(400); - echo h(t('error.name_required')); + echo h($name === '' ? t('error.name_required') : t('animal.name_too_long')); return; } if ($breed === '' && SpeciesService::isCat($species)) { diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 01d4903..29e9c9a 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -68,6 +68,10 @@ final class AuthController $error = t('install.association_required'); } elseif ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { $error = t('settings.invalid_email'); + } elseif (!Ids::validRna((string) ($_POST['association_rna'] ?? ''))) { + $error = t('settings.invalid_rna'); + } elseif (!Ids::validSiret((string) ($_POST['association_siret'] ?? ''))) { + $error = t('settings.invalid_siret'); } elseif (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username)) { $error = t('auth.invalid_username'); } elseif ($display === '') { diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 82a2dc8..64b59d6 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -46,6 +46,14 @@ final class SettingsController echo h(t('settings.invalid_email')); return; } + if (!Ids::validRna($values['association_rna'])) { + header('Location: /settings/general?error=' . rawurlencode(t('settings.invalid_rna'))); + exit(); + } + if (!Ids::validSiret($values['association_siret'])) { + header('Location: /settings/general?error=' . rawurlencode(t('settings.invalid_siret'))); + exit(); + } $values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15))); $values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10))); $values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')]) @@ -230,6 +238,7 @@ final class SettingsController $color = (string) ($row['color'] ?? '#6c757d'); if ( $name === '' || + mb_strlen($name) > 100 || !in_array($type, ['collective', 'boxes'], true) || !in_array($status, ['refuge', 'soin', 'quarantaine'], true) || !preg_match('/^#[0-9a-fA-F]{6}$/', $color) @@ -253,6 +262,9 @@ final class SettingsController $new = (array) ($_POST['new_room'] ?? []); $newName = trim((string) ($new['name'] ?? '')); if ($newName !== '') { + if (mb_strlen($newName) > 100) { + throw new RuntimeException(t('rooms.name_too_long')); + } $code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName)); if (strlen($code) < 2) { throw new RuntimeException(t('rooms.invalid')); @@ -297,6 +309,9 @@ final class SettingsController $known = $existing->fetchAll(PDO::FETCH_ASSOC); $kept = []; foreach ($names as $index => $name) { + if (mb_strlen($name) > 100) { + throw new RuntimeException(t('rooms.box_name_too_long')); + } $position = ($index + 1) * 10; if (isset($known[$index])) { $id = (int) $known[$index]['id']; diff --git a/app/Services/Ids.php b/app/Services/Ids.php index 48b56bb..0f98a94 100644 --- a/app/Services/Ids.php +++ b/app/Services/Ids.php @@ -4,6 +4,29 @@ declare(strict_types=1); final class Ids { + public static function validRna(string $value): bool + { + $value = strtoupper(preg_replace('/\s+/', '', trim($value)) ?? ''); + return $value === '' || preg_match('/^W[0-9]{9}$/', $value) === 1; + } + + public static function validSiret(string $value): bool + { + $digits = preg_replace('/\D+/', '', trim($value)) ?? ''; + if ($digits === '') { + return true; + } + if (strlen($digits) !== 14) { + return false; + } + $sum = 0; + foreach (str_split($digits) as $index => $digit) { + $number = (int) $digit * ($index % 2 === 0 ? 2 : 1); + $sum += $number > 9 ? $number - 9 : $number; + } + return $sum % 10 === 0; + } + public static function slugifyUpper(string $s): string { $s = trim($s); diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index 98571a0..e6be26d 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -6,6 +6,14 @@ final class ImageService { private const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; + public static function processor(): ?string + { + if (self::magickBinary() !== null) { + return 'ImageMagick'; + } + return extension_loaded('gd') ? 'GD' : null; + } + public static function inspect(string $path, int $maxBytes = 20_000_000): array { if (!is_file($path) || filesize($path) > $maxBytes) { diff --git a/app/Services/InstallationService.php b/app/Services/InstallationService.php index 71f2185..1d25c25 100644 --- a/app/Services/InstallationService.php +++ b/app/Services/InstallationService.php @@ -57,17 +57,14 @@ final class InstallationService ? 'Connecteurs externes disponibles' : 'Extension cURL absente : I-CAD et géocodage indisponibles', ); + $imageProcessor = ImageService::processor(); self::add( $checks, 'image', - extension_loaded('imagick') || extension_loaded('gd'), + $imageProcessor !== null, 'recommended', 'Traitement des images', - extension_loaded('imagick') - ? 'ImageMagick disponible' - : (extension_loaded('gd') - ? 'GD disponible' - : 'ImageMagick ou GD conseillé'), + $imageProcessor !== null ? $imageProcessor . ' disponible' : 'ImageMagick ou GD conseillé', ); if ($withFilesystem) { foreach (self::directories() as $key => $path) { diff --git a/app/Services/SystemHealthService.php b/app/Services/SystemHealthService.php index 4d7b3f4..acb6420 100644 --- a/app/Services/SystemHealthService.php +++ b/app/Services/SystemHealthService.php @@ -74,7 +74,7 @@ final class SystemHealthService ); $backups = BackupService::list(); if (!$backups) { - $checks[] = self::check('error', 'continuity', t('health.last_backup'), t('health.no_backup')); + $checks[] = self::check('warning', 'continuity', t('health.last_backup'), t('health.no_backup')); } else { $latest = $backups[0]; $date = DateTimeImmutable::createFromFormat( diff --git a/app/Views/animal_form.php b/app/Views/animal_form.php index caf7891..26a9353 100644 --- a/app/Views/animal_form.php +++ b/app/Views/animal_form.php @@ -95,7 +95,7 @@ $estMonths = $birth_estimated_months > 0 ? ($birth_estimated_months % 12) : '';
+ maxlength="150" required>
diff --git a/app/Views/auth.php b/app/Views/auth.php index 747e648..b62ff5c 100644 --- a/app/Views/auth.php +++ b/app/Views/auth.php @@ -109,9 +109,11 @@ value="" required>
- 1.0.0-rc.7 -

Premier lot de tickets

-

Géocodage fiabilisé, doublons expliqués, tournées plus lisibles et animaux - adoptables publiés dans la démonstration.

+ 1.0.0-rc.8 +

Deuxième lot de tickets

+

Diagnostic système clarifié, identifiants administratifs contrôlés et + longueurs des principaux noms encadrées.

diff --git a/app/Views/settings_general.php b/app/Views/settings_general.php index 1e9af8f..91b8eff 100644 --- a/app/Views/settings_general.php +++ b/app/Views/settings_general.php @@ -19,9 +19,10 @@ required>
+ pattern="W[0-9]{9}" maxlength="10" placeholder="W123456789">
+ class="form-control" name="association_siret" inputmode="numeric" maxlength="17" + value="">
+ value="" maxlength="100" required>
@@ -40,7 +40,7 @@ value="">
+ placeholder="" data-line-maxlength="100">
diff --git a/resources/lang/en.php b/resources/lang/en.php index 52f2f6f..cd8aaf2 100644 --- a/resources/lang/en.php +++ b/resources/lang/en.php @@ -122,4 +122,5 @@ return [ 'public_site.settings_heading'=>'Public website','public_site.theme_custom'=>'Custom','public_site.site_name'=>'Website name','public_site.custom_css'=>'Custom CSS','public_site.custom_css_help'=>'Used by the custom theme. Imports and external resources are blocked.','public_site.invalid_css'=>'The stylesheet contains a forbidden instruction.','public_site.routing_management_help'=>'Management stays at / and the public website at /site.','public_site.publish_animal'=>'Publish this animal on the public website','public_site.publish_animal_help'=>'The profile will only be visible when the animal is also available for adoption. Only public photos and the text below are used.','lab_reference.add_species'=>'Add a species to lab reports','lab_reference.copy_from'=>'Copy reference ranges from','lab_reference.target_species'=>'New species','lab_reference.copy_help'=>'Ranges are copied as a starting point and remain editable.','lab_reference.invalid_species'=>'Choose two different valid species.','lab_reference.nothing_copied'=>'This species already has these reference ranges.','statistics.hair_types'=>'Hair types','animals.filtered_by'=>'Filter: :value','service.icad.home_unavailable'=>'The I-CAD website is unavailable','service.icad.search_unavailable'=>'I-CAD search is unavailable','service.icad.record_unavailable'=>'The I-CAD record is unavailable', 'public_site.image_library'=>'Image library','public_site.image_file'=>'Image to add','public_site.image_upload'=>'Upload image','public_site.image_help'=>'Upload an image, place the cursor in a text field, then choose “Insert”. Accepted formats: JPEG, PNG and WebP.','public_site.image_none'=>'No image uploaded.','public_site.image_insert'=>'Insert','public_site.image_invalid'=>'Choose a valid image.', 'rooms.duplicate'=>'A room with this name or code already exists.','settings.species_duplicate'=>'A species with this name or code already exists.', + 'settings.invalid_rna'=>'The RNA number must start with W followed by 9 digits.','settings.invalid_siret'=>'The SIRET number must contain 14 digits and be valid.','rooms.name_too_long'=>'A room name cannot exceed 100 characters.','rooms.box_name_too_long'=>'A box name cannot exceed 100 characters.','animal.name_too_long'=>'An animal name cannot exceed 150 characters.', ]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php index 3c8d71f..a898da4 100644 --- a/resources/lang/fr.php +++ b/resources/lang/fr.php @@ -5,7 +5,7 @@ return [ 'error.unexpected_reference'=>'Une erreur interne est survenue. Référence : :reference.', 'auth.remember_device'=>'Rester connecté sur cet appareil','auth.remember_device_help'=>'Une session révocable sera conservée pendant 90 jours. Le mot de passe n’est jamais enregistré.','devices.title'=>'Appareils connectés','devices.intro'=>'Sessions persistantes autorisées pour votre compte. Révoquez immédiatement un appareil perdu ou partagé.','devices.revoke_all'=>'Tout déconnecter','devices.revoke_all_confirm'=>'Déconnecter tous les appareils, y compris celui-ci ?','devices.revoked'=>'Appareil déconnecté.','devices.this_device'=>'Cet appareil','devices.active'=>'Actif','devices.inactive'=>'Révoqué ou expiré','devices.created'=>'Ajouté le :date','devices.last_used'=>'Dernière utilisation : :date','devices.expires'=>'Expiration : :date','devices.revoke'=>'Révoquer','devices.none'=>'Aucun appareil de confiance enregistré.', 'public_site.to_complete'=>'À compléter','public_site.default_legal'=>"Éditeur du site\n\nLe présent site est édité par :association, association déclarée sous le numéro RNA :rna et identifiée sous le numéro SIRET :siret.\nSiège social : :address\nContact : :email\n\nDirection de la publication\n\nLa direction de la publication est assurée par la représentante ou le représentant légal de l’association. Nom : [À compléter].\n\nHébergement\n\nHébergeur : [raison sociale, adresse et coordonnées à compléter].\n\nPropriété intellectuelle\n\nSauf mention contraire, les textes, photographies et éléments graphiques de ce site appartiennent à l’association ou sont utilisés avec l’autorisation de leurs titulaires. Toute réutilisation doit respecter les droits applicables et l’image des animaux et des personnes présentées.\n\nResponsabilité\n\nL’association s’efforce de fournir des informations exactes et à jour. Les disponibilités et caractéristiques des animaux peuvent évoluer ; leur publication ne vaut pas acceptation automatique d’une adoption.",'public_site.default_privacy'=>"Responsable du traitement\n\nLe responsable du traitement est :association.\nAdresse : :address\nContact pour l’exercice de vos droits : :email\n\nDonnées concernées et finalités\n\nLe site public présente les animaux proposés à l’adoption et les moyens de contacter l’association. Lorsque vous contactez l’association, les informations que vous transmettez peuvent être utilisées pour répondre à votre demande, étudier une adoption, gérer un don ou assurer le suivi administratif correspondant.\n\nDestinataires et conservation\n\nLes données sont accessibles aux membres autorisés de l’association et, lorsque cela est nécessaire, à ses partenaires habilités. Elles ne sont pas vendues. Elles sont conservées pendant la durée nécessaire à la finalité poursuivie et aux obligations légales ou comptables applicables, puis supprimées ou anonymisées selon la politique de l’association.\n\nCookies et services externes\n\nLe site public n’utilise pas de dispositif publicitaire ou de mesure d’audience intégré par Globinours. La carte de contact utilise les tuiles OpenStreetMap ; son affichage entraîne une connexion aux serveurs cartographiques concernés et la transmission de données techniques telles que l’adresse IP. La partie de gestion utilise un cookie de session indispensable à l’authentification.\n\nVos droits\n\nVous pouvez demander l’accès, la rectification, l’effacement ou la limitation de vos données et, lorsque les conditions sont réunies, vous opposer au traitement ou demander leur portabilité. Adressez votre demande à :email. Vous pouvez également saisir l’autorité de contrôle compétente, notamment la CNIL en France.\n\nMise à jour\n\nCette politique doit être adaptée aux traitements réellement effectués par l’association et mise à jour lorsqu’ils évoluent.", - 'privacy.heading'=>'Confidentialité et conservation','privacy.no_auto_delete'=>'Aucune suppression automatique : la liste sert à une revue humaine.','privacy.reason'=>'Motif','privacy.none_review'=>'Aucun contact à réexaminer.','privacy.access_register'=>'Registre des accès privés','privacy.contact_not_found'=>'Contact introuvable.','privacy.export_failed'=>'Impossible de produire l’export.','privacy.reason_required'=>'Le motif est obligatoire.','privacy.anonymous_contact'=>'Contact anonymisé', + 'privacy.heading'=>'Confidentialité et conservation','privacy.no_auto_delete'=>'Aucune suppression n’est effectuée automatiquement. Cette liste vous aide à identifier les dossiers à examiner.','privacy.reason'=>'Motif','privacy.none_review'=>'Aucun contact à réexaminer.','privacy.access_register'=>'Registre des accès privés','privacy.contact_not_found'=>'Contact introuvable.','privacy.export_failed'=>'Impossible de produire l’export.','privacy.reason_required'=>'Le motif est obligatoire.','privacy.anonymous_contact'=>'Contact anonymisé', 'settings.privacy'=>'Confidentialité / RGPD','privacy.title'=>'Confidentialité et conservation','privacy.intro'=>'Pilotez la conservation des coordonnées, les exports et les accès aux fichiers privés.','privacy.saved'=>'Politique de conservation enregistrée.','privacy.retention'=>'Durée de conservation','privacy.retention_years'=>'Durée après la dernière activité (années)','privacy.retention_help'=>'Cette règle produit une liste de contrôle : aucune donnée n’est supprimée automatiquement.','privacy.review'=>'Contacts à réexaminer','privacy.no_review'=>'Aucun contact ne dépasse actuellement cette durée.','privacy.last_activity'=>'Dernière activité','privacy.export'=>'Exporter','privacy.anonymize'=>'Anonymiser','privacy.anonymize_reason'=>'Motif obligatoire','privacy.anonymize_confirm'=>'Anonymiser ce contact ? Une sauvegarde sera créée avant l’opération.','privacy.access_log'=>'Accès aux fichiers privés','privacy.no_access'=>'Aucun accès enregistré.','privacy.resource'=>'Ressource','privacy.actor'=>'Utilisateur','privacy.action'=>'Action','privacy.date'=>'Date','privacy.anonymized'=>'Contact anonymisé. Sauvegarde : :backup','public_site.legal'=>'Mentions légales','public_site.privacy'=>'Confidentialité','public_site.legal_privacy'=>'Mentions légales et confidentialité','public_site.legal_hint'=>'Ces textes sont publiés sur deux pages dédiées. Faites-les valider selon la situation de votre association.', 'settings.group_general'=>'Configuration','settings.group_business'=>'Référentiels métier','settings.group_access'=>'Accès','settings.group_maintenance'=>'Maintenance','public_site.main_address'=>'Page affichée à l’adresse principale','public_site.routing_management'=>'Globinours à la racine','public_site.routing_management_help'=>'La gestion reste sur / et le mini-site sur /site.','public_site.routing_public'=>'Site public à la racine','public_site.routing_public_help'=>'Le site utilise / et l’accès à la gestion se fait par /gestion.','public_site.routing_no_server'=>'Ce choix est géré par Globinours : aucune modification Apache ou Nginx n’est nécessaire.', 'public_site.published'=>'Publié sur le site','public_site.waiting_publication'=>'Publication en attente de disponibilité', @@ -122,4 +122,5 @@ return [ 'public_site.settings_heading'=>'Site public','public_site.theme_custom'=>'Personnalisé','public_site.site_name'=>'Nom du site','public_site.custom_css'=>'CSS personnalisé','public_site.custom_css_help'=>'Actif avec le thème personnalisé. Les imports et les ressources externes sont bloqués.','public_site.invalid_css'=>'La feuille de style contient une instruction interdite.','public_site.routing_management_help'=>'La gestion reste sur / et le site public sur /site.','public_site.publish_animal'=>'Publier cet animal sur le site public','public_site.publish_animal_help'=>'La fiche ne sera visible que si l’animal est également disponible à l’adoption. Seules les photos publiques et le texte ci-dessous seront utilisés.','lab_reference.add_species'=>'Ajouter une espèce aux bilans','lab_reference.copy_from'=>'Copier les références de','lab_reference.target_species'=>'Nouvelle espèce','lab_reference.copy_help'=>'Les intervalles sont copiés comme point de départ, puis restent modifiables.','lab_reference.invalid_species'=>'Choisissez deux espèces différentes et valides.','lab_reference.nothing_copied'=>'Cette espèce possède déjà ces références.','statistics.hair_types'=>'Types de poil','animals.filtered_by'=>'Filtre : :value','service.icad.home_unavailable'=>'Le site I-CAD est indisponible','service.icad.search_unavailable'=>'La recherche I-CAD est indisponible','service.icad.record_unavailable'=>'La fiche I-CAD est indisponible', 'public_site.image_library'=>'Bibliothèque d’images','public_site.image_file'=>'Image à ajouter','public_site.image_upload'=>'Ajouter l’image','public_site.image_help'=>'Ajoutez une image, placez le curseur dans un texte puis choisissez « Insérer ». Formats acceptés : JPEG, PNG et WebP.','public_site.image_none'=>'Aucune image ajoutée.','public_site.image_insert'=>'Insérer','public_site.image_invalid'=>'Choisissez une image valide.', 'rooms.duplicate'=>'Une salle portant ce nom ou ce code existe déjà.','settings.species_duplicate'=>'Une espèce portant ce nom ou ce code existe déjà.', + 'settings.invalid_rna'=>'Le numéro RNA doit commencer par W et être suivi de 9 chiffres.','settings.invalid_siret'=>'Le numéro SIRET doit contenir 14 chiffres et être valide.','rooms.name_too_long'=>'Le nom d’une salle ne peut pas dépasser 100 caractères.','rooms.box_name_too_long'=>'Le nom d’un box ne peut pas dépasser 100 caractères.','animal.name_too_long'=>'Le nom d’un animal ne peut pas dépasser 150 caractères.', ]; diff --git a/tests/run.php b/tests/run.php index 1dacc97..060fa18 100644 --- a/tests/run.php +++ b/tests/run.php @@ -11,6 +11,7 @@ foreach ( [ 'DB', 'Migrations', + 'Ids', 'I18n', 'AppSettings', 'SecurityService', @@ -116,6 +117,12 @@ try { $assert(AppSettings::get('app_language') === 'en'); I18n::setLocale('fr'); }); + $test('Les identifiants RNA et SIRET sont contrôlés', function () use ($assert): void { + $assert(Ids::validRna('W123456789')); + $assert(!Ids::validRna('W1234')); + $assert(Ids::validSiret('73282932000074')); + $assert(!Ids::validSiret('73282932000075')); + }); $test('Les quatre bilans biologiques existent pour chats et chiens', function () use ($db, $assert): void { $rows = $db ->query(