Files
xamxam/app/tests/Unit/ErrorHandlerTest.php

453 lines
19 KiB
PHP

<?php
/**
* ErrorHandler Unit Test
*
* Tests that ErrorHandler correctly:
* - Maps PDO FK constraint errors to precise field names
* - Falls back to generic messages when table cannot be identified
* - Passes through domain exceptions (DuplicateThesisException, RuntimeException)
* - Generates structured log entries without crashing
* - Handles edge cases: empty message, null values, unknown exception types
*/
putenv('DB_ENV=test');
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 2));
}
if (!defined('STORAGE_ROOT')) {
define('STORAGE_ROOT', APP_ROOT . '/storage');
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
// ── Test harness ─────────────────────────────────────────────────────────────
function ehAssert(bool $cond, string $label): void
{
if ($cond) {
echo "$label\n";
} else {
throw new RuntimeException("FAIL: $label");
}
}
function ehAssertContains(string $needle, string $haystack, string $label): void
{
if (str_contains($haystack, $needle)) {
echo "$label\n";
} else {
throw new RuntimeException("FAIL: $label\n expected to contain: $needle\n actual: $haystack");
}
}
function ehAssertEq(mixed $expected, mixed $actual, string $label): void
{
if ($expected === $actual) {
echo "$label\n";
} else {
$e = var_export($expected, true);
$a = var_export($actual, true);
throw new RuntimeException("FAIL: $label\n expected: $e\n actual: $a");
}
}
function ehAssertNotContains(string $needle, string $haystack, string $label): void
{
if (!str_contains($haystack, $needle)) {
echo "$label\n";
} else {
throw new RuntimeException("FAIL: $label\n expected NOT to contain: $needle\n actual: $haystack");
}
}
// ── Helper: create a PDOException with a given SQLite error message ──────────
// PDOException's constructor takes (message, code, previous).
// The message is what SQLite would produce.
function makeFkException(string $sqliteMessage): PDOException
{
return new PDOException($sqliteMessage, 787); // 787 = SQLITE_CONSTRAINT_FOREIGNKEY
}
function makeUniqueException(): PDOException
{
return new PDOException('UNIQUE constraint failed: thesis_tags.tag_id, thesis_tags.thesis_id', 2067);
}
function makeNotNullException(): PDOException
{
return new PDOException('NOT NULL constraint failed: theses.title', 1299);
}
function makeGenericPdoException(): PDOException
{
return new PDOException('database disk image is malformed', 11);
}
// ── Start ────────────────────────────────────────────────────────────────────
echo "ErrorHandler Unit Test\n";
echo "======================\n\n";
try {
// =========================================================================
// SECTION A: FK constraint — precise field extraction
// =========================================================================
echo "A: FK constraint — table name extracted from INSERT INTO\n";
echo "A1: theses table (orientation_id FK) — lists all possible FK fields\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (title, orientation_id) VALUES (?,?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Orientation', $user, 'mentions Orientation (one of the possible FK fields on theses)');
ehAssertContains('AP', $user, 'mentions AP');
ehAssertContains('Licence', $user, 'mentions Licence');
ehAssertNotContains('FOREIGN KEY', $user, 'raw SQL not leaked');
echo "A2: ap_programs\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (ap_program_id) VALUES (?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('AP', $user, 'identifies AP field');
echo "A3: finality_types\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (finality_id) VALUES (?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Finalité', $user, 'identifies Finalité field');
echo "A4: thesis_languages\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Langue(s)', $user, 'identifies Langue(s) field');
echo "A5: thesis_formats\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Format(s)', $user, 'identifies Format(s) field');
echo "A6: thesis_tags\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Mots-clés', $user, 'identifies Mots-clés field');
echo "A7: thesis_supervisors\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_supervisors (thesis_id, supervisor_id) VALUES (?,?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Composition du jury', $user, 'identifies jury field');
echo "A8: access_types\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (access_type_id) VALUES (?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains("Type d'accès", $user, 'identifies access type');
echo "A9: license_types\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (license_id) VALUES (?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Licence', $user, 'identifies Licence field');
echo "A10: authors\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_authors (thesis_id, author_id) VALUES (?,?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Auteur·ice', $user, 'identifies Auteur·ice field');
echo "\n";
// =========================================================================
// SECTION B: FK constraint — \"table\" pattern (SQLite 3.37+)
// =========================================================================
echo "B: FK constraint — \"table\" pattern (SQLite 3.37+)\n";
echo "B1: quoted table name\n";
$msg = makeFkException('FOREIGN KEY constraint failed (table "orientations")');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Orientation', $user, 'extracts from quoted table name');
echo "B2: quoted table — languages\n";
$msg = makeFkException('FOREIGN KEY constraint failed (table "languages")');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Langue(s)', $user, 'maps languages → Langue(s)');
echo "B3: quoted table — format_types\n";
$msg = makeFkException('FOREIGN KEY constraint failed (table "format_types")');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Format(s)', $user, 'maps format_types → Format(s)');
echo "\n";
// =========================================================================
// SECTION C: FK constraint — REFERENCES pattern
// =========================================================================
echo "C: FK constraint — REFERENCES pattern\n";
echo "C1: REFERENCES tags\n";
$msg = makeFkException('FOREIGN KEY constraint failed (REFERENCES tags(id))');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Mots-clés', $user, 'maps REFERENCES tags → Mots-clés');
echo "C2: REFERENCES orientations\n";
$msg = makeFkException('FOREIGN KEY constraint failed (REFERENCES orientations(id))');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Orientation', $user, 'maps REFERENCES orientations → Orientation');
echo "\n";
// =========================================================================
// SECTION D: FK constraint — unknown table falls back to generic
// =========================================================================
echo "D: FK constraint — unknown table → generic fallback\n";
echo "D1: unknown table in INSERT\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO unknown_table VALUES (?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('contrainte de référence est invalide', $user, 'generic FK message');
ehAssertNotContains('unknown_table', $user, 'table name not leaked');
echo "D2: empty message with FK keywords\n";
$msg = makeFkException('FOREIGN KEY constraint failed');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('contrainte de référence est invalide', $user, 'generic FK for unparseable message');
echo "\n";
// =========================================================================
// SECTION E: UNIQUE constraint\n
// =========================================================================
echo "E: UNIQUE constraint\n";
echo "E1: UNIQUE constraint failed\n";
$msg = makeUniqueException();
$user = ErrorHandler::userMessage($msg);
ehAssertContains('valeur en double', $user, 'mentions duplicate value');
ehAssertNotContains('UNIQUE', $user, 'raw SQL not leaked');
ehAssertNotContains('thesis_tags', $user, 'table name not leaked');
echo "\n";
// =========================================================================
// SECTION F: NOT NULL constraint\n
// =========================================================================
echo "F: NOT NULL constraint\n";
echo "F1: NOT NULL constraint failed\n";
$msg = makeNotNullException();
$user = ErrorHandler::userMessage($msg);
ehAssertContains('champ obligatoire est manquant', $user, 'mentions required field');
ehAssertNotContains('NOT NULL', $user, 'raw SQL not leaked');
echo "\n";
// =========================================================================
// SECTION G: Generic PDO errors\n
// =========================================================================
echo "G: Generic PDO errors\n";
echo "G1: disk image malformed\n";
$msg = makeGenericPdoException();
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Une erreur de base de données est survenue', $user, 'generic DB message');
ehAssertNotContains('disk image', $user, 'raw SQL not leaked');
ehAssertNotContains('malformed', $user, 'raw error text not leaked');
echo "\n";
// =========================================================================
// SECTION H: Domain exceptions pass through\n
// =========================================================================
echo "H: Domain exceptions pass through with original message\n";
echo "H1: DuplicateThesisException\n";
$dup = new DuplicateThesisException(42, '2025-ABC12345', 'Test titre', 'Auteur', 2025);
$user = ErrorHandler::userMessage($dup);
ehAssertContains('2025-ABC12345', $user, 'identifier in message');
ehAssertContains('Auteur', $user, 'author in message');
echo "H2: RuntimeException (validation)\n";
$val = new RuntimeException('Le titre est requis.');
$user = ErrorHandler::userMessage($val);
ehAssertEq('Le titre est requis.', $user, 'validation message passes through');
echo "\n";
// =========================================================================
// SECTION I: Unknown exception types → generic fallback\n
// =========================================================================
echo "I: Unknown exception types → generic fallback\n";
echo "I1: generic Exception (passes through for validation errors)\n";
$gen = new Exception('Something went wrong');
$user = ErrorHandler::userMessage($gen);
ehAssertContains('Something went wrong', $user, 'Exception message passes through');
echo "I2: TypeError\n";
$typeErr = new TypeError('htmlspecialchars(): Argument #1 must be string, array given');
$user = ErrorHandler::userMessage($typeErr);
ehAssertContains('Une erreur inattendue est survenue', $user, 'generic message for TypeError');
ehAssertNotContains('htmlspecialchars', $user, 'internal function name not leaked');
echo "\n";
// =========================================================================
// SECTION J: Log method — does not crash, captures all context\n
// =========================================================================
echo "J: ErrorHandler::log() — structured logging without errors\n";
// Suppress actual error_log output during test; verify no exception thrown.
echo "J1: log with extra context\n";
try {
ErrorHandler::log('test_context', new Exception('test message'), [
'thesis_id' => 42,
'slug' => '20250101-TEST1234',
'author' => 'Test Author',
]);
echo " ✓ log() completed without exception\n";
} catch (Throwable $e) {
throw new RuntimeException('FAIL: log() threw: ' . $e->getMessage());
}
echo "J2: log with null values in extra\n";
ErrorHandler::log('test_null', new PDOException('test'), ['id' => null, 'name' => 'foo']);
echo " ✓ log() handles null values\n";
echo "J3: log with empty extra array\n";
ErrorHandler::log('test_empty', new RuntimeException('bare'));
echo " ✓ log() handles empty extra\n";
echo "\n";
// =========================================================================
// SECTION K: Keyword normalization logic (tag normalization)
// =========================================================================
echo "K: Keyword normalization logic\n";
// Test the normalization regex used in controllers and JS:
// strtolower(trim(preg_replace('/\s+/', ' ', $t)))
$normalize = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
echo "K1: basic trimming and casing\n";
ehAssertEq('hello', $normalize('Hello'), 'uppercase → lowercase');
ehAssertEq('hello', $normalize(' hello '), 'trimmed');
ehAssertEq('hello world', $normalize('Hello World'), 'two words lowercased');
echo "K2: multiple spaces collapsed\n";
ehAssertEq('a b c', $normalize('a b c'), 'double spaces → single');
ehAssertEq('x y', $normalize(' x y '), 'leading/trailing + multiple → clean');
echo "K3: tabs and newlines collapsed to space\n";
ehAssertEq('word1 word2', $normalize("word1\tword2"), 'tab → space');
ehAssertEq('line1 line2', $normalize("line1\nline2"), 'newline → space');
ehAssertEq('mixed spaces', $normalize("mixed \t \n spaces"), 'mixed whitespace → single space');
echo "K4: French accents preserved\n";
ehAssertEq('très précis', $normalize('Très Précis'), 'accents preserved in lowercase');
echo "K5: empty string\n";
ehAssertEq('', $normalize(''), 'empty stays empty');
ehAssertEq('', $normalize(' '), 'whitespace-only becomes empty');
echo "K6: special characters not mangled\n";
ehAssertEq('c++', $normalize('C++'), 'symbols preserved');
ehAssertEq('c#', $normalize('C#'), 'hash preserved');
echo "\n";
// =========================================================================
// SECTION L: Deduplication on normalize (case-insensitive)
// =========================================================================
echo "L: Deduplication after normalization\n";
$dedup = function (array $tags): array {
return array_values(array_unique(array_map(
fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))),
$tags
)));
};
echo "L1: case-insensitive dedup\n";
ehAssertEq(['hello'], $dedup(['Hello', 'hello', 'HELLO']), 'case variations → one entry');
echo "L2: whitespace-insensitive dedup\n";
ehAssertEq(['hello world'], $dedup(['hello world', 'Hello World', 'hello world']), 'whitespace + case → one entry');
echo "L3: empty strings filtered\n";
$filtered = array_values(array_filter($dedup(['', ' ', 'valid']), fn ($t) => $t !== ''));
ehAssertEq(['valid'], $filtered, 'empty/whitespace-only removed');
echo "L4: mixed valid and empty\n";
$result = array_values(array_filter($dedup(['Alpha', '', ' ', 'BETA', 'alpha']), fn ($t) => $t !== ''));
ehAssertEq(['alpha', 'beta'], $result, 'deduplicated and empties filtered');
echo "\n";
// =========================================================================
// SECTION M: Minimum/maximum tag count enforcement\n
// =========================================================================
echo "M: Tag count constraints\n";
echo "M1: 3 tags is valid\n";
$valid = ['one', 'two', 'three'];
ehAssert(count($valid) >= 3, '3 tags ≥ minimum 3');
ehAssert(count($valid) <= 10, '3 tags ≤ maximum 10');
echo "M2: < 3 tags triggers error\n";
$tooFew = ['one'];
ehAssert(count($tooFew) < 3, '1 tag < minimum 3');
echo "M3: > 10 tags triggers error\n";
$tooMany = ['a','b','c','d','e','f','g','h','i','j','k'];
ehAssert(count($tooMany) > 10, '11 tags > maximum 10');
echo "M4: empty array\n";
ehAssert(count([]) < 3, 'empty array < minimum 3');
echo "\n";
// =========================================================================
// SECTION N: Real SQLite FK error message formats
// =========================================================================
echo "N: Real-world SQLite FK error message patterns\n";
// These are actual error messages observed in the wild.
echo "N1: typical INSERT INTO with VALUES\n";
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats ("thesis_id", "format_id") VALUES (?, ?)');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Format(s)', $user, 'quoted column names handled');
echo "N2: UPDATE statement\n";
$msg = makeFkException('FOREIGN KEY constraint failed UPDATE theses SET orientation_id = ? WHERE id = ?');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Orientation', $user, 'UPDATE statement parsed');
echo "N3: long FK message with multiple table references\n";
// Only the first match should be used (the INSERT target table)
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?) REFERENCES format_types');
$user = ErrorHandler::userMessage($msg);
ehAssertContains('Format(s)', $user, 'first table match used');
echo "\n";
echo "✅ All ErrorHandler tests passed!\n";
$result = true;
} catch (Exception $e) {
echo '❌ FAIL: ' . $e->getMessage() . "\n";
$result = false;
}
return $result ?? false;