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

9
tests/README.md Normal file
View file

@ -0,0 +1,9 @@
# Tests Globinours
La suite légère fonctionne sans Composer et utilise exclusivement une base SQLite temporaire :
```bash
php tests/run.php
```
Elle contrôle les migrations, lintégrité SQLite, les paramètres, lauthentification, les permissions, un parcours médical et dadoption, les agrégats de lannuaire, la génération dalertes et la structure des sauvegardes ZIP. La base réelle nest jamais ouverte par le processus de test.

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);