mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
91
CURRENT_ISSUES.md
Normal file
91
CURRENT_ISSUES.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Current Issues — XAMXAM (2026-05-10)
|
||||
|
||||
## 1. FK constraint violation on thesis save (create + edit)
|
||||
|
||||
**Symptom:** `⚠ SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed`
|
||||
|
||||
**Triggers:**
|
||||
- Editing an imported CSV thesis, changing access type to Interdit, save
|
||||
- Opening any imported thesis edit form and saving *without changes*
|
||||
- Saving the add form with empty fields — form below "Cadre académique" disappears (PHP dies mid-render)
|
||||
|
||||
**Root cause found so far:** `Database::createThesis()` at line ~1860 was doing `(int)$data['orientation_id']` which converts SQL null → PHP `null` → `(int)null` = `0`. Since no row with ID 0 exists in `orientations`, this triggers FK violation. Fixed in commit `55088c94` by using `$v ? (int)$v : null` pattern.
|
||||
|
||||
**Still happening after fix** — suggests another code path has same issue, or the fix wasn't complete. The `updateThesis` path was already safe (uses `?: null`), but the error persists.
|
||||
|
||||
**Debugging added:**
|
||||
- `[DB:updateThesis]` log line with all FK values before query (commit `55088c94`)
|
||||
- `[ThesisEdit] Step 1-6 OK` step-level logging (commit `8734d964`)
|
||||
- `ErrorHandler::log()` with full trace on catch (commit `03ad73f3`)
|
||||
|
||||
**Dev server output:** No error_log visible in dev mode — PHP built-in server sends errors to stderr which may not be captured.
|
||||
|
||||
**Next steps:**
|
||||
- Enable `display_errors=1` in dev mode so FK errors render in browser
|
||||
- Check if `setThesisFormats`, `setThesisLanguages`, `setThesisTags` paths also have `(int)null` → `0` issues
|
||||
- Check `formulaire.php` action file — does it also use `createThesis`?
|
||||
|
||||
---
|
||||
|
||||
## 2. Dev server debugging output
|
||||
|
||||
**Symptom:** No error output visible in browser when PHP crashes.
|
||||
|
||||
**Current config** (`bootstrap.php`):
|
||||
- Dev mode (cli-server): `display_errors=1`, `error_reporting=E_ALL`
|
||||
- Production: `display_errors=0`, `log_errors=1`
|
||||
|
||||
**But:** the admin action files override this:
|
||||
- `formulaire.php` line 5-7: `ini_set('display_errors', 0); ini_set('log_errors', 1);`
|
||||
- `edit.php` action: no override (uses bootstrap defaults)
|
||||
|
||||
**Action needed:** Don't suppress display_errors in dev mode. Check `php_sapi_name()` before overriding.
|
||||
|
||||
---
|
||||
|
||||
## 3. Console warnings
|
||||
|
||||
```
|
||||
Layout was forced before the page was fully loaded. node.js:416:1
|
||||
[file-upload-queue] XamxamInitFileUploads called (twice)
|
||||
```
|
||||
- `file-upload-queue.js` called twice — the script might be included twice (check `add.php` template + the `form.php` partial)
|
||||
|
||||
---
|
||||
|
||||
## 4. Tags: lowercase + dedup + CSV import
|
||||
|
||||
**Status:** Implemented across all paths:
|
||||
- Frontend JS: `normalizeTag()` with `replace(/\s+/g, ' ')`, lowercase
|
||||
- Server fragment: `preg_replace('/\s+/', ' ', strtolower(...))`
|
||||
- Both controllers: `fn(string $t) => strtolower(trim(preg_replace('/\s+/', ' ', $t)))`
|
||||
- CSV import: same normalization (commit `8734d964`)
|
||||
- Minimum 3 tags enforced (commit `8734d964`)
|
||||
|
||||
## 5. ErrorHandler coverage
|
||||
|
||||
**Status:** Applied to 12 admin action files + 6 public controllers + 2 form controllers + partage entry point (commit `03ad73f3`). 77 unit test assertions.
|
||||
|
||||
## Relevant commits (most recent first)
|
||||
|
||||
```
|
||||
55088c94 Fix FK violation: (int)null → 0 in createThesis
|
||||
27378b42 ErrorHandler tests: 77 assertions
|
||||
4d9296fd ErrorHandler: precise FK field extraction from SQLite
|
||||
03ad73f3 ErrorHandler: shared logging across all actions/controllers
|
||||
6b6c62d1 Error logging: step-by-step transaction tracing
|
||||
8734d964 Mots-clés: collapse spaces, minimum 3 keywords
|
||||
dfe1186b Mots-clés: lowercase, dedup, keyboard nav, absolute dropdown
|
||||
8d04d4ba Mots-clés: lowercase enforcement, deduplication, absolute dropdown
|
||||
7fe53f8c Mots-clés: interactive HTMX tag search
|
||||
dd110cc5 Admin mobile block: fix inline style beating media query
|
||||
```
|
||||
|
||||
## Key files to review
|
||||
|
||||
- `app/src/Database.php` — `createThesis()` line ~1830, `updateThesis()` line ~1751, `setThesisFormats/Languages/Tags`
|
||||
- `app/src/Controllers/ThesisCreateController.php` — `submit()` line ~146, `validateAndSanitise()` line ~312
|
||||
- `app/src/Controllers/ThesisEditController.php` — `save()` line ~158
|
||||
- `app/public/admin/actions/formulaire.php` — calls `$ctrl->submit()` (create path)
|
||||
- `app/public/admin/actions/edit.php` — calls `$ctrl->save()` (edit path)
|
||||
- `app/public/admin/index.php` — CSV import at line ~220
|
||||
3
TODO.md
3
TODO.md
@@ -66,3 +66,6 @@
|
||||
- [x] Admin mobile block screen: fix inline style beating media query, use CSS default display:none instead
|
||||
- [x] Fix deploy: add missing 023b migration to rename cc4r→cc2r, make run.php skip 'no such column' errors
|
||||
- [x] Fix FK constraint violation on edit: pass null instead of 0 for absent orientation/ap/finality
|
||||
- [x] Mots-clés: interactive tag search with HTMX suggestions, pill display, round bin-icon remove buttons
|
||||
- [x] Mots-clés: lowercase enforcement, deduplication, absolute dropdown, keyboard arrows/enter/escape, blur hide, spacing + counter above input, CSV import lowercased, space-collapse normalization, minimum 3 keywords required
|
||||
- [x] ErrorHandler: shared static helper for structured error_log + user-friendly messages with precise FK field extraction from SQLite errors. Applied to 12 action files + 6 public controllers + 2 form controllers + partage. Covers FK, UNIQUE, NOT NULL constraint types.
|
||||
|
||||
@@ -18,6 +18,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/SmtpRelay.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$db = Database::getInstance();
|
||||
$logger = AdminLogger::make();
|
||||
@@ -59,7 +60,7 @@ try {
|
||||
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
|
||||
App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}.");
|
||||
} catch (SmtpSendException $e) {
|
||||
error_log('[access-request] Email delivery failed after approval: ' . $e->getMessage());
|
||||
ErrorHandler::log('access_request_smtp', $e, ['request_id' => $requestId]);
|
||||
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
|
||||
$smtpMsg = $e->isRecipientRejected()
|
||||
? "Demande approuvée, mais l'email n'a pas pu être délivré : adresse inconnue ({$request['email']})."
|
||||
@@ -78,7 +79,7 @@ try {
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('Access request action failed: ' . $e->getMessage());
|
||||
ErrorHandler::log('access_request', $e);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => 'Erreur lors du traitement']);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ if (!in_array($aproposKey, $allowedKeys)) {
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
try {
|
||||
$db = new Database();
|
||||
@@ -54,8 +55,8 @@ try {
|
||||
AdminLogger::make()->logAproposEdit($aproposKey);
|
||||
App::flash('success', "Contenu « $aproposKey » mis à jour avec succès.");
|
||||
} catch (Exception $e) {
|
||||
error_log("Apropos save error: " . $e->getMessage());
|
||||
die("Erreur lors de la sauvegarde : " . htmlspecialchars($e->getMessage()));
|
||||
ErrorHandler::log('apropos', $e);
|
||||
die('Erreur lors de la sauvegarde : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
|
||||
}
|
||||
|
||||
header('Location: /admin/contenus.php');
|
||||
|
||||
@@ -6,6 +6,7 @@ AdminAuth::requireLogin();
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
// CSRF validation
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
@@ -57,8 +58,8 @@ try {
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('delete.php error: ' . $e->getMessage());
|
||||
App::flash('error', 'Erreur lors de la suppression : ' . $e->getMessage());
|
||||
ErrorHandler::log('thesis_delete', $e);
|
||||
App::flash('error', 'Erreur lors de la suppression : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -26,6 +26,7 @@ if ($thesisId <= 0) {
|
||||
|
||||
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
try {
|
||||
$ctrl = ThesisEditController::create();
|
||||
@@ -41,9 +42,8 @@ try {
|
||||
exit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Edit action error: " . $e->getMessage());
|
||||
|
||||
App::flash('error', $e->getMessage());
|
||||
ErrorHandler::log('thesis_edit', $e, ['thesis_id' => $thesisId]);
|
||||
App::flash('error', ErrorHandler::userMessage($e));
|
||||
|
||||
// WCAG 3.3.1 — map error message to field name for autofocus on re-render.
|
||||
$autofocusField = ThesisEditController::autofocusFieldForError($e->getMessage());
|
||||
|
||||
@@ -15,6 +15,7 @@ AdminAuth::requireLogin();
|
||||
|
||||
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
try {
|
||||
$controller = ExportController::create();
|
||||
@@ -40,9 +41,9 @@ try {
|
||||
// Clean up temp file
|
||||
@unlink($zipPath);
|
||||
} catch (Exception $e) {
|
||||
error_log('Files export error: ' . $e->getMessage());
|
||||
ErrorHandler::log('export_files', $e);
|
||||
http_response_code(500);
|
||||
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars($e->getMessage()));
|
||||
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
|
||||
}
|
||||
|
||||
exit;
|
||||
|
||||
@@ -15,6 +15,7 @@ AdminAuth::requireLogin();
|
||||
|
||||
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$doCsv = !empty($_GET['csv']);
|
||||
$doFiles = !empty($_GET['files']);
|
||||
@@ -69,9 +70,9 @@ if ($doFiles) {
|
||||
readfile($zipPath);
|
||||
@unlink($zipPath);
|
||||
} catch (Exception $e) {
|
||||
error_log('Files export error: ' . $e->getMessage());
|
||||
ErrorHandler::log('export', $e);
|
||||
http_response_code(500);
|
||||
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars($e->getMessage()));
|
||||
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ $content = $_POST['content'] ?? '';
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
$db = new Database();
|
||||
|
||||
if (!in_array($key, Database::FORM_HELP_KEYS, true)) {
|
||||
@@ -32,8 +33,8 @@ try {
|
||||
AdminLogger::make()->logFormStructureEdit($key);
|
||||
App::flash('success', 'Bloc « ' . htmlspecialchars($key) . ' » mis à jour.');
|
||||
} catch (Exception $e) {
|
||||
error_log('form-help save error: ' . $e->getMessage());
|
||||
App::flash('error', 'Erreur lors de la sauvegarde : ' . htmlspecialchars($e->getMessage()));
|
||||
ErrorHandler::log('form_help', $e);
|
||||
App::flash('error', 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -26,6 +26,7 @@ require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
|
||||
require_once APP_ROOT . '/src/AppLogger.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$logger = new AppLogger();
|
||||
$adminLogger = AdminLogger::make();
|
||||
@@ -47,8 +48,7 @@ try {
|
||||
|
||||
} catch (DuplicateThesisException $e) {
|
||||
$logger->logDuplicate('admin', $authorName, $e->existingThesisId, $e->existingIdentifier);
|
||||
|
||||
error_log('ThesisCreateController duplicate: ' . $e->getMessage());
|
||||
ErrorHandler::log('thesis_create_duplicate', $e, ['author' => $authorName]);
|
||||
|
||||
// Build a warning with a clickable link to the existing thesis.
|
||||
$existingUrl = htmlspecialchars('/admin/edit.php?id=' . $e->existingThesisId);
|
||||
@@ -68,10 +68,8 @@ try {
|
||||
'author' => $authorName,
|
||||
'post_keys' => array_keys($_POST),
|
||||
]);
|
||||
|
||||
error_log('ThesisCreateController error: ' . $e->getMessage());
|
||||
|
||||
App::flash('error', $e->getMessage());
|
||||
ErrorHandler::log('thesis_create', $e, ['author' => $authorName]);
|
||||
App::flash('error', ErrorHandler::userMessage($e));
|
||||
$_SESSION['form_data'] = $_POST;
|
||||
|
||||
$redirect = '../add.php';
|
||||
|
||||
@@ -25,6 +25,7 @@ if (!in_array($slug, $allowedSlugs, true)) {
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
$db = new Database();
|
||||
|
||||
try {
|
||||
@@ -32,8 +33,8 @@ try {
|
||||
AdminLogger::make()->logPageEdit($slug);
|
||||
App::flash('success', 'Page « ' . htmlspecialchars($slug) . ' » mise à jour.');
|
||||
} catch (Exception $e) {
|
||||
error_log('page save error: ' . $e->getMessage());
|
||||
App::flash('error', 'Erreur lors de la sauvegarde : ' . htmlspecialchars($e->getMessage()));
|
||||
ErrorHandler::log('page', $e);
|
||||
App::flash('error', 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -6,6 +6,7 @@ AdminAuth::requireLogin();
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||
@@ -61,8 +62,8 @@ try {
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('publish.php error: ' . $e->getMessage());
|
||||
App::flash('error', 'Erreur lors de la modification : ' . $e->getMessage());
|
||||
ErrorHandler::log('thesis_publish', $e);
|
||||
App::flash('error', 'Erreur lors de la modification : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -12,6 +12,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
try {
|
||||
$db = new Database();
|
||||
@@ -48,7 +49,8 @@ try {
|
||||
|
||||
App::flash('success', "Opération effectuée.");
|
||||
} catch (Exception $e) {
|
||||
App::flash('error', $e->getMessage());
|
||||
ErrorHandler::log('tag', $e);
|
||||
App::flash('error', ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
header('Location: /admin/tags.php');
|
||||
|
||||
@@ -12,6 +12,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||
|
||||
$action = $_POST['action'] ?? ''; // 'set_visibility'
|
||||
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||
@@ -51,8 +52,8 @@ try {
|
||||
App::flash('success', "Visibilité mise à jour.");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("visibility.php error: " . $e->getMessage());
|
||||
App::flash('error', "Erreur : " . $e->getMessage());
|
||||
ErrorHandler::log('visibility', $e);
|
||||
App::flash('error', 'Erreur : ' . ErrorHandler::userMessage($e));
|
||||
}
|
||||
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -39,7 +39,9 @@ function withAutofocus(string $fieldName, array $attrs = []): array {
|
||||
|
||||
function old($key, $default = "") {
|
||||
global $formData;
|
||||
return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default;
|
||||
if (!isset($formData[$key])) return $default;
|
||||
if (is_array($formData[$key])) return $formData[$key]; // Return raw array for callers that handle it
|
||||
return htmlspecialchars($formData[$key]);
|
||||
}
|
||||
|
||||
function wasSelected($key, $value) {
|
||||
|
||||
@@ -23,7 +23,9 @@ $helpFn = fn(string $key) => empty($helpBlocks[$key]['enabled']) ? '' : ($helpBl
|
||||
|
||||
function old($key, $default = "") {
|
||||
global $formData;
|
||||
return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default;
|
||||
if (!isset($formData[$key])) return $default;
|
||||
if (is_array($formData[$key])) return $formData[$key]; // Return raw array for callers that handle it
|
||||
return htmlspecialchars($formData[$key]);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -330,13 +330,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
}
|
||||
}
|
||||
if (!empty($keywordsRaw)) {
|
||||
foreach (array_slice(array_map('trim', explode(',', $keywordsRaw)), 0, 10) as $kw) {
|
||||
if ($kw) {
|
||||
$tId = $importDb->findOrCreateTag($kw);
|
||||
if ($tId) {
|
||||
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
|
||||
$s->execute([$thesisId, $tId]);
|
||||
}
|
||||
$normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
|
||||
$tags = array_values(array_unique(array_map($normalizeTag, explode(',', $keywordsRaw))));
|
||||
$tags = array_filter($tags, fn($t) => $t !== '');
|
||||
foreach (array_slice($tags, 0, 10) as $kw) {
|
||||
$tId = $importDb->findOrCreateTag($kw);
|
||||
if ($tId) {
|
||||
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
|
||||
$s->execute([$thesisId, $tId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
app/public/admin/tag-search-fragment.php
Normal file
14
app/public/admin/tag-search-fragment.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* tag-search-fragment.php (admin)
|
||||
*
|
||||
* HTMX fragment: returns matching tag suggestions for the mot-clé
|
||||
* interactive search input. Admin-auth gated wrapper.
|
||||
*/
|
||||
require_once __DIR__ . '/../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
App::boot();
|
||||
|
||||
require_once APP_ROOT . '/public/partage/tag-search-fragment.php';
|
||||
@@ -1006,3 +1006,216 @@ a.recap-file-name:hover {
|
||||
.btn-secondary {
|
||||
/* deprecated alias for .btn--secondary; kept for backward-compat */
|
||||
}
|
||||
|
||||
/* ── Tag search (mots-clés interactive component) ──────────────────────── */
|
||||
|
||||
/* The outer container may be placed inside a fieldset or as a direct child
|
||||
of .admin-form. Use a generic vertical layout that works in both contexts. */
|
||||
[id$="-search-container"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
|
||||
[id$="-search-container"] > .admin-row-label {
|
||||
font-size: var(--step--1);
|
||||
padding-top: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tag-search-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Hint text above input */
|
||||
.tag-search-hint {
|
||||
display: block;
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-3xs);
|
||||
}
|
||||
|
||||
/* Counter (e.g. "3/10") */
|
||||
.tag-search-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
font-size: var(--step--2);
|
||||
}
|
||||
|
||||
.tag-search-count {
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
background: var(--accent-muted, rgba(149, 87, 181, 0.1));
|
||||
padding: 1px var(--space-2xs);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.tag-search-max-msg {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Pill row — spacing around selected tags area */
|
||||
.tag-search-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2xs);
|
||||
min-height: 0;
|
||||
padding: var(--space-2xs) 0;
|
||||
}
|
||||
|
||||
.tag-search-pills:empty {
|
||||
padding: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Individual pill */
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3xs);
|
||||
padding: 2px 2px 2px var(--space-2xs);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 999px;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.tag-pill:hover {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tag-pill-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Round remove button */
|
||||
.tag-pill-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tag-pill-remove:hover {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tag-pill-remove svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Search input */
|
||||
.tag-search-input-wrap {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tag-search-input {
|
||||
width: 100%;
|
||||
font-size: var(--step--1);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Suggestions dropdown — absolute positioned to hover above content */
|
||||
.tag-search-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tag-search-suggestions:not(:empty) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tag-search-empty {
|
||||
padding: var(--space-2xs) var(--space-xs);
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Individual suggestion item */
|
||||
.tag-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
width: 100%;
|
||||
padding: var(--space-2xs) var(--space-xs);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-family: inherit;
|
||||
font-size: var(--step--1);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tag-search-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tag-search-item:hover,
|
||||
.tag-search-item:focus,
|
||||
.tag-search-item--highlight {
|
||||
background: var(--bg-secondary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tag-search-item--highlight {
|
||||
background: var(--accent-muted, rgba(149, 87, 181, 0.1));
|
||||
}
|
||||
|
||||
.tag-search-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag-search-item-count {
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-search-item--create {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 500;
|
||||
border-bottom-style: dashed;
|
||||
}
|
||||
|
||||
.tag-search-item--create:hover,
|
||||
.tag-search-item--create.tag-search-item--highlight {
|
||||
background: var(--accent-muted, rgba(149, 87, 181, 0.08));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ if ($slug === 'licence-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Special route: /partage/tag-search-fragment (HTMX fragment — interactive tag search)
|
||||
if ($slug === 'tag-search-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
App::boot();
|
||||
require_once __DIR__ . '/tag-search-fragment.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Special route: /partage/recapitulatif?id=N
|
||||
if ($slug === 'recapitulatif' || $slug === 'recapitulatif.php') {
|
||||
App::boot();
|
||||
@@ -440,6 +447,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
require_once APP_ROOT . '/src/StudentEmail.php';
|
||||
require_once APP_ROOT . '/src/AppLogger.php';
|
||||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$logger = new AppLogger();
|
||||
$authorName = $_POST['auteurice'] ?? 'unknown';
|
||||
@@ -467,6 +475,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
$emailSent = StudentEmail::sendConfirmation(Database::getInstance(), $thesisId, $_POST);
|
||||
$_SESSION['share_email_sent'] = $emailSent;
|
||||
} catch (SmtpSendException $e) {
|
||||
ErrorHandler::log('partage_smtp', $e, ['slug' => $slug, 'thesis_id' => $thesisId]);
|
||||
if ($e->isRecipientRejected()) {
|
||||
$_SESSION['share_email_retry_thesis'] = $thesisId;
|
||||
$_SESSION['share_email_retry_error'] = $e->smtpResponse;
|
||||
@@ -485,8 +494,7 @@ function handleShareLinkSubmission(string $slug): void
|
||||
$logger->logDuplicate('partage', $authorName, $e->existingThesisId, $e->existingIdentifier, [
|
||||
'share_slug' => $slug,
|
||||
]);
|
||||
|
||||
error_log('Share link duplicate submission: ' . $e->getMessage());
|
||||
ErrorHandler::log('partage_duplicate', $e, ['slug' => $slug, 'author' => $authorName]);
|
||||
|
||||
// Repopulate the form and surface a clear warning to the student.
|
||||
// Store as plain text — htmlspecialchars() is applied at render time.
|
||||
@@ -505,10 +513,9 @@ function handleShareLinkSubmission(string $slug): void
|
||||
'author' => $authorName,
|
||||
'post_keys' => array_keys($_POST),
|
||||
]);
|
||||
ErrorHandler::log('partage_submit', $e, ['slug' => $slug, 'author' => $authorName]);
|
||||
|
||||
error_log('Share link submission error: ' . $e->getMessage());
|
||||
|
||||
$_SESSION['_flash_error'] = $e->getMessage();
|
||||
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e);
|
||||
$_SESSION['form_data_share_' . $slug] = $_POST;
|
||||
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
|
||||
|
||||
|
||||
67
app/public/partage/tag-search-fragment.php
Normal file
67
app/public/partage/tag-search-fragment.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* tag-search-fragment.php
|
||||
*
|
||||
* Shared HTMX fragment: returns matching tag suggestions for the mot-clé
|
||||
* interactive search input.
|
||||
*
|
||||
* Included by:
|
||||
* - /admin/tag-search-fragment.php (AdminAuth gated)
|
||||
* - partage/index.php special route (public, session already booted)
|
||||
*
|
||||
* Expected POST:
|
||||
* q — search query string (partial tag name)
|
||||
* tag[] — already selected tag names (sent via hx-include, to exclude from suggestions)
|
||||
*/
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
$query = trim(preg_replace('/\s+/', ' ', strtolower($_POST['q'] ?? '')));
|
||||
$currentTags = isset($_POST['tag']) && is_array($_POST['tag'])
|
||||
? array_map(function($t) { return trim(preg_replace('/\s+/', ' ', strtolower($t))); }, $_POST['tag'])
|
||||
: [];
|
||||
|
||||
$db = Database::getInstance();
|
||||
$results = $db->searchTags($query);
|
||||
|
||||
// Deduplicate results by lowercase name
|
||||
$seen = [];
|
||||
$results = array_values(array_filter($results, function($tag) use (&$seen) {
|
||||
$key = strtolower($tag['name']);
|
||||
if (isset($seen[$key])) return false;
|
||||
$seen[$key] = true;
|
||||
return true;
|
||||
}));
|
||||
|
||||
// Filter out already-selected tags (case-insensitive)
|
||||
$results = array_values(array_filter($results, function($tag) use ($currentTags) {
|
||||
return !in_array(strtolower($tag['name']), $currentTags, true);
|
||||
}));
|
||||
|
||||
// Check if query exactly matches an existing tag (case-insensitive)
|
||||
$exactExists = false;
|
||||
foreach ($results as $tag) {
|
||||
if (strcasecmp($tag['name'], $query) === 0) {
|
||||
$exactExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match and query non-empty, suggest creation
|
||||
$canCreate = ($query !== '' && !$exactExists && !in_array($query, $currentTags, true));
|
||||
?>
|
||||
<?php if (empty($results) && !$canCreate): ?>
|
||||
<div class="tag-search-empty">Aucun mot-clé trouvé.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($results as $tag): ?>
|
||||
<button type="button" class="tag-search-item" data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name']) ?>">
|
||||
<span class="tag-search-item-name"><?= htmlspecialchars($tag['name']) ?></span>
|
||||
<span class="tag-search-item-count">(<?= (int)$tag['thesis_count'] ?>)</span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($canCreate): ?>
|
||||
<button type="button" class="tag-search-item tag-search-item--create" data-tag-name="<?= htmlspecialchars($query) ?>">
|
||||
<span class="tag-search-item-name">Créer « <?= htmlspecialchars($query) ?> »</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/Parsedown.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
class AboutController
|
||||
{
|
||||
@@ -24,7 +25,7 @@ class AboutController
|
||||
$contacts = $db->getAproposContent('contacts');
|
||||
$contacts = is_array($contacts) && !empty($contacts) ? $contacts : null;
|
||||
} catch (Exception $e) {
|
||||
error_log('Error loading about page: ' . $e->getMessage());
|
||||
ErrorHandler::log('about_page', $e);
|
||||
$rawContent = $this->defaultContent;
|
||||
$contacts = null;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ class HomeController
|
||||
public static function create(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
return new self(Database::getInstance());
|
||||
}
|
||||
@@ -98,7 +99,7 @@ class HomeController
|
||||
);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('HomeController: ' . $e->getMessage());
|
||||
ErrorHandler::log('home', $e);
|
||||
// Return safe empty state; view will show "Aucun mémoire trouvé"
|
||||
$isDefaultView = false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/Parsedown.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
class LicenceController
|
||||
{
|
||||
@@ -18,7 +19,7 @@ class LicenceController
|
||||
$content = $dbPage ? $dbPage['content'] : '';
|
||||
$pageTitle = $dbPage ? $dbPage['title'] : 'Licences';
|
||||
} catch (Exception $e) {
|
||||
error_log('Error loading licence page: ' . $e->getMessage());
|
||||
ErrorHandler::log('licence_page', $e);
|
||||
$content = '';
|
||||
$pageTitle = 'Licences';
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class MediaController
|
||||
// 3. Visibility gate for thesis files
|
||||
if (preg_match('#^theses/#', $requestedPath)) {
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
try {
|
||||
$mediaDb = Database::getInstance();
|
||||
$accessTypeId = $mediaDb->getFileVisibility($requestedPath);
|
||||
@@ -61,7 +62,7 @@ class MediaController
|
||||
exit;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MediaController visibility check error: ' . $e->getMessage());
|
||||
ErrorHandler::log('media_visibility', $e, ['path' => $path]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class SearchController
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/RateLimit.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
$rateLimit = new RateLimit(
|
||||
self::RATE_LIMIT_MAX,
|
||||
@@ -105,7 +106,7 @@ class SearchController
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$validationError = $e->getMessage();
|
||||
} catch (Exception $e) {
|
||||
error_log('SearchController: ' . $e->getMessage());
|
||||
ErrorHandler::log('search', $e);
|
||||
$validationError = 'Une erreur est survenue.';
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ class SearchController
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$validationError = $e->getMessage();
|
||||
} catch (Exception $e) {
|
||||
error_log('SearchController: ' . $e->getMessage());
|
||||
ErrorHandler::log('repertoire', $e);
|
||||
$validationError = 'Une erreur est survenue.';
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class TfeController
|
||||
public static function create(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
return new self(Database::getInstance());
|
||||
}
|
||||
@@ -64,7 +65,7 @@ class TfeController
|
||||
try {
|
||||
$data = $this->db->getThesisById($thesisId);
|
||||
} catch (Exception $e) {
|
||||
error_log('TfeController: ' . $e->getMessage());
|
||||
ErrorHandler::log('tfe_view', $e, ['thesis_id' => $thesisId]);
|
||||
$this->redirectHome();
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ class ThesisCreateController
|
||||
public static function make(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
return new self(new Database());
|
||||
}
|
||||
@@ -198,17 +199,28 @@ class ThesisCreateController
|
||||
]);
|
||||
|
||||
$identifier = $this->db->getThesisIdentifier($thesisId);
|
||||
error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . ' author(s)');
|
||||
error_log("[ThesisCreate] Step 1 OK — thesis_id=$thesisId ($identifier) | authors=" . count($authorEntries));
|
||||
|
||||
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
||||
error_log("[ThesisCreate] Step 2 OK — authors=" . json_encode($data['authorNames']));
|
||||
|
||||
$this->db->setThesisJury($thesisId, $data['juryMembers']);
|
||||
error_log("[ThesisCreate] Step 3 OK — jury=" . count($data['juryMembers']));
|
||||
|
||||
$this->db->setThesisLanguages($thesisId, $data['languageIds']);
|
||||
error_log("[ThesisCreate] Step 4 OK — languages=" . json_encode($data['languageIds']));
|
||||
|
||||
$this->db->setThesisFormats($thesisId, $data['formatIds']);
|
||||
error_log("[ThesisCreate] Step 5 OK — formats=" . json_encode($data['formatIds']));
|
||||
|
||||
$this->db->setThesisTags($thesisId, $data['keywords']);
|
||||
error_log("[ThesisCreate] Step 6 OK — tags=" . json_encode($data['keywords']));
|
||||
|
||||
$this->db->commit();
|
||||
error_log("[ThesisCreate] COMMIT OK — thesis_id=$thesisId");
|
||||
|
||||
} catch (Exception $e) {
|
||||
ErrorHandler::log('thesis_create_tx', $e, ['thesis_id' => $thesisId ?? null]);
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
@@ -420,12 +432,31 @@ class ThesisCreateController
|
||||
throw new Exception('Veuillez indiquer au moins un·e lecteur·ice externe.');
|
||||
}
|
||||
|
||||
// Keywords (max 10)
|
||||
$tagRaw = $this->sanitiseString($post['tag'] ?? '');
|
||||
$keywords = $tagRaw !== '' ? array_map('trim', explode(',', $tagRaw)) : [];
|
||||
// Keywords (max 10, min 3) — lowercased, spaces collapsed, deduplicated
|
||||
$keywords = [];
|
||||
$normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
|
||||
if (isset($post['tag']) && is_array($post['tag'])) {
|
||||
$keywords = array_values(array_unique(array_map(
|
||||
$normalizeTag,
|
||||
array_map(fn($t) => (string)$t, $post['tag'])
|
||||
)));
|
||||
$keywords = array_filter($keywords, fn($t) => $t !== '');
|
||||
$keywords = array_slice($keywords, 0, 10);
|
||||
} else {
|
||||
$tagRaw = $this->sanitiseString($post['tag'] ?? '');
|
||||
if ($tagRaw !== '') {
|
||||
$keywords = array_map($normalizeTag, explode(',', $tagRaw));
|
||||
}
|
||||
}
|
||||
$keywords = array_values(array_unique($keywords));
|
||||
$keywords = array_filter($keywords, fn($t) => $t !== '');
|
||||
$keywords = array_slice($keywords, 0, 10);
|
||||
if (count($keywords) > 10) {
|
||||
throw new Exception('Maximum 10 mots-clés autorisés.');
|
||||
}
|
||||
if (count($keywords) < 3) {
|
||||
throw new Exception('Veuillez indiquer au moins 3 mots-clés.');
|
||||
}
|
||||
|
||||
// Languages (at least one required)
|
||||
$languageIds = isset($post['languages']) && is_array($post['languages'])
|
||||
|
||||
@@ -36,6 +36,7 @@ class ThesisEditController
|
||||
public static function create(?Database $db = null): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
|
||||
return new self($db ?? Database::getInstance());
|
||||
}
|
||||
@@ -192,7 +193,7 @@ class ThesisEditController
|
||||
|
||||
try {
|
||||
// ── 1. Thesis metadata ────────────────────────────────────────────
|
||||
$this->db->updateThesis($thesisId, [
|
||||
$meta = [
|
||||
'title' => trim($post['titre'] ?? ''),
|
||||
'subtitle' => trim($post['subtitle'] ?? ''),
|
||||
'year' => intval($post['année'] ?? 0),
|
||||
@@ -211,7 +212,9 @@ class ThesisEditController
|
||||
'exemplaire_erg' => !empty($post['exemplaire_erg']),
|
||||
'cc2r' => !empty($post['cc2r']),
|
||||
'license_custom' => trim($post['license_custom'] ?? ''),
|
||||
]);
|
||||
];
|
||||
$this->db->updateThesis($thesisId, $meta);
|
||||
error_log('[ThesisEdit] Step 1 OK — thesis_id=' . $thesisId);
|
||||
|
||||
// ── 2. Authors (alphabetically sorted) ─────────────────────────────
|
||||
$authorsRaw = trim($post['auteurice'] ?? '');
|
||||
@@ -230,10 +233,12 @@ class ThesisEditController
|
||||
];
|
||||
}
|
||||
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
||||
error_log('[ThesisEdit] Step 2 OK — authors=' . json_encode($authorNames));
|
||||
|
||||
// ── 3. Jury ───────────────────────────────────────────────────────
|
||||
$juryMembers = $this->collectJuryMembers($post);
|
||||
$this->db->setThesisJury($thesisId, $juryMembers);
|
||||
error_log('[ThesisEdit] Step 3 OK — jury=' . count($juryMembers));
|
||||
|
||||
// ── 4. Languages ──────────────────────────────────────────────────
|
||||
$langIds = isset($post['languages']) && is_array($post['languages'])
|
||||
@@ -248,25 +253,43 @@ class ThesisEditController
|
||||
}
|
||||
}
|
||||
$this->db->setThesisLanguages($thesisId, $langIds);
|
||||
error_log('[ThesisEdit] Step 4 OK — languages=' . json_encode($langIds));
|
||||
|
||||
// ── 5. Formats ────────────────────────────────────────────────────
|
||||
$this->db->setThesisFormats(
|
||||
$thesisId,
|
||||
isset($post['formats']) && is_array($post['formats'])
|
||||
? $post['formats']
|
||||
: []
|
||||
);
|
||||
$formatIds = isset($post['formats']) && is_array($post['formats'])
|
||||
? $post['formats']
|
||||
: [];
|
||||
$this->db->setThesisFormats($thesisId, $formatIds);
|
||||
error_log('[ThesisEdit] Step 5 OK — formats=' . json_encode($formatIds));
|
||||
|
||||
// ── 6. Tags ───────────────────────────────────────────────────────
|
||||
$keywordsRaw = trim($post['tag'] ?? '');
|
||||
$keywords = $keywordsRaw !== ''
|
||||
? array_map('trim', explode(',', $keywordsRaw))
|
||||
: [];
|
||||
$normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
|
||||
$keywords = [];
|
||||
if (isset($post['tag']) && is_array($post['tag'])) {
|
||||
$keywords = array_values(array_unique(array_map(
|
||||
$normalizeTag,
|
||||
array_map(fn($t) => (string)$t, $post['tag'])
|
||||
)));
|
||||
} else {
|
||||
$keywordsRaw = trim($post['tag'] ?? '');
|
||||
if ($keywordsRaw !== '') {
|
||||
$keywords = array_map($normalizeTag, explode(',', $keywordsRaw));
|
||||
}
|
||||
}
|
||||
$keywords = array_values(array_unique($keywords));
|
||||
$keywords = array_filter($keywords, fn($t) => $t !== '');
|
||||
$keywords = array_slice($keywords, 0, 10);
|
||||
if (count($keywords) < 3) {
|
||||
throw new Exception('Veuillez indiquer au moins 3 mots-clés.');
|
||||
}
|
||||
$this->db->setThesisTags($thesisId, $keywords);
|
||||
error_log('[ThesisEdit] Step 6 OK — tags=' . json_encode($keywords));
|
||||
|
||||
$this->db->commit();
|
||||
error_log('[ThesisEdit] COMMIT OK — thesis_id=' . $thesisId);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ErrorHandler::log('thesis_edit_tx', $e, ['thesis_id' => $thesisId]);
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
@@ -1134,6 +1134,39 @@ class Database
|
||||
// TAG MANAGEMENT (admin)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Search tags by name prefix. Returns up to 10 matching tags.
|
||||
* If $query is empty, returns the most-used tags (up to 10).
|
||||
*/
|
||||
public function searchTags(string $query = ''): array
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
$stmt = $this->pdo->query('
|
||||
SELECT tg.id, tg.name,
|
||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
||||
FROM tags tg
|
||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||
GROUP BY tg.id
|
||||
ORDER BY thesis_count DESC, tg.name COLLATE NOCASE
|
||||
LIMIT 10
|
||||
');
|
||||
} else {
|
||||
$stmt = $this->pdo->prepare('
|
||||
SELECT tg.id, tg.name,
|
||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
||||
FROM tags tg
|
||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||
WHERE tg.name LIKE ?
|
||||
GROUP BY tg.id
|
||||
ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE
|
||||
LIMIT 10
|
||||
');
|
||||
$stmt->execute([$query . '%', $query]);
|
||||
}
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all tags with a count of associated (published) theses.
|
||||
*/
|
||||
@@ -1740,19 +1773,27 @@ class Database
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
');
|
||||
$orientation = ($data['orientation_id'] ?? null) ? (int)$data['orientation_id'] : null;
|
||||
$ap = ($data['ap_program_id'] ?? null) ? (int)$data['ap_program_id'] : null;
|
||||
$finality = ($data['finality_id'] ?? null) ? (int)$data['finality_id'] : null;
|
||||
$license = $data['license_id'] ?? null;
|
||||
$access = $data['access_type_id'] ?? null;
|
||||
|
||||
error_log("[DB:updateThesis] thesis_id=$thesisId orientation=$orientation ap=$ap finality=$finality license=$license access=$access");
|
||||
|
||||
$stmt->execute([
|
||||
$data['title'],
|
||||
!empty($data['subtitle']) ? $data['subtitle'] : null,
|
||||
(int)$data['year'],
|
||||
($data['orientation_id'] ?? null) ? (int)$data['orientation_id'] : null,
|
||||
($data['ap_program_id'] ?? null) ? (int)$data['ap_program_id'] : null,
|
||||
($data['finality_id'] ?? null) ? (int)$data['finality_id'] : null,
|
||||
$orientation,
|
||||
$ap,
|
||||
$finality,
|
||||
$data['synopsis'],
|
||||
!empty($data['context_note']) ? $data['context_note'] : null,
|
||||
!empty($data['baiu_link']) ? $data['baiu_link'] : null,
|
||||
$data['license_id'] ?? null,
|
||||
$license,
|
||||
!empty($data['license_custom']) ? $data['license_custom'] : null,
|
||||
$data['access_type_id'] ?? null,
|
||||
$access,
|
||||
$data['is_published'] ? 1 : 0,
|
||||
!empty($data['remarks']) ? $data['remarks'] : null,
|
||||
isset($data['jury_points']) && $data['jury_points'] !== '' ? (float)$data['jury_points'] : null,
|
||||
@@ -1808,20 +1849,26 @@ class Database
|
||||
$validObjet = ['tfe', 'thèse', 'frart'];
|
||||
$objet = in_array($data['objet'] ?? '', $validObjet, true) ? $data['objet'] : 'tfe';
|
||||
|
||||
$orientation = $data['orientation_id'] ?? null;
|
||||
$ap = $data['ap_program_id'] ?? null;
|
||||
$finality = $data['finality_id'] ?? null;
|
||||
$license = $data['license_id'] ?? null;
|
||||
$access = $data['access_type_id'] ?? 2;
|
||||
|
||||
$stmt->execute([
|
||||
$identifier,
|
||||
$data['title'],
|
||||
!empty($data['subtitle']) ? $data['subtitle'] : null,
|
||||
(int)$data['year'],
|
||||
(int)$data['orientation_id'],
|
||||
(int)$data['ap_program_id'],
|
||||
(int)$data['finality_id'],
|
||||
$orientation ? (int)$orientation : null,
|
||||
$ap ? (int)$ap : null,
|
||||
$finality ? (int)$finality : null,
|
||||
$data['synopsis'],
|
||||
!empty($data['context_note']) ? $data['context_note'] : null,
|
||||
!empty($data['baiu_link']) ? $data['baiu_link'] : null,
|
||||
$data['license_id'] ?? null,
|
||||
$license ? (int)$license : null,
|
||||
!empty($data['license_custom']) ? $data['license_custom'] : null,
|
||||
isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne
|
||||
$access ? (int)$access : 2, // default: Interne
|
||||
$objet,
|
||||
!empty($data['remarks']) ? $data['remarks'] : null,
|
||||
isset($data['jury_points']) && $data['jury_points'] !== '' ? (float)$data['jury_points'] : null,
|
||||
|
||||
183
app/src/ErrorHandler.php
Normal file
183
app/src/ErrorHandler.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,19 @@
|
||||
\\\\\\\ to: unnnvyqs 357a2fff "Admin mobile block: fix inline style beating media query" (rebased revision)
|
||||
+ $linkName = $link['name'] ?? '';
|
||||
+ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||
%%%%%%%%%%% diff from: unnnvyqs 357a2fff "Admin mobile block: fix inline style beating media query" (rebased revision)
|
||||
\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
||||
- $linkName = $link['name'] ?? '';
|
||||
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||
%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
|
||||
\\\\\\\\\\\ to: szktqmnn bdcd30e9 "Error tests, FK violations fix" (rebased revision)
|
||||
$linkName = $link['name'] ?? '';
|
||||
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||
$linkLockedYear = $link['locked_year'] ?? null;
|
||||
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
||||
+\\\\\\\ to: szktqmnn 29b3397f "Error tests, FK violations fix" (rebased revision)
|
||||
++ $linkName = $link['name'] ?? '';
|
||||
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||
?>
|
||||
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
|
||||
<td><?= htmlspecialchars($linkName ?: '—') ?></td>
|
||||
|
||||
@@ -105,6 +105,14 @@
|
||||
// Languages — either from flash repopulation or current thesis data
|
||||
$formData['languages'] = $formData['languages'] ?? $currentLanguages ?? [];
|
||||
|
||||
// Tags — either from flash repopulation or current thesis data
|
||||
$keywordsStr = $thesis['keywords'] ?? '';
|
||||
$currentTags = $keywordsStr !== '' ? array_map('trim', explode(',', $keywordsStr)) : [];
|
||||
// If formData has tag[], use that instead
|
||||
if (!empty($formData['tag']) && is_array($formData['tag'])) {
|
||||
$currentTags = $formData['tag'];
|
||||
}
|
||||
|
||||
// Formats — either from flash repopulation or current thesis data
|
||||
$checkedFormats = $formData['formats'] ?? $currentFormats ?? [];
|
||||
// Populate formData.formats for checkbox-list partial
|
||||
|
||||
@@ -222,13 +222,36 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
|
||||
<fieldset>
|
||||
<legend>Mots-clés</legend>
|
||||
<?php
|
||||
// Build selectedTags array from form data or thesis keywords
|
||||
$_selectedTags = [];
|
||||
// If formData has tag as an array (pill-based repopulation), prefer that
|
||||
if (!empty($formData['tag']) && is_array($formData['tag'])) {
|
||||
foreach ($formData['tag'] as $_t) {
|
||||
if (is_string($_t) && trim($_t) !== '') {
|
||||
$_selectedTags[] = ['name' => trim($_t)];
|
||||
}
|
||||
}
|
||||
} elseif (!empty($currentTags) && is_array($currentTags)) {
|
||||
$_selectedTags = array_map(fn($n) => ['name' => $n], $currentTags);
|
||||
} else {
|
||||
$_tagsRaw = $formData["tag"] ?? '';
|
||||
if (is_string($_tagsRaw) && $_tagsRaw !== '') {
|
||||
foreach (array_map('trim', explode(',', $_tagsRaw)) as $_t) {
|
||||
if ($_t !== '') {
|
||||
$_selectedTags[] = ['name' => $_t];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$name = "tag";
|
||||
$label = "Mots-clés (max 10) :";
|
||||
$value = $oldFn("tag");
|
||||
$placeholder = "sociologie, anthropologie, ...";
|
||||
$hint = "Séparez par des virgules. Max 10 mots-clés.";
|
||||
$attrs = $withAutofocusFn("tag");
|
||||
include APP_ROOT . "/templates/partials/form/text-field.php";
|
||||
$label = "Mots-clés :";
|
||||
$placeholder = "Rechercher un mot-clé…";
|
||||
$hint = "Tapez pour rechercher ou créer des mots-clés.";
|
||||
$selectedTags = $_selectedTags;
|
||||
$hxPost = ($mode === 'partage') ? "/partage/tag-search-fragment" : "/admin/tag-search-fragment.php";
|
||||
include APP_ROOT . "/templates/partials/form/tag-search.php";
|
||||
unset($_tagsRaw, $_selectedTags, $_t, $name, $label, $placeholder, $hint, $selectedTags, $hxPost);
|
||||
?>
|
||||
</fieldset>
|
||||
|
||||
|
||||
236
app/templates/partials/form/tag-search.php
Normal file
236
app/templates/partials/form/tag-search.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* Tag search partial — interactive mot-clé input with HTMX-powered suggestions.
|
||||
*
|
||||
* Replaces the old comma-separated text field with an interactive component:
|
||||
* - Type to search among existing tags via HTMX
|
||||
* - If the tag doesn't exist, a "Créer" option appears
|
||||
* - Selected tags are shown as pills with a round delete button (bin icon)
|
||||
* - All keywords are lowercased and deduplicated
|
||||
*
|
||||
* Variables consumed:
|
||||
* string $name — base input name (hidden inputs will be name[]); default 'tag'
|
||||
* string $label — visible label text
|
||||
* string $placeholder — placeholder text for the search input
|
||||
* string $hint — optional hint shown above the input
|
||||
* string $hxPost — HTMX POST endpoint for tag search
|
||||
* array $selectedTags — array of ['id' => int|null, 'name' => string] for pre-filled tags
|
||||
* string|null $id — override the id attribute prefix
|
||||
* int $maxTags — maximum number of tags (default 10)
|
||||
*/
|
||||
|
||||
$name = $name ?? 'tag';
|
||||
$label = $label ?? 'Mots-clés';
|
||||
$placeholder = $placeholder ?? 'Rechercher un mot-clé…';
|
||||
$hint = $hint ?? null;
|
||||
$hxPost = $hxPost ?? '/admin/tag-search-fragment.php';
|
||||
$selectedTags = $selectedTags ?? [];
|
||||
$id = $id ?? $name;
|
||||
$maxTags = $maxTags ?? 10;
|
||||
$tagCount = count($selectedTags);
|
||||
?>
|
||||
<div id="<?= htmlspecialchars($id) ?>-search-container">
|
||||
<span class="admin-row-label"><?= htmlspecialchars($label) ?></span>
|
||||
<div class="tag-search-wrapper">
|
||||
<?php if ($hint): ?>
|
||||
<small class="tag-search-hint"><?= htmlspecialchars($hint) ?></small>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Active tag pills -->
|
||||
<div class="tag-search-pills" id="<?= htmlspecialchars($id) ?>-pills">
|
||||
<?php foreach ($selectedTags as $tag): ?>
|
||||
<span class="tag-pill">
|
||||
<input type="hidden" name="<?= htmlspecialchars($name) ?>[]" value="<?= htmlspecialchars($tag['name']) ?>">
|
||||
<span class="tag-pill-name"><?= htmlspecialchars($tag['name']) ?></span>
|
||||
<button type="button" class="tag-pill-remove" title="Retirer « <?= htmlspecialchars($tag['name']) ?> »" aria-label="Retirer <?= htmlspecialchars($tag['name']) ?>">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>
|
||||
</button>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Counter visible only when tags exist or max reached -->
|
||||
<div class="tag-search-counter" id="<?= htmlspecialchars($id) ?>-counter"<?= $tagCount === 0 ? ' style="display:none"' : '' ?>>
|
||||
<span class="tag-search-count" id="<?= htmlspecialchars($id) ?>-count"><?= $tagCount ?>/<?= (int)$maxTags ?></span>
|
||||
<?php if ($tagCount >= $maxTags): ?>
|
||||
<span class="tag-search-max-msg">Maximum de mots-clés atteint</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Search input (hidden when max tags reached) -->
|
||||
<div class="tag-search-input-wrap"<?= $tagCount >= $maxTags ? ' style="display:none"' : '' ?>>
|
||||
<input type="text"
|
||||
name="q"
|
||||
id="<?= htmlspecialchars($id) ?>-search"
|
||||
class="tag-search-input"
|
||||
placeholder="<?= htmlspecialchars($placeholder) ?>"
|
||||
autocomplete="off"
|
||||
hx-post="<?= htmlspecialchars($hxPost) ?>"
|
||||
hx-trigger="input changed delay:200ms, focus"
|
||||
hx-target="#<?= htmlspecialchars($id) ?>-suggestions"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#<?= htmlspecialchars($id) ?>-pills">
|
||||
</div>
|
||||
|
||||
<!-- Suggestions dropdown (positioned absolutely over content) -->
|
||||
<div class="tag-search-suggestions" id="<?= htmlspecialchars($id) ?>-suggestions" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline script for the interactive behaviour (no external JS required) -->
|
||||
<script>
|
||||
(function() {
|
||||
const container = document.getElementById(<?= json_encode($id . '-search-container') ?>);
|
||||
if (!container || container._tagSearchInit) return;
|
||||
container._tagSearchInit = true;
|
||||
|
||||
const pills = document.getElementById(<?= json_encode($id . '-pills') ?>);
|
||||
const search = document.getElementById(<?= json_encode($id . '-search') ?>);
|
||||
const dropdown = document.getElementById(<?= json_encode($id . '-suggestions') ?>);
|
||||
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
|
||||
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
|
||||
const maxTags = <?= (int)$maxTags ?>;
|
||||
const inputName = <?= json_encode($name) ?>;
|
||||
let selectedIdx = -1;
|
||||
|
||||
function updateCount() {
|
||||
const n = pills.querySelectorAll('.tag-pill').length;
|
||||
if (countEl) countEl.textContent = n + '/' + maxTags;
|
||||
if (counter) counter.style.display = (n > 0) ? '' : 'none';
|
||||
|
||||
// Show/hide search input based on max
|
||||
const wrap = container.querySelector('.tag-search-input-wrap');
|
||||
const maxMsg = container.querySelector('.tag-search-max-msg');
|
||||
if (n >= maxTags) {
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
if (maxMsg) maxMsg.style.display = '';
|
||||
} else {
|
||||
if (wrap) {
|
||||
wrap.style.display = '';
|
||||
if (search) search.style.display = '';
|
||||
}
|
||||
if (maxMsg) maxMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Lowercase, collapse spaces, trim
|
||||
function normalizeTag(name) {
|
||||
return name.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
// Check if tag already exists in pills (case-insensitive)
|
||||
function tagAlreadyAdded(name) {
|
||||
const norm = normalizeTag(name);
|
||||
const existing = pills.querySelectorAll('.tag-pill-name');
|
||||
for (const el of existing) {
|
||||
if (normalizeTag(el.textContent) === norm) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove a pill
|
||||
pills.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.tag-pill-remove');
|
||||
if (!btn) return;
|
||||
const pill = btn.closest('.tag-pill');
|
||||
pill.remove();
|
||||
updateCount();
|
||||
// Re-enable search field visibility
|
||||
const wrap = container.querySelector('.tag-search-input-wrap');
|
||||
const searchInput = container.querySelector('.tag-search-input');
|
||||
if (wrap && searchInput) {
|
||||
wrap.style.display = '';
|
||||
searchInput.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Highlight a suggestion by index
|
||||
function highlight(idx) {
|
||||
const items = dropdown.querySelectorAll('.tag-search-item');
|
||||
items.forEach(function(item, i) {
|
||||
if (i === idx) {
|
||||
item.classList.add('tag-search-item--highlight');
|
||||
} else {
|
||||
item.classList.remove('tag-search-item--highlight');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select a suggestion by button element
|
||||
function selectTag(btn) {
|
||||
const tagName = normalizeTag(btn.getAttribute('data-tag-name') || '');
|
||||
if (!tagName) return;
|
||||
|
||||
if (tagAlreadyAdded(tagName)) return;
|
||||
if (pills.querySelectorAll('.tag-pill').length >= maxTags) return;
|
||||
|
||||
const escapedName = htmlEscape(tagName);
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'tag-pill';
|
||||
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escapedName + '">'
|
||||
+ '<span class="tag-pill-name">' + escapedName + '</span>'
|
||||
+ '<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' + escapedName + '\u00A0\u00BB" aria-label="Retirer ' + escapedName + '">'
|
||||
+ '<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>'
|
||||
+ '</button>';
|
||||
pills.appendChild(pill);
|
||||
updateCount();
|
||||
search.value = '';
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
search.focus();
|
||||
}
|
||||
|
||||
// Click on suggestion
|
||||
dropdown.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.tag-search-item');
|
||||
if (!btn) return;
|
||||
selectTag(btn);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
search.addEventListener('keydown', function(e) {
|
||||
const items = dropdown.querySelectorAll('.tag-search-item');
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
selectedIdx = (selectedIdx + 1) % items.length;
|
||||
} else {
|
||||
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
|
||||
}
|
||||
highlight(selectedIdx);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (items.length > 0) {
|
||||
e.preventDefault();
|
||||
if (selectedIdx >= 0 && selectedIdx < items.length) {
|
||||
selectTag(items[selectedIdx]);
|
||||
} else {
|
||||
selectTag(items[0]);
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
}
|
||||
});
|
||||
|
||||
// Hide dropdown on blur (after a tiny delay so click events fire)
|
||||
search.addEventListener('blur', function() {
|
||||
setTimeout(function() {
|
||||
if (!dropdown.contains(document.activeElement)) {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
function htmlEscape(str) {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = str;
|
||||
return el.innerHTML;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
unset($name, $label, $placeholder, $hint, $hxPost, $selectedTags, $id, $maxTags, $tagCount);
|
||||
453
app/tests/Unit/ErrorHandlerTest.php
Normal file
453
app/tests/Unit/ErrorHandlerTest.php
Normal file
@@ -0,0 +1,453 @@
|
||||
<?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;
|
||||
@@ -68,7 +68,7 @@ function buildPost(Database $db, array $overrides = []): array
|
||||
'languages' => [(string)$languages[0]['id']],
|
||||
'language_autre' => '',
|
||||
'formats' => [(string)$formatTypes[0]['id']],
|
||||
'tag' => 'art, test',
|
||||
'tag' => 'art, test, recherche',
|
||||
'jury_promoteur' => 'Prof. Smith',
|
||||
'jury_lecteur_interne' => ['Dr. Internal'],
|
||||
'jury_lecteur_externe' => ['Dr. External'],
|
||||
|
||||
@@ -15,6 +15,7 @@ $testFiles = [
|
||||
['name' => 'Rate Limit (Unit)', 'path' => __DIR__ . '/Unit/RateLimitTest.php'],
|
||||
['name' => 'Form Save Round-Trip (Unit)', 'path' => __DIR__ . '/Unit/FormSaveTest.php'],
|
||||
['name' => 'ShareLink (Unit)', 'path' => __DIR__ . '/Unit/ShareLinkTest.php'],
|
||||
['name' => 'ErrorHandler (Unit)', 'path' => __DIR__ . '/Unit/ErrorHandlerTest.php'],
|
||||
['name' => 'Pure Logic (Unit)', 'path' => __DIR__ . '/Unit/PureLogicTest.php'],
|
||||
['name' => 'Search (Integration)', 'path' => __DIR__ . '/Integration/SearchTest.php'],
|
||||
['name' => 'Security', 'path' => __DIR__ . '/Security/SecurityTest.php'],
|
||||
|
||||
Reference in New Issue
Block a user