634 lines
33 KiB
PHP
634 lines
33 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
ob_start();
|
||
|
||
$root = dirname(__DIR__);
|
||
$tmp = sys_get_temp_dir() . '/globinours-tests-' . bin2hex(random_bytes(5)) . '.sqlite';
|
||
putenv('GLOBINOURS_DB_PATH=' . $tmp);
|
||
putenv('GLOBINOURS_BACKUP_DATA_ONLY=1');
|
||
foreach (
|
||
[
|
||
'DB',
|
||
'Migrations',
|
||
'Ids',
|
||
'I18n',
|
||
'AppSettings',
|
||
'SecurityService',
|
||
'Auth',
|
||
'TrustedDeviceService',
|
||
'PermissionService',
|
||
'AuditService',
|
||
'BackupService',
|
||
'InstallationService',
|
||
'InventoryService',
|
||
'AgendaService',
|
||
'CalendarFeedService',
|
||
'NotificationService',
|
||
'PrivacyService',
|
||
'GeocodeService',
|
||
'LocationHistoryService',
|
||
'AdoptionService',
|
||
'StatisticsService',
|
||
'PricingService',
|
||
'SpeciesService',
|
||
'LabReferenceService',
|
||
'PublicContentFormatter',
|
||
'ShelterRoomService',
|
||
'GlobalSearchService',
|
||
'PrivateMediaService',
|
||
'DemoDataService',
|
||
]
|
||
as $class
|
||
) {
|
||
require_once $root . '/app/Services/' . $class . '.php';
|
||
}
|
||
$passed = 0;
|
||
$failed = 0;
|
||
$createdBackup = null;
|
||
$test = static function (string $name, callable $callback) use (&$passed, &$failed): void {
|
||
try {
|
||
$callback();
|
||
echo "✅ $name\n";
|
||
$passed++;
|
||
} catch (Throwable $e) {
|
||
echo "❌ $name — {$e->getMessage()}\n";
|
||
$failed++;
|
||
}
|
||
};
|
||
$assert = static function (bool $condition, string $message = 'Assertion échouée'): void {
|
||
if (!$condition) {
|
||
throw new RuntimeException($message);
|
||
}
|
||
};
|
||
try {
|
||
Migrations::run($root . '/migrations');
|
||
I18n::setLocale('fr');
|
||
$db = DB::pdo();
|
||
Auth::start();
|
||
$test('Toutes les migrations sont appliquées', function () use ($db, $root, $assert): void {
|
||
$expected = count(glob($root . '/migrations/*.sql') ?: []);
|
||
$actual = (int) $db->query('SELECT COUNT(*) FROM _migrations')->fetchColumn();
|
||
$assert($actual === $expected, "$actual/$expected migrations");
|
||
});
|
||
$test('Les tournées peuvent enregistrer une température', function () use ($db, $assert): void {
|
||
$columns = array_column(
|
||
$db->query('PRAGMA table_info(care_round_observations)')->fetchAll(PDO::FETCH_ASSOC),
|
||
'name',
|
||
);
|
||
$assert(in_array('temperature_c', $columns, true), 'Colonne temperature_c absente');
|
||
});
|
||
$test('La création d’une portée ne dépend pas d’un téléversement', function () use ($assert): void {
|
||
$relationships = file_get_contents(__DIR__ . '/../app/Controllers/AnimalRelationshipActions.php');
|
||
$animals = file_get_contents(__DIR__ . '/../app/Controllers/AnimalsController.php');
|
||
$assert(!str_contains($relationships, 'PrivateMediaService::moveVisibility'), 'Bloc de photo résiduel dans les portées');
|
||
$assert(
|
||
str_contains($animals, 'animal_id, action, type, label, details, user_id'),
|
||
'L’historique des portées ne renseigne pas son action',
|
||
);
|
||
});
|
||
$test('Les médias publics restent accessibles au serveur web', function () use ($assert): void {
|
||
$security = file_get_contents(__DIR__ . '/../app/Services/SecurityService.php');
|
||
$images = file_get_contents(__DIR__ . '/../app/Services/ImageService.php');
|
||
$updates = file_get_contents(__DIR__ . '/../app/Services/UpdateService.php');
|
||
$assert(str_contains($security, "@chmod(\$root . '/data', 0710)"), 'La racine des données ne reste pas traversable');
|
||
$assert(str_contains($security, "hardenPublicTree(\$root . '/public/media')"), 'Les médias publics ne sont pas réparés');
|
||
$assert(str_contains($images, 'preparePublicDirectory($directory)'), 'Les nouveaux dossiers publics ne sont pas préparés');
|
||
$assert(str_contains($updates, "normalizePublicCodePermissions(\$root . '/public')"), 'Le code public n’est pas normalisé après mise à jour');
|
||
});
|
||
$test("Les voies d’administration des médicaments sont initialisées", function () use ($db, $assert): void {
|
||
$routes = $db
|
||
->query('SELECT accro FROM ref_med_administration ORDER BY id')
|
||
->fetchAll(PDO::FETCH_COLUMN);
|
||
$assert(count($routes) === 14, 'Référentiel des voies incomplet');
|
||
$assert(in_array('PO', $routes, true) && in_array('SC', $routes, true) && in_array('IV', $routes, true));
|
||
$historyColumns = array_column(
|
||
$db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC),
|
||
'name',
|
||
);
|
||
foreach (['action', 'field', 'old_value', 'new_value', 'meta', 'type', 'label', 'details', 'user_id'] as $column) {
|
||
$assert(in_array($column, $historyColumns, true), 'Colonne historique absente : ' . $column);
|
||
}
|
||
$assert(
|
||
(int) $db->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='icad_cache'")->fetchColumn() === 1,
|
||
'Cache i-CAD absent',
|
||
);
|
||
});
|
||
$test('Une installation vierge initialise les médias privés', function () use ($db, $assert): void {
|
||
$columns = array_column($db->query('PRAGMA table_info(animal_photos)')->fetchAll(PDO::FETCH_ASSOC), 'name');
|
||
$assert(in_array('is_public', $columns, true), 'Colonne is_public absente');
|
||
$assert(
|
||
(int) $db
|
||
->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='medical_photos'")
|
||
->fetchColumn() === 1,
|
||
'Table medical_photos absente',
|
||
);
|
||
PrivateMediaService::migrateLegacy();
|
||
});
|
||
$test('La base SQLite temporaire est intègre', function () use ($db, $assert): void {
|
||
$assert($db->query('PRAGMA integrity_check')->fetchColumn() === 'ok');
|
||
});
|
||
$test('La configuration initiale des salles est complète', function () use ($assert): void {
|
||
$rooms = ShelterRoomService::byCode(false);
|
||
foreach (['silver', 'twix', 'care', 'quarantine'] as $code) {
|
||
$assert(isset($rooms[$code]), 'Salle absente : ' . $code);
|
||
}
|
||
$assert($rooms['silver']['name'] === 'Salle collective 1');
|
||
$assert($rooms['twix']['name'] === 'Salle collective 2');
|
||
$assert(ShelterRoomService::periods($rooms['silver']) === ['morning', 'evening']);
|
||
$assert(ShelterRoomService::accepts($rooms['silver'], ['species' => 'chien', 'sex' => 'F', 'fiv' => 0]));
|
||
$assert(
|
||
count($rooms['care']['boxes']) === 5 && count($rooms['quarantine']['boxes']) === 5,
|
||
'Les cinq box initiaux ne sont pas disponibles',
|
||
);
|
||
});
|
||
$test('Les tournées acceptent quatre créneaux configurables', function () use ($db, $assert): void {
|
||
foreach (ShelterRoomService::PERIODS as $period) {
|
||
$db->prepare('INSERT INTO care_rounds(round_date,period) VALUES(?,?)')->execute(['2099-01-01', $period]);
|
||
}
|
||
$assert((int) $db->query("SELECT COUNT(*) FROM care_rounds WHERE round_date='2099-01-01'")->fetchColumn() === 4);
|
||
$db->exec("DELETE FROM care_rounds WHERE round_date='2099-01-01'");
|
||
});
|
||
$test('Les critères d’accueil d’une salle sont appliqués', function () use ($assert): void {
|
||
$room = ['allowed_species' => 'chat', 'allowed_sex' => 'M', 'required_fiv' => 'positive'];
|
||
$assert(ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'M', 'fiv' => 1]));
|
||
$assert(!ShelterRoomService::accepts($room, ['species' => 'chien', 'sex' => 'M', 'fiv' => 1]));
|
||
$assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'F', 'fiv' => 1]));
|
||
$assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'M', 'fiv' => 0]));
|
||
});
|
||
$test('Les paramètres sont enregistrés et relus', function () use ($assert): void {
|
||
AppSettings::save(['association_name' => 'Refuge Test', 'app_language' => 'en'], null);
|
||
$assert(AppSettings::get('association_name') === 'Refuge Test');
|
||
$assert(AppSettings::get('app_language') === 'en');
|
||
I18n::setLocale('fr');
|
||
});
|
||
$test('Les frais d’adoption sont configurables par espèce', function () use ($db, $assert): void {
|
||
$fees = PricingService::adoptionFees();
|
||
$assert(isset($fees['chat']), 'Tarif du chat absent');
|
||
$db->exec("UPDATE adoption_fee_rules SET fee_before_sterilization=175,fee_after_sterilization=225,is_free=0 WHERE species_code='chat'");
|
||
$assert(PricingService::adoptionFee('chat', false) === 175);
|
||
$assert(PricingService::adoptionFee('chat', true) === 225);
|
||
$db->exec("UPDATE adoption_fee_rules SET is_free=1 WHERE species_code='chat'");
|
||
$assert(PricingService::adoptionFee('chat', true) === null, 'Le tarif libre n’est pas appliqué');
|
||
});
|
||
$test('Un tarif vétérinaire peut être supprimé sans effacer la dépense', function () use ($db, $assert): void {
|
||
$db->exec("INSERT INTO animals(internal_code,name,species,status) VALUES('TEST-TARIF','Tarif Test','chat','refuge')");
|
||
$animalId = (int) $db->lastInsertId();
|
||
$db->exec("INSERT INTO clinic_tariffs(clinic_contact_id,label,amount_cents) VALUES(1,'Tarif temporaire',1234)");
|
||
$tariffId = (int) $db->lastInsertId();
|
||
$db->prepare("INSERT INTO animal_expenses(animal_id,clinic_contact_id,tariff_id,label,occurred_on,catalog_unit_cents,total_cents) VALUES(?,1,?,'Tarif temporaire','2099-01-01',1234,1234)")->execute([$animalId, $tariffId]);
|
||
$expenseId = (int) $db->lastInsertId();
|
||
$assert(PricingService::deleteTariff($tariffId) === 'Tarif temporaire');
|
||
$expense = $db->query("SELECT tariff_id,label,total_cents FROM animal_expenses WHERE id=$expenseId")->fetch(PDO::FETCH_ASSOC);
|
||
$assert($expense && $expense['tariff_id'] === null && $expense['label'] === 'Tarif temporaire' && (int) $expense['total_cents'] === 1234);
|
||
});
|
||
$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(
|
||
'SELECT analyzer_code,species_code,COUNT(*) total FROM lab_reference_ranges GROUP BY analyzer_code,species_code',
|
||
)
|
||
->fetchAll(PDO::FETCH_ASSOC);
|
||
$sets = [];
|
||
foreach ($rows as $row) {
|
||
$sets[$row['analyzer_code']][$row['species_code']] = (int) $row['total'];
|
||
}
|
||
foreach (['vetscan-hm5', 'renal', 'hepatic', 'thyroid'] as $code) {
|
||
foreach (['chat', 'chien'] as $species) {
|
||
$assert(($sets[$code][$species] ?? 0) > 0, "Référentiel $code/$species absent");
|
||
}
|
||
}
|
||
$assert(
|
||
(string) $db
|
||
->query("SELECT analyzer_name FROM lab_reference_ranges WHERE analyzer_code='vetscan-hm5' LIMIT 1")
|
||
->fetchColumn() === 'Bilan complet',
|
||
);
|
||
});
|
||
$test('Une espèce peut recevoir un référentiel biologique modifiable', function () use ($db, $assert): void {
|
||
$copied = LabReferenceService::copySpecies('chat', 'lapin', null);
|
||
$assert($copied > 0, 'Aucune référence copiée');
|
||
$count = (int) $db->query("SELECT COUNT(*) FROM lab_reference_ranges WHERE species_code='lapin'")->fetchColumn();
|
||
$assert($count === $copied, 'Références incomplètes');
|
||
});
|
||
$test('La mise en forme du site public échappe le HTML', function () use ($assert): void {
|
||
$html = PublicContentFormatter::render("## Titre\n- **Texte**\n<script>alert(1)</script>");
|
||
$assert(str_contains($html, '<h3>Titre</h3>'));
|
||
$assert(str_contains($html, '<strong>Texte</strong>'));
|
||
$assert(!str_contains($html, '<script>'), 'HTML brut non échappé');
|
||
$image = PublicContentFormatter::render('');
|
||
$assert(str_contains($image, '<img class="public-content-image"'));
|
||
$externalImage = PublicContentFormatter::render('');
|
||
$assert(!str_contains($externalImage, '<img'), 'Une image externe a été acceptée');
|
||
});
|
||
$test('Authentification et mot de passe haché', function () use ($db, $assert): void {
|
||
$assert(ini_get('session.use_strict_mode') === '1', 'Le mode strict des sessions est désactivé');
|
||
$hash = password_hash('MotDePasseSolide!', PASSWORD_DEFAULT);
|
||
$db->prepare(
|
||
"INSERT INTO users(username,password_hash,display_name,role,active) VALUES('admin-test',?,'Administrateur test','admin',1)",
|
||
)->execute([$hash]);
|
||
$assert(!Auth::login('admin-test', 'incorrect'));
|
||
$assert(
|
||
(int) $db->query('SELECT COUNT(*) FROM login_attempts WHERE succeeded=0')->fetchColumn() === 1,
|
||
'Échec de connexion non enregistré',
|
||
);
|
||
$assert(Auth::login('admin-test', 'MotDePasseSolide!'));
|
||
$assert(Auth::is('admin'));
|
||
});
|
||
$test('Les permissions administrateur restent complètes', function () use ($assert): void {
|
||
$assert(PermissionService::can('animals', 'edit'));
|
||
$assert(PermissionService::can('medical', 'view'));
|
||
});
|
||
$test('Les permissions et répétitions de l’agenda sont opérationnelles', function () use ($db, $assert): void {
|
||
$assert(PermissionService::can('agenda', 'edit'));
|
||
$id = AgendaService::save([
|
||
'item_type' => 'task',
|
||
'title' => 'Tâche répétée de test',
|
||
'date' => date('Y-m-01'),
|
||
'time' => '09:00',
|
||
'priority' => 'high',
|
||
'repeat_rule' => 'weekly',
|
||
'repeat_until' => date('Y-m-t'),
|
||
]);
|
||
$month = AgendaService::month(new DateTimeImmutable('first day of this month'));
|
||
$occurrences = array_values(array_filter($month, static fn($row) => (int) $row['id'] === $id));
|
||
$assert(count($occurrences) >= 4, 'La répétition hebdomadaire n’est pas développée');
|
||
AgendaService::setStatus($id, 'completed');
|
||
$assert((string) $db->query('SELECT status FROM agenda_items WHERE id=' . $id)->fetchColumn() === 'completed');
|
||
});
|
||
$test('La vue hebdomadaire respecte le lundi, le dimanche et la préférence utilisateur', function () use (
|
||
$db,
|
||
$assert,
|
||
): void {
|
||
$inside = AgendaService::save([
|
||
'item_type' => 'task',
|
||
'title' => 'Dans la semaine',
|
||
'date' => date('Y-m-d'),
|
||
'time' => '10:00',
|
||
]);
|
||
$outside = AgendaService::save([
|
||
'item_type' => 'task',
|
||
'title' => 'Semaine suivante',
|
||
'date' => (new DateTimeImmutable('monday next week'))->format('Y-m-d'),
|
||
'time' => '10:00',
|
||
]);
|
||
$ids = array_map('intval', array_column(AgendaService::week(new DateTimeImmutable('today')), 'id'));
|
||
$assert(in_array($inside, $ids, true), 'Le jour courant est absent de sa semaine');
|
||
$assert(!in_array($outside, $ids, true), 'La semaine suivante déborde dans la vue');
|
||
$columns = array_column($db->query('PRAGMA table_info(users)')->fetchAll(PDO::FETCH_ASSOC), 'name');
|
||
$assert(in_array('agenda_view', $columns, true), 'La préférence de vue agenda est absente');
|
||
});
|
||
$test('Les bénévoles de l’annuaire peuvent être mobilisés sur une mission', function () use ($db, $assert): void {
|
||
$db->exec("INSERT INTO directory_contacts(kind,name) VALUES('person','Bénévole Agenda')");
|
||
$volunteer = (int) $db->lastInsertId();
|
||
$db->prepare("INSERT INTO directory_contact_roles(contact_id,role) VALUES(?,'benevole')")->execute([
|
||
$volunteer,
|
||
]);
|
||
$event = AgendaService::save([
|
||
'item_type' => 'transport',
|
||
'title' => 'Mission bénévole',
|
||
'date' => date('Y-m-d'),
|
||
'time' => '14:00',
|
||
'location' => 'Clinique',
|
||
'volunteer_contact_ids' => [$volunteer],
|
||
]);
|
||
$rows = array_values(
|
||
array_filter(
|
||
AgendaService::week(new DateTimeImmutable('today')),
|
||
static fn($row) => (int) $row['id'] === $event,
|
||
),
|
||
);
|
||
$assert(
|
||
count($rows) === 1 && str_contains((string) $rows[0]['volunteer_names'], 'Bénévole Agenda'),
|
||
'Bénévole absent de la mission',
|
||
);
|
||
$assert(str_contains((string) $rows[0]['title'], 'Bénévole Agenda'), 'Affectation non visible dans l’agenda');
|
||
$overlap = AgendaService::save([
|
||
'item_type' => 'task',
|
||
'title' => 'Mission simultanée',
|
||
'date' => date('Y-m-d'),
|
||
'time' => '14:30',
|
||
'volunteer_contact_ids' => [$volunteer],
|
||
]);
|
||
$assert(count(AgendaService::conflicts($overlap)) === 1, 'Le chevauchement du bénévole n’est pas détecté');
|
||
});
|
||
$test('L’export iCalendar et son abonnement privé sont révocables', function () use ($db, $assert): void {
|
||
$token = CalendarFeedService::generateToken((int) Auth::id());
|
||
$assert(
|
||
strlen($token) === 64 && CalendarFeedService::userForToken($token) !== null,
|
||
'Jeton iCalendar invalide',
|
||
);
|
||
$stored = (string) $db
|
||
->query('SELECT agenda_feed_token_hash FROM users WHERE id=' . (int) Auth::id())
|
||
->fetchColumn();
|
||
$assert($stored === hash('sha256', $token) && $stored !== $token, 'Le jeton privé est stocké en clair');
|
||
$ics = CalendarFeedService::render(AgendaService::week(new DateTimeImmutable('today')), 'Agenda Test');
|
||
$assert(
|
||
str_starts_with($ics, "BEGIN:VCALENDAR\r\n") &&
|
||
str_contains($ics, 'BEGIN:VEVENT') &&
|
||
str_contains($ics, 'END:VCALENDAR'),
|
||
'Export iCalendar incomplet',
|
||
);
|
||
CalendarFeedService::revoke((int) Auth::id());
|
||
$assert(CalendarFeedService::userForToken($token) === null, 'Le lien révoqué reste utilisable');
|
||
});
|
||
$test('Une échéance vaccinale produit une notification', function () use ($db, $assert): void {
|
||
$db->exec("INSERT INTO ref_vaccines(name) VALUES('Test vaccin')");
|
||
$v = (int) $db->lastInsertId();
|
||
$db->exec(
|
||
"INSERT INTO animals(internal_code,name,species,status) VALUES('TEST-001','Animal Test','chat','refuge')",
|
||
);
|
||
$a = (int) $db->lastInsertId();
|
||
$db->prepare(
|
||
"INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date) VALUES(?,?,date('now','-1 year'),date('now','-1 day'))",
|
||
)->execute([$a, $v]);
|
||
$alerts = NotificationService::alerts();
|
||
$assert((bool) array_filter($alerts, static fn($x) => $x['key'] === 'vaccines'));
|
||
});
|
||
$test('Les rappels vaccinaux apparaissent automatiquement dans l’agenda', function () use ($assert): void {
|
||
$month = AgendaService::month(new DateTimeImmutable('first day of this month'));
|
||
$reminders = array_values(
|
||
array_filter(
|
||
$month,
|
||
static fn($row) => ($row['source_type'] ?? '') === 'vaccine' && ($row['item_type'] ?? '') === 'vaccine',
|
||
),
|
||
);
|
||
$assert(count($reminders) >= 1, 'Aucun rappel vaccinal virtuel dans le calendrier');
|
||
$assert((int) $reminders[0]['id'] < 0, 'Le rappel vaccinal ne doit pas devenir un événement éditable');
|
||
});
|
||
$test('Vermifuges et traitements alimentent l’agenda sans double saisie', function () use ($db, $assert): void {
|
||
$animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn();
|
||
$dewormer = (int) $db->query('SELECT id FROM ref_dewormers ORDER BY id LIMIT 1')->fetchColumn();
|
||
$db->prepare(
|
||
"INSERT INTO dewormings(animal_id,dewormer_id,administered_on,next_due_date) VALUES(?,?,date('now','-3 months'),date('now'))",
|
||
)->execute([$animal, $dewormer]);
|
||
$db->exec("INSERT INTO ref_medications(name) VALUES('Traitement agenda')");
|
||
$medication = (int) $db->lastInsertId();
|
||
$db->prepare(
|
||
"INSERT INTO treatments(animal_id,medication_id,dose_text,start_date,end_date,ongoing) VALUES(?,?,'Dose test',date('now'),date('now','+1 day'),1)",
|
||
)->execute([$animal, $medication]);
|
||
$sources = array_column(AgendaService::upcoming(2), 'source_type');
|
||
foreach (['deworming', 'treatment_start', 'treatment_end'] as $source) {
|
||
$assert(in_array($source, $sources, true), 'Échéance absente : ' . $source);
|
||
}
|
||
});
|
||
$test('Le parcours médical et adoption conserve ses relations', function () use ($db, $assert): void {
|
||
$animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn();
|
||
$db->prepare(
|
||
"INSERT INTO medical_notes(animal_id,kind,reason,created_by) VALUES(?,'controle','Contrôle de test',?)",
|
||
)->execute([$animal, Auth::id()]);
|
||
AdoptionService::adopt([
|
||
'animal_id' => $animal,
|
||
'adopter_name' => 'Adoptant test',
|
||
'adopter_phone' => '',
|
||
'adopter_email' => '',
|
||
'adopter_address' => '',
|
||
'adopter_postal_code' => '',
|
||
'adopter_city' => '',
|
||
'adoption_date' => date('Y-m-d'),
|
||
'notes' => '',
|
||
'adopter_contact_id' => null,
|
||
]);
|
||
$assert((int) $db->query('SELECT COUNT(*) FROM medical_notes')->fetchColumn() === 1);
|
||
$assert((int) $db->query('SELECT COUNT(*) FROM adoptions')->fetchColumn() === 1);
|
||
$availability = $db->query("SELECT adoption_availability FROM animals WHERE internal_code='TEST-001'")
|
||
->fetchColumn();
|
||
$assert($availability === 'not_available', 'Un animal adopté reste disponible à l’adoption');
|
||
$assert(!$db->query('PRAGMA foreign_key_check')->fetchAll());
|
||
});
|
||
$test('Les contrôles médicaux conservent leur date et leur heure', function () use ($assert): void {
|
||
$view = file_get_contents(__DIR__ . '/../app/Views/_animal_show_modals.php');
|
||
$controller = file_get_contents(__DIR__ . '/../app/Controllers/AnimalMedicalActions.php');
|
||
$assert(str_contains($view, 'name="noted_date"'), 'Le champ de date du contrôle est absent');
|
||
$assert(str_contains($view, 'name="noted_time"'), 'Le champ d’heure du contrôle est absent');
|
||
$assert(str_contains($controller, "':noted_at' => \$observedAtSql"), 'La note ne reprend pas l’instant choisi');
|
||
$assert(str_contains($controller, "':at' => \$observedAtSql"), 'La courbe de poids ne reprend pas l’instant choisi');
|
||
});
|
||
$test('La fiche annuaire agrège adoptions et dépenses', function () use ($db, $assert): void {
|
||
$db->exec("INSERT INTO directory_contacts(kind,name) VALUES('organization','Structure test')");
|
||
$contact = (int) $db->lastInsertId();
|
||
$animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn();
|
||
$db->prepare('UPDATE adoptions SET adopter_contact_id=? WHERE animal_id=?')->execute([$contact, $animal]);
|
||
$db->prepare(
|
||
"INSERT INTO animal_expenses(animal_id,clinic_contact_id,label,occurred_on,quantity,catalog_unit_cents,total_cents) VALUES(?,?,'Acte test',date('now'),1,1234,1234)",
|
||
)->execute([$animal, $contact]);
|
||
$q = $db->prepare(
|
||
"SELECT (SELECT COUNT(*) FROM (SELECT animal_id FROM adoptions WHERE adopter_contact_id=dc.id UNION SELECT animal_id FROM animal_placements WHERE contact_id=dc.id AND event_type='adoption')) adopted,(SELECT COALESCE(SUM(total_cents),0) FROM animal_expenses WHERE clinic_contact_id=dc.id) total FROM directory_contacts dc WHERE dc.id=?",
|
||
);
|
||
$q->execute([$contact]);
|
||
$row = $q->fetch();
|
||
$assert((int) $row['adopted'] === 1);
|
||
$assert((int) $row['total'] === 1234);
|
||
$costs = StatisticsService::costs((int) date('Y'));
|
||
$structure =
|
||
array_values(
|
||
array_filter($costs['by_clinic'], static fn($item) => (int) $item['contact_id'] === $contact),
|
||
)[0] ?? null;
|
||
$assert($structure !== null && $structure['label'] === 'Structure test');
|
||
$assert((int) $structure['total_cents'] === 1234);
|
||
});
|
||
$test('La recherche globale retrouve les dossiers accessibles', function () use ($assert): void {
|
||
$animals = GlobalSearchService::search('Animal Test');
|
||
$assert(($animals['animals'][0]['title'] ?? '') === 'Animal Test', 'Animal absent de la recherche');
|
||
$contacts = GlobalSearchService::search('Structure test');
|
||
$assert(($contacts['directory'][0]['title'] ?? '') === 'Structure test', 'Contact absent de la recherche');
|
||
$agenda = GlobalSearchService::search('Mission bénévole');
|
||
$assert(!empty($agenda['agenda']), 'Événement absent de la recherche');
|
||
});
|
||
$test('Une sauvegarde ZIP contient une base et un manifeste', function () use (&$createdBackup, $assert): void {
|
||
if (!class_exists('ZipArchive')) {
|
||
return;
|
||
}
|
||
$createdBackup = BackupService::create('test-suite');
|
||
$path = BackupService::path($createdBackup['name']);
|
||
$assert($path !== null);
|
||
$zip = new ZipArchive();
|
||
$assert($zip->open($path) === true);
|
||
$assert($zip->locateName('manifest.json') !== false);
|
||
$assert($zip->locateName('database/refuge.sqlite') !== false);
|
||
$zip->close();
|
||
});
|
||
$test('La sauvegarde créée est relue et son SQLite vérifié', function () use (&$createdBackup, $assert): void {
|
||
if (!$createdBackup) {
|
||
return;
|
||
}
|
||
$result = BackupService::verify($createdBackup['name']);
|
||
$assert($result['valid'] === true);
|
||
$assert($result['files'] >= 1);
|
||
});
|
||
$test('La pharmacie refuse un stock négatif et valorise l’animal', function () use ($db, $assert): void {
|
||
$product = InventoryService::saveProduct([
|
||
'category' => 'medication',
|
||
'name' => 'Produit stock test',
|
||
'unit' => 'comprimé',
|
||
'minimum_quantity' => '2',
|
||
]);
|
||
$batch = InventoryService::receive([
|
||
'product_id' => $product,
|
||
'quantity' => '10',
|
||
'unit_cost' => '1,20',
|
||
'batch_number' => 'TEST-LOT',
|
||
'expires_on' => date('Y-m-d', strtotime('+1 year')),
|
||
]);
|
||
$animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn();
|
||
InventoryService::consumeBatch($batch, 2, $animal, 'Test automatisé');
|
||
$assert((float) $db->query('SELECT quantity FROM inventory_batches WHERE id=' . $batch)->fetchColumn() === 8.0);
|
||
$assert(
|
||
(int) $db
|
||
->query(
|
||
"SELECT total_cents FROM animal_expenses WHERE source_type='inventory' AND source_id=(SELECT MAX(id) FROM inventory_movements WHERE batch_id=$batch)",
|
||
)
|
||
->fetchColumn() === 240,
|
||
);
|
||
$failed = false;
|
||
try {
|
||
InventoryService::consumeBatch($batch, 20, null, 'Impossible');
|
||
} catch (RuntimeException) {
|
||
$failed = true;
|
||
}
|
||
$assert($failed, 'Le stock négatif a été accepté');
|
||
$assert(
|
||
(int) $db->query('SELECT COUNT(*) FROM inventory_movements WHERE batch_id=' . $batch)->fetchColumn() === 2,
|
||
);
|
||
});
|
||
$test('La portabilité et le registre privé sont opérationnels', function () use ($db, $assert): void {
|
||
if (!class_exists('ZipArchive')) {
|
||
return;
|
||
}
|
||
$db->exec(
|
||
"INSERT INTO directory_contacts(kind,name,email) VALUES('person','Contact RGPD','contact@example.test')",
|
||
);
|
||
$id = (int) $db->lastInsertId();
|
||
$path = PrivacyService::exportContact($id);
|
||
$assert(is_file($path));
|
||
$zip = new ZipArchive();
|
||
$assert($zip->open($path) === true);
|
||
$assert($zip->locateName('contact.json') !== false);
|
||
$zip->close();
|
||
unlink($path);
|
||
PrivacyService::logAccess('test_document', 1, null, 'test.pdf');
|
||
$assert(
|
||
(int) $db
|
||
->query("SELECT COUNT(*) FROM private_file_access_log WHERE resource_type='test_document'")
|
||
->fetchColumn() === 1,
|
||
);
|
||
});
|
||
$test('La maintenance RGPD purge uniquement le journal arrivé à échéance', function () use ($db, $assert): void {
|
||
AppSettings::save(['privacy_access_log_months' => '12'], Auth::id());
|
||
$db->exec(
|
||
"INSERT INTO private_file_access_log(resource_type,action,accessed_at) VALUES('ancien_test','view',datetime('now','-13 months'))",
|
||
);
|
||
$result = PrivacyService::maintenance();
|
||
$assert($result['access_logs_removed'] >= 1);
|
||
$assert(
|
||
(int) $db
|
||
->query("SELECT COUNT(*) FROM private_file_access_log WHERE resource_type='ancien_test'")
|
||
->fetchColumn() === 0,
|
||
);
|
||
$assert(
|
||
(int) $db->query("SELECT COUNT(*) FROM directory_contacts WHERE name='Contact RGPD'")->fetchColumn() === 1,
|
||
'Un contact a été supprimé automatiquement',
|
||
);
|
||
});
|
||
$test('Les appareils persistants sont révocables', function () use ($db, $assert): void {
|
||
TrustedDeviceService::create((int) Auth::id(), 'Téléphone de test');
|
||
$device = $db->query('SELECT * FROM trusted_devices ORDER BY id DESC LIMIT 1')->fetch();
|
||
$assert($device && $device['device_name'] === 'Téléphone de test');
|
||
TrustedDeviceService::revoke((int) $device['id'], (int) Auth::id());
|
||
$assert(
|
||
(bool) $db->query('SELECT revoked_at FROM trusted_devices WHERE id=' . (int) $device['id'])->fetchColumn(),
|
||
);
|
||
});
|
||
$test('Les vues ne contiennent aucune balise PHP déformée', function () use ($root, $assert): void {
|
||
$views = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/app/Views'));
|
||
foreach ($views as $view) {
|
||
if (!$view->isFile() || $view->getExtension() !== 'php') {
|
||
continue;
|
||
}
|
||
$contents = file_get_contents($view->getPathname());
|
||
$assert($contents !== false, 'Vue illisible : ' . $view->getFilename());
|
||
foreach (['< ? =', '? >', ' - > '] as $brokenToken) {
|
||
$assert(
|
||
!str_contains($contents, $brokenToken),
|
||
'Syntaxe déformée dans ' . $view->getFilename() . ' : ' . $brokenToken,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
$test('Le statut visible d’une fiche animale est bien envoyé au serveur', function () use ($root, $assert): void {
|
||
$form = file_get_contents($root . '/app/Views/animal_form.php');
|
||
$assert($form !== false, 'Formulaire animal illisible');
|
||
$assert(str_contains($form, 'name="status" id="status"'), 'Le sélecteur de statut ne porte pas le nom attendu');
|
||
$assert(!str_contains($form, 'name="status_display"'), 'Ancien nom de champ encore présent');
|
||
});
|
||
$test('Les noms accentués produisent un code interne lisible', function () use ($assert): void {
|
||
$assert(Ids::slugifyUpper('Gérard') === 'GERARD', 'La translittération de Gérard a échoué');
|
||
$assert(Ids::slugifyUpper('Éléonore Ça') === 'ELEONORE-CA', 'La translittération des accents a échoué');
|
||
});
|
||
$test('La démonstration installe une fiche Twix complète', function () use ($db, $root, $assert): void {
|
||
DemoDataService::reset($db);
|
||
$summary = DemoDataService::seed($db, Auth::id());
|
||
DemoDataService::clearFiles();
|
||
DemoDataService::installFiles($db);
|
||
$twix = (int) $db
|
||
->query("SELECT id FROM animals WHERE internal_code='DEMO-003' AND name='Twix' AND status='quarantaine'")
|
||
->fetchColumn();
|
||
$assert($twix > 0, 'Twix absent de la quarantaine');
|
||
$assert(
|
||
$summary['lab_reports'] === 1 && $summary['agenda_items'] === 1 && $summary['photos'] === 1,
|
||
'Résumé de démonstration incomplet',
|
||
);
|
||
foreach (
|
||
[
|
||
'medical_notes',
|
||
'treatments',
|
||
'vaccinations',
|
||
'dewormings',
|
||
'animal_health_conditions',
|
||
'lab_reports',
|
||
'animal_photos',
|
||
'agenda_items',
|
||
]
|
||
as $table
|
||
) {
|
||
$assert(
|
||
(int) $db->query("SELECT COUNT(*) FROM $table WHERE animal_id=$twix")->fetchColumn() > 0,
|
||
"Donnée Twix absente : $table",
|
||
);
|
||
}
|
||
$assert(is_file($root . '/public/media/animals/' . $twix . '/twix.webp'), 'Photo de Twix absente');
|
||
$assert(
|
||
(int) $db
|
||
->query(
|
||
"SELECT COUNT(*) FROM animals WHERE website_published=1 AND adoption_availability='available' AND archived_at IS NULL",
|
||
)
|
||
->fetchColumn() >= 1,
|
||
'Aucun animal de démonstration publié sur le site public',
|
||
);
|
||
$assert(
|
||
(int) $db->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='geocode_cache'")
|
||
->fetchColumn() === 1,
|
||
'Cache de géocodage absent',
|
||
);
|
||
DemoDataService::clearFiles();
|
||
});
|
||
} finally {
|
||
if ($createdBackup && isset($createdBackup['name'])) {
|
||
try {
|
||
BackupService::delete($createdBackup['name']);
|
||
} catch (Throwable) {
|
||
}
|
||
}
|
||
DB::close();
|
||
foreach ([$tmp, $tmp . '-wal', $tmp . '-shm'] as $file) {
|
||
if (is_file($file)) {
|
||
unlink($file);
|
||
}
|
||
}
|
||
}
|
||
echo "\n$passed réussi(s), $failed échec(s).\n";
|
||
exit($failed ? 1 : 0);
|