Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

466
tests/run.php Normal file
View file

@ -0,0 +1,466 @@
<?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',
'I18n',
'AppSettings',
'SecurityService',
'Auth',
'TrustedDeviceService',
'PermissionService',
'AuditService',
'BackupService',
'InstallationService',
'InventoryService',
'AgendaService',
'CalendarFeedService',
'NotificationService',
'PrivacyService',
'StatisticsService',
'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('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 lagenda 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 nest 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 lannuaire 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 lagenda');
$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 nest pas détecté');
});
$test('Lexport 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 lagenda', 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 lagenda 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 lanimal', 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);