Error tests, FK violations fix

- 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
This commit is contained in:
Pontoporeia
2026-05-09 21:36:42 +02:00
parent a80b2c08bf
commit 6cc0e407f3
38 changed files with 1515 additions and 82 deletions

183
app/src/ErrorHandler.php Normal file
View File

@@ -0,0 +1,183 @@
<?php
/**
* ErrorHandler — shared error-normalisation and logging utilities.
*
* Provides:
* - userMessage($e) — turns raw exceptions into user-friendly French messages
* - log($context, $e, array $extra) — structured error_log with trace
*
* Usage from any action/controller:
* ErrorHandler::log('thesis_edit', $e, ['thesis_id' => $id]);
* App::flash('error', ErrorHandler::userMessage($e));
*/
class ErrorHandler
{
/**
* Map SQLite FK constraint errors to human-readable field names.
*
* SQLite FK errors reference the *child* table (where the FK column lives),
* e.g. "INSERT INTO theses" when orientation_id FK fails.
*
* We map child table → the form field(s) whose values populate its FK columns.
*/
private const FK_TABLE_MAP = [
// Main thesis table — FK columns: orientation_id, ap_program_id,
// finality_id, access_type_id, license_id
'theses' => 'Orientation, AP, Finalité, Type d\'accès ou Licence',
// Junction tables — each maps to one specific field
'thesis_authors' => 'Auteur·ice',
'thesis_supervisors' => 'Composition du jury',
'thesis_languages' => 'Langue(s)',
'thesis_formats' => 'Format(s)',
'thesis_tags' => 'Mots-clés',
'thesis_files' => 'Fichiers',
// Admin / system tables
'share_links' => 'Lien étudiant·e',
'file_access_requests' => "Demande d'accès",
'form_help_blocks' => "Bloc d'aide",
'site_settings' => 'Paramètres',
'admin_log' => 'Journal admin',
// Parent tables (when referenced directly in "table" pattern)
'orientations' => 'Orientation',
'ap_programs' => 'AP',
'finality_types' => 'Finalité',
'access_types' => "Type d'accès",
'license_types' => 'Licence',
'authors' => 'Auteur·ice',
'supervisors' => 'Composition du jury',
'languages' => 'Langue(s)',
'format_types' => 'Format(s)',
'tags' => 'Mots-clés',
];
/**
* Map an exception to a user-facing message.
*
* Domain exceptions (validation, duplicates) carry their own message;
* system exceptions (PDO, generic) get sanitised explanations with
* specific field identification where possible.
*/
public static function userMessage(\Throwable $e): string
{
// ── Domain exceptions with meaningful messages ──────────────────────
if ($e instanceof DuplicateThesisException) {
return $e->getMessage(); // Already user-friendly
}
// ── Database errors ─────────────────────────────────────────────────
if ($e instanceof \PDOException) {
return self::pdoMessage($e);
}
// ── Validation errors (RuntimeException, InvalidArgumentException) ──
// These are thrown with user-friendly French messages — pass through.
if ($e instanceof \RuntimeException) {
return $e->getMessage();
}
// ── Everything else — generic fallback ──────────────────────────────
return 'Une erreur inattendue est survenue.'
. ' Veuillez réessayer ou contacter l\'équipe.';
}
/**
* Build a user-facing message from a PDOException.
*
* For FK constraint failures, the SQLite error message includes the
* table name (e.g. "FOREIGN KEY constraint failed INSERT INTO thesis_formats").
* We extract it and map to a field label.
*/
private static function pdoMessage(\PDOException $e): string
{
$msg = $e->getMessage();
if (str_contains($msg, 'FOREIGN KEY constraint failed')) {
$field = self::extractFkField($msg);
if ($field !== null) {
return "Erreur de base de données : la référence pour « {$field} » est invalide."
. ' Vérifiez que la valeur existe dans la base de données.';
}
return 'Erreur de base de données : une contrainte de référence est invalide.'
. ' Vérifiez les champs Orientation, AP, Finalité, Langues, Formats.';
}
// ── UNIQUE constraint ───────────────────────────────────────────────
if (str_contains($msg, 'UNIQUE constraint failed')) {
return 'Erreur de base de données : une valeur en double a été détectée.'
. ' Veuillez réessayer ou contacter l\'équipe.';
}
// ── NOT NULL ────────────────────────────────────────────────────────
if (str_contains($msg, 'NOT NULL constraint failed')) {
return 'Erreur de base de données : un champ obligatoire est manquant.'
. ' Veuillez réessayer ou contacter l\'équipe.';
}
// ── Generic SQL error — don't leak raw SQL to users ────────────────
return 'Une erreur de base de données est survenue.'
. ' Veuillez réessayer ou contacter l\'équipe.';
}
/**
* Try to extract the referenced table name from a SQLite FK error message.
*
* SQLite FK errors typically contain the table name in the message body:
* "FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?)"
*
* Also handles quoted table names in newer SQLite messages:
* "FOREIGN KEY constraint failed (table \"orientations\")"
*/
private static function extractFkField(string $msg): ?string
{
// Pattern 1: "table \"tablename\"" (SQLite 3.37+)
if (preg_match('/table\s+"([^"]+)"/i', $msg, $m)) {
$table = strtolower($m[1]);
return self::FK_TABLE_MAP[$table] ?? null;
}
// Pattern 2: "INSERT INTO tablename" or "UPDATE tablename"
if (preg_match('/(?:INSERT\s+INTO|UPDATE)\s+"?(\w+)"?/i', $msg, $m)) {
$table = strtolower($m[1]);
return self::FK_TABLE_MAP[$table] ?? null;
}
// Pattern 3: "REFERENCES tablename" — the constraint itself
if (preg_match('/REFERENCES\s+"?(\w+)"?/i', $msg, $m)) {
$table = strtolower($m[1]);
return self::FK_TABLE_MAP[$table] ?? null;
}
return null;
}
/**
* Write a structured error log entry.
*
* @param string $context e.g. 'thesis_edit', 'partage_submit', 'csv_import'
* @param \Throwable $e
* @param array $extra arbitrary key-value context to include in the log
*/
public static function log(string $context, \Throwable $e, array $extra = []): void
{
$parts = [
'context=' . $context,
'exception=' . get_class($e),
'message=' . $e->getMessage(),
];
foreach ($extra as $k => $v) {
if (is_scalar($v) || $v === null) {
$parts[] = $k . '=' . json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} elseif (is_array($v)) {
$parts[] = $k . '=' . json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} else {
$parts[] = $k . '=' . gettype($v);
}
}
$parts[] = 'trace=' . $e->getTraceAsString();
error_log(implode(' | ', $parts));
}
}