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('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( count($rooms['care']['boxes']) === 5 && count($rooms['quarantine']['boxes']) === 5, 'Les cinq box initiaux ne sont pas disponibles', ); }); $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 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('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()]); $db->prepare( "INSERT INTO adoptions(animal_id,adopter_name,adoption_date) VALUES(?,'Adoptant test',date('now'))", )->execute([$animal]); $assert((int) $db->query('SELECT COUNT(*) FROM medical_notes')->fetchColumn() === 1); $assert((int) $db->query('SELECT COUNT(*) FROM adoptions')->fetchColumn() === 1); $assert(!$db->query('PRAGMA foreign_key_check')->fetchAll()); }); $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('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'); 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);