Globinours/tests/run.php

625 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 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 (['type', 'label', 'details'] 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('![Twix](/media/site/twix.webp)');
$assert(str_contains($image, '<img class="public-content-image"'));
$externalImage = PublicContentFormatter::render('![Piège](https://example.org/image.webp)');
$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);