mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
- ErrorHandler tests: 77 assertions covering FK extraction, normalization, dedup, edge cases. Fix FK table map for child tables. - Fix FK violation: (int)null → 0 in createThesis for orientation/ap/finality/license FK columns. Add FK value logging to updateThesis. - Add CURRENT_ISSUES.md with summary of FK violation, dev debugging, and tag dedup status for next conversation
454 lines
19 KiB
PHP
454 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\n";
|
|
$gen = new Exception('Something went wrong');
|
|
$user = ErrorHandler::userMessage($gen);
|
|
ehAssertContains('Une erreur inattendue est survenue', $user, 'generic message');
|
|
ehAssertNotContains('Something went wrong', $user, 'raw message not leaked');
|
|
|
|
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;
|