test: add ShareLinkTest + PureLogicTest (TDD), fix coverMap undefined in SearchController

This commit is contained in:
Pontoporeia
2026-05-08 10:56:27 +02:00
parent 15d54fa19e
commit 6ba13e00ea
16 changed files with 1274 additions and 68 deletions

17
TODO.md
View File

@@ -2,9 +2,24 @@
## Completed
- [x] TDD analysis + new test suites
- [x] **Bug fixed**: `SearchController::handleSearch()``$coverMap` undefined variable + never populated for search results
- [x] `ShareLinkTest` (13 tests) — `generateSlug`, all `validateLink` branches, `verifyPassword`, `incrementUsage`, `objet_restriction`
- [x] `PureLogicTest` (31 tests) — `TfeController` helpers (meta, OG image, jury split, captions), `ThesisCreateController` helpers (autofocus, detectFileType, authorSlug), `ThesisEditController::buildFileSizeInfo`, `ExportController` CSV column consistency, `SearchController` coverMap regression
- [x] Private helpers promoted to `protected` in `TfeController`, `ThesisCreateController`, `ThesisEditController` to enable subclass-based testing without reflection
- [x] Form save audit + TDD
- [x] `createThesis()` missing `duration_pages`/`duration_minutes` columns — fixed
- [x] `ThesisCreateController` not passing raw page/minute values to `createThesis()` — fixed (`durationPages`, `durationMinutes` extracted and passed)
- [x] `FormSaveTest.php` — 14 red-green tests covering create+edit round-trips for all fields
- [x] Language form improvements
- [x] Add Néerlandais as default language option (schema + migration 017)
- [x] Make `language_autre` required only when no Langue du TFE checkbox is checked (JS in form.php, PHP server-side default)
- [x] `language_autre` conditionally required via HTMX fragment (replaced custom JS)
- [x] `language_autre` saved via `getOrCreateLanguage()` in both create and edit controllers
- [x] `formData['languages']` wired in edit.php so checkboxes are pre-checked
- [x] `duration_pages`/`duration_minutes` saved in `updateThesis()` and read back in `getThesisRawFields()`
- [x] `beforeunload-guard` applied to add and partage forms too
- [x] Merge banner images into cover images
- [x] Migration 016: copy `storage/banners/*``storage/covers/`, insert `thesis_files` cover records, clear `banner_path`, remove banners dir

View File

@@ -52,7 +52,7 @@ function wasSelected($key, $value) {
$isAdmin = true;
$bodyClass = 'admin-body';
$extraCss = ['/assets/css/form.css'];
$extraJs = ['/assets/js/sortable.min.js', '/assets/js/file-upload-queue.js'];
$extraJs = ['/assets/js/sortable.min.js', '/assets/js/file-upload-queue.js', '/assets/js/beforeunload-guard.js'];
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/admin/add.php';

View File

@@ -0,0 +1,46 @@
<?php
/**
* language-autre-fragment.php
*
* HTMX fragment: returns the "Autre(s) langue(s)" input row when no standard
* language checkbox is checked, or an empty hidden placeholder when at least
* one is checked.
*
* Expected POST:
* languages[] — selected language IDs (may be absent)
* language_autre — current free-text value (for repopulation)
*/
require_once __DIR__ . '/../bootstrap.php';
$selectedIds = isset($_POST['languages']) && is_array($_POST['languages'])
? $_POST['languages']
: [];
$currentValue = htmlspecialchars(trim($_POST['language_autre'] ?? ''));
$anyChecked = !empty($selectedIds);
?>
<div id="language-autre-row">
<?php if (!$anyChecked): ?>
<div>
<label for="language_autre">Autre(s) langue(s) : <span class="asterisk">*</span></label>
<div>
<input type="text"
id="language_autre"
name="language_autre"
value="<?= $currentValue ?>"
required>
<small>Si votre TFE contient une langue absente de la liste, précisez-la ici.</small>
</div>
</div>
<?php else: ?>
<div>
<label for="language_autre">Autre(s) langue(s) :</label>
<div>
<input type="text"
id="language_autre"
name="language_autre"
value="<?= $currentValue ?>">
<small>Si votre TFE contient une langue absente de la liste, précisez-la ici.</small>
</div>
</div>
<?php endif; ?>
</div>

View File

@@ -330,6 +330,7 @@ function renderShareLinkForm(string $slug, array $link): void
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form.css') ?>">
<script src="<?= App::assetV('/assets/js/sortable.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/file-upload-queue.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/beforeunload-guard.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/htmx.min.js') ?>" defer></script>
</head>
<body class="student-body">

View File

@@ -86,6 +86,7 @@ class SearchController
$years = [];
$orientations = [];
$apPrograms = [];
$coverMap = [];
try {
$results = $this->db->searchTheses(
@@ -98,6 +99,9 @@ class SearchController
$years = $this->db->getAvailableYears();
$orientations = $this->db->getAllOrientations();
$apPrograms = $this->db->getAllAPPrograms();
if (!empty($results)) {
$coverMap = $this->db->getCoverPathsForTheses(array_column($results, 'id'));
}
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {

View File

@@ -141,7 +141,7 @@ class TfeController
/**
* Build a ~160-character meta description from the thesis synopsis.
*/
private function buildMetaDescription(string $synopsis): string
protected function buildMetaDescription(string $synopsis): string
{
$plain = strip_tags($synopsis);
@@ -159,7 +159,7 @@ class TfeController
*
* @param array<int, array<string, mixed>> $files
*/
private function resolveOgImage(array $files): string
protected function resolveOgImage(array $files): string
{
// Prefer the dedicated cover
foreach ($files as $file) {
@@ -185,7 +185,7 @@ class TfeController
* @param array<string, mixed> $data
* @return array<string, string>
*/
private function buildOgTags(array $data, int $thesisId, string $metaDescription): array
protected function buildOgTags(array $data, int $thesisId, string $metaDescription): array
{
$ogImage = $this->resolveOgImage($data['files'] ?? []);
$title = $data['title'] . (!empty($data['authors']) ? ' ' . $data['authors'] : '');
@@ -210,7 +210,7 @@ class TfeController
* @param array<int, array<string, mixed>> $jury
* @return array{presidents: list<string>, internes: list<string>, externes: list<string>, ulb: list<string>, lecteurs_internes: list<string>, lecteurs_externes: list<string>}
*/
private function splitJuryByRole(array $jury): array
protected function splitJuryByRole(array $jury): array
{
$result = ['presidents' => [], 'internes' => [], 'externes' => [], 'ulb' => [], 'lecteurs_internes' => [], 'lecteurs_externes' => []];
@@ -253,7 +253,7 @@ class TfeController
* @param array<int, array<string, mixed>> $files
* @return list<string>
*/
private function collectCaptionPaths(array $files): array
protected function collectCaptionPaths(array $files): array
{
$captions = [];

View File

@@ -183,6 +183,8 @@ class ThesisCreateController
'synopsis' => $data['synopsis'],
'context_note' => $data['contextNote'],
'file_size_info' => $data['durationInfo'],
'duration_pages' => $data['durationPages'],
'duration_minutes'=> $data['durationMinutes'],
'baiu_link' => $data['lien'],
'license_id' => $data['licenseId'],
'license_custom' => $data['licenseCustom'],
@@ -338,14 +340,15 @@ class ThesisCreateController
$subtitle = $this->sanitiseString($post['subtitle'] ?? '');
$synopsis = $this->validateRequired($this->sanitiseString($post['synopsis'] ?? ''), 'Synopsis');
$durationInfo = $this->sanitiseString($post['duration_pages'] ?? '');
$durationPages = $this->sanitiseString($post['duration_pages'] ?? '');
$durationMinutes = $this->sanitiseString($post['duration_minutes'] ?? '');
if ($durationInfo !== '' && $durationMinutes !== '') {
$durationInfo = $durationInfo . ' pages + ' . $durationMinutes . ' minutes';
$durationInfo = '';
if ($durationPages !== '' && $durationMinutes !== '') {
$durationInfo = $durationPages . ' pages + ' . $durationMinutes . ' minutes';
} elseif ($durationMinutes !== '') {
$durationInfo = $durationMinutes . ' minutes';
} elseif ($durationInfo !== '') {
$durationInfo = $durationInfo . ' pages';
} elseif ($durationPages !== '') {
$durationInfo = $durationPages . ' pages';
}
if (!empty($post['has_annexes'])) {
$durationInfo = $durationInfo ? $durationInfo . ' + annexe(s)' : 'Annexe(s)';
@@ -430,6 +433,14 @@ class ThesisCreateController
$languageIds = isset($post['languages']) && is_array($post['languages'])
? array_map('intval', $post['languages'])
: [];
$autreRaw = trim($post['language_autre'] ?? '');
if ($autreRaw !== '') {
foreach (array_map('trim', explode(',', $autreRaw)) as $langName) {
if ($langName !== '') {
$languageIds[] = $this->db->getOrCreateLanguage($langName);
}
}
}
if (empty($languageIds)) {
throw new Exception('Veuillez sélectionner au moins une langue.');
}
@@ -508,6 +519,8 @@ class ThesisCreateController
'subtitle',
'synopsis',
'durationInfo',
'durationPages',
'durationMinutes',
'juryMembers',
'keywords',
'languageIds',
@@ -676,7 +689,7 @@ class ThesisCreateController
/**
* Determine the logical file_type from MIME type, extension, and original filename.
*/
private function detectFileType(string $mimeType, string $ext, string $originalName): string
protected function detectFileType(string $mimeType, string $ext, string $originalName): string
{
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 'caption';
@@ -725,7 +738,7 @@ class ThesisCreateController
* Generate a filesystem-safe author slug from the author name.
* Converts to uppercase, replaces spaces with underscores, removes accents.
*/
private function generateAuthorSlug(string $authorName): string
protected function generateAuthorSlug(string $authorName): string
{
// Remove accents using iconv if available, otherwise simple mapping
$normalized = $authorName;
@@ -764,7 +777,7 @@ class ThesisCreateController
* Sanitize a filename: remove accents, replace spaces with underscore, remove special chars.
* Keeps extension.
*/
private function sanitizeFilename(string $filename): string
protected function sanitizeFilename(string $filename): string
{
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$name = pathinfo($filename, PATHINFO_FILENAME);
@@ -800,7 +813,7 @@ class ThesisCreateController
* Find a unique folder name inside theses/{year}/.
* Pattern: {year}_{authorSlug} or {year}_{authorSlug}_{suffix} if exists.
*/
private function ensureUniqueFolder(int $year, string $authorSlug): string
protected function ensureUniqueFolder(int $year, string $authorSlug): string
{
$baseDir = STORAGE_ROOT . '/theses/' . $year . '/';
if (!is_dir($baseDir)) {
@@ -822,7 +835,7 @@ class ThesisCreateController
* The URL is stored in file_path; no filesystem operation is performed.
* label and sort_order from the POST are preserved.
*/
private function handleWebsiteUrl(int $thesisId, array $post): void
protected function handleWebsiteUrl(int $thesisId, array $post): void
{
$websiteUrl = trim($post['website_url'] ?? '');
if ($websiteUrl === '') {

View File

@@ -256,6 +256,8 @@ class ThesisEditController
'synopsis' => trim($post['synopsis'] ?? ''),
'context_note' => trim($post['context_note'] ?? ''),
'file_size_info' => $this->buildFileSizeInfo($post),
'duration_pages' => trim($post['duration_pages'] ?? ''),
'duration_minutes'=> trim($post['duration_minutes'] ?? ''),
'baiu_link' => trim($post['lien'] ?? ''),
'license_id' => filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null,
'access_type_id' => filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null,
@@ -291,12 +293,18 @@ class ThesisEditController
$this->db->setThesisJury($thesisId, $juryMembers);
// ── 4. Languages ──────────────────────────────────────────────────
$this->db->setThesisLanguages(
$thesisId,
isset($post['languages']) && is_array($post['languages'])
? $post['languages']
: []
);
$langIds = isset($post['languages']) && is_array($post['languages'])
? $post['languages']
: [];
$autreRaw = trim($post['language_autre'] ?? '');
if ($autreRaw !== '') {
foreach (array_map('trim', explode(',', $autreRaw)) as $langName) {
if ($langName !== '') {
$langIds[] = (string)$this->db->getOrCreateLanguage($langName);
}
}
}
$this->db->setThesisLanguages($thesisId, $langIds);
// ── 5. Formats ────────────────────────────────────────────────────
$this->db->setThesisFormats(
@@ -710,7 +718,7 @@ class ThesisEditController
/**
* Build file_size_info from separate duration fields.
*/
private function buildFileSizeInfo(array $post): string
protected function buildFileSizeInfo(array $post): string
{
$pages = trim($post['duration_pages'] ?? '');
$minutes = trim($post['duration_minutes'] ?? '');

View File

@@ -1477,6 +1477,23 @@ class Database
}
}
/**
* Return the ID of an existing language by name, inserting it if absent.
* Name is trimmed and stored as-is (case-preserved).
*/
public function getOrCreateLanguage(string $name): int
{
$name = trim($name);
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) LIMIT 1');
$stmt->execute([$name]);
$id = $stmt->fetchColumn();
if ($id !== false) {
return (int)$id;
}
$this->pdo->prepare('INSERT INTO languages (name) VALUES (?)')->execute([$name]);
return (int)$this->pdo->lastInsertId();
}
/**
* Replace all format associations for a thesis.
* @param int $thesisId
@@ -1581,7 +1598,7 @@ class Database
public function getThesisRawFields(int $thesisId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT license_id, license_custom, access_type_id, context_note, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc4r FROM theses WHERE id = ? LIMIT 1'
'SELECT license_id, license_custom, access_type_id, context_note, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc4r, duration_pages, duration_minutes FROM theses WHERE id = ? LIMIT 1'
);
$stmt->execute([$thesisId]);
$row = $stmt->fetch();
@@ -1697,6 +1714,8 @@ class Database
synopsis = ?,
context_note = ?,
file_size_info = ?,
duration_pages = ?,
duration_minutes = ?,
baiu_link = ?,
license_id = ?,
license_custom = ?,
@@ -1720,6 +1739,8 @@ class Database
$data['synopsis'],
!empty($data['context_note']) ? $data['context_note'] : null,
!empty($data['file_size_info']) ? $data['file_size_info'] : null,
isset($data['duration_pages']) && $data['duration_pages'] !== '' ? (int)$data['duration_pages'] : null,
isset($data['duration_minutes']) && $data['duration_minutes'] !== '' ? (int)$data['duration_minutes'] : null,
!empty($data['baiu_link']) ? $data['baiu_link'] : null,
$data['license_id'] ?? null,
!empty($data['license_custom']) ? $data['license_custom'] : null,
@@ -1765,6 +1786,7 @@ class Database
identifier, title, subtitle, year,
orientation_id, ap_program_id, finality_id,
synopsis, context_note, file_size_info,
duration_pages, duration_minutes,
baiu_link, license_id, license_custom,
access_type_id,
objet,
@@ -1773,7 +1795,7 @@ class Database
exemplaire_baiu, exemplaire_erg,
cc4r,
submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
');
$validObjet = ['tfe', 'thèse', 'frart'];
@@ -1790,6 +1812,8 @@ class Database
$data['synopsis'],
!empty($data['context_note']) ? $data['context_note'] : null,
!empty($data['file_size_info']) ? $data['file_size_info'] : null,
isset($data['duration_pages']) && $data['duration_pages'] !== '' ? (int)$data['duration_pages'] : null,
isset($data['duration_minutes']) && $data['duration_minutes'] !== '' ? (int)$data['duration_minutes'] : null,
!empty($data['baiu_link']) ? $data['baiu_link'] : null,
$data['license_id'] ?? null,
!empty($data['license_custom']) ? $data['license_custom'] : null,

View File

@@ -98,6 +98,9 @@
}
}
// Languages — either from flash repopulation or current thesis data
$formData['languages'] = $formData['languages'] ?? $currentLanguages ?? [];
// Formats — either from flash repopulation or current thesis data
$checkedFormats = $formData['formats'] ?? $currentFormats ?? [];
// Populate formData.formats for checkbox-list partial

View File

@@ -15,12 +15,14 @@
* bool $required — whether at least one checkbox must be checked; default false
* string $hxPost — optional hx-post URL for HTMX live update
* string $hxTarget — optional hx-target CSS selector for HTMX swap
* string $hxSwap — optional hx-swap value; default 'outerHTML'
*/
$checked = $checked ?? [];
$required = $required ?? false;
$hxPost = $hxPost ?? '';
$hxTarget = $hxTarget ?? '';
$hxSwap = $hxSwap ?? 'outerHTML';
?>
<div>
<span class="admin-row-label"><?= htmlspecialchars($label) ?><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span>
@@ -31,7 +33,7 @@ $hxTarget = $hxTarget ?? '';
hx-target="<?= htmlspecialchars($hxTarget) ?>"
hx-trigger="change"
hx-include="this, #website-url-fieldset"
hx-swap="outerHTML"
hx-swap="<?= htmlspecialchars($hxSwap) ?>"
<?php endif; ?>>
<legend class="sr-only"><?= htmlspecialchars($label) ?></legend>
<ul>
@@ -50,4 +52,4 @@ $hxTarget = $hxTarget ?? '';
</fieldset>
</div>
<?php
unset($checked, $hxPost, $hxTarget);
unset($checked, $hxPost, $hxTarget, $hxSwap);

View File

@@ -132,7 +132,7 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
<?php endif; ?>
<?php endif; ?>
<form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form"<?= $mode === 'edit' ? ' data-beforeunload-guard' : '' ?>>
<form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard>
<?= $hiddenFields ?>
<p class="required-note"><span class="asterisk">*</span> Champs obligatoires</p>
@@ -173,50 +173,31 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$options = $languages;
$checked = $formData["languages"] ?? [];
$required = true;
$hxPost = "/language-autre-fragment.php";
$hxTarget = "#language-autre-row";
$hxSwap = "outerHTML";
include APP_ROOT . "/templates/partials/form/checkbox-list.php";
unset($hxSwap);
?>
<?php
$name = "language_autre";
$label = "Autre(s) langue(s) :";
$value = $oldFn("language_autre");
$required = empty($formData["languages"]);
$hint =
"Si votre TFE contient une langue absente de la liste, précisez-la ici.";
include APP_ROOT . "/templates/partials/form/text-field.php";
$_langAutreRequired = empty($formData["languages"]);
$_langAutreValue = $oldFn("language_autre");
?>
<div id="language-autre-row">
<div>
<label for="language_autre">Autre(s) langue(s) :<?= $_langAutreRequired ? ' <span class="asterisk">*</span>' : '' ?></label>
<div>
<input type="text"
id="language_autre"
name="language_autre"
value="<?= $_langAutreValue ?>"
<?= $_langAutreRequired ? 'required' : '' ?>>
<small>Si votre TFE contient une langue absente de la liste, précisez-la ici.</small>
</div>
</div>
</div>
<?php unset($_langAutreRequired, $_langAutreValue); ?>
</fieldset>
<script>
(function () {
var fs = document.getElementById('languages-fieldset');
if (!fs) return;
var autreInput = fs.querySelector('input[name="language_autre"]');
if (!autreInput) return;
var autreLabel = fs.querySelector('label[for="language_autre"]');
function update() {
var anyChecked = fs.querySelectorAll('input[name="languages[]"]').length > 0
? Array.from(fs.querySelectorAll('input[name="languages[]"]')).some(function(cb){ return cb.checked; })
: false;
autreInput.required = !anyChecked;
if (autreLabel) {
var star = autreLabel.querySelector('.asterisk');
if (!anyChecked && !star) {
var s = document.createElement('span');
s.className = 'asterisk';
s.textContent = '*';
autreLabel.appendChild(document.createTextNode(' '));
autreLabel.appendChild(s);
} else if (anyChecked && star) {
star.previousSibling && star.previousSibling.nodeType === 3 && star.previousSibling.remove();
star.remove();
}
}
}
fs.addEventListener('change', function(e) {
if (e.target && e.target.name === 'languages[]') update();
});
update();
})();
</script>
<!-- ═══════════════════ Mots-clés ═══════════════════ -->
<fieldset>

View File

@@ -0,0 +1,450 @@
<?php
/**
* Form Save Round-Trip Test
*
* TDD: verifies that every field the form collects is persisted and can be
* read back from the database after create and edit operations.
*
* Covered fields:
* - titre, subtitle, synopsis, année
* - orientation, ap, finality
* - duration_pages, duration_minutes, has_annexes → file_size_info + individual columns
* - languages (checkboxes), language_autre (free-text)
* - formats (checkboxes)
* - jury (promoteur, lecteur interne, lecteur externe)
* - tags / keywords
* - lien (baiu_link), license_id, access_type_id
* - objet
* - context_note
* - remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc4r (backoffice)
* - is_published
*/
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/Controllers/ThesisCreateController.php';
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Build a minimal-but-complete POST payload.
* Accepts overrides to test specific fields.
*/
function buildPost(Database $db, array $overrides = []): array
{
$orientations = $db->getAllOrientations();
$apPrograms = $db->getAllAPPrograms();
$finalityTypes = $db->getAllFinalityTypes();
$languages = $db->getAllLanguages();
$formatTypes = $db->getAllFormatTypes();
$licenseTypes = $db->getAllLicenseTypes();
if (empty($orientations) || empty($apPrograms) || empty($finalityTypes)
|| empty($languages) || empty($formatTypes) || empty($licenseTypes)) {
throw new RuntimeException('Lookup tables empty — cannot build POST fixture');
}
$base = [
'titre' => 'Test TFE Title',
'subtitle' => 'Test Subtitle',
'auteurice' => 'Doe, Jane',
'mail' => 'jane@example.com',
'synopsis' => 'A short synopsis for testing purposes.',
'année' => '2025',
'orientation' => (string)$orientations[0]['id'],
'ap' => (string)$apPrograms[0]['id'],
'finality' => (string)$finalityTypes[0]['id'],
'duration_pages' => '120',
'duration_minutes' => '',
'has_annexes' => '',
'languages' => [(string)$languages[0]['id']],
'language_autre' => '',
'formats' => [(string)$formatTypes[0]['id']],
'tag' => 'art, test',
'jury_promoteur' => 'Prof. Smith',
'jury_lecteur_interne' => ['Dr. Internal'],
'jury_lecteur_externe' => ['Dr. External'],
'license_id' => (string)$licenseTypes[0]['id'],
'license_custom' => '',
'access_type_id' => '2',
'objet' => 'tfe',
'lien' => '',
'context_note' => '',
'remarks' => '',
'jury_points' => '',
'exemplaire_baiu' => '',
'exemplaire_erg' => '',
'cc2r' => '',
'is_published' => '',
];
return array_merge($base, $overrides);
}
/**
* Assert helper — throws on failure, echoes on pass.
*/
function assertEq(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 assertContains(mixed $needle, array $haystack, string $label): void
{
if (in_array($needle, $haystack, false)) {
echo "$label\n";
} else {
$a = implode(', ', $haystack);
throw new RuntimeException("FAIL $label: $needle not in [$a]");
}
}
function assertNotEmpty(mixed $value, string $label): void
{
if (!empty($value)) {
echo "$label\n";
} else {
throw new RuntimeException("FAIL $label: value is empty");
}
}
// ── Test setup ────────────────────────────────────────────────────────────────
echo "Form Save Round-Trip Test\n";
echo "=========================\n\n";
$db = Database::getInstance();
$createCtrl = new ThesisCreateController($db);
$editCtrl = new ThesisEditController($db);
$createdIds = [];
try {
// =========================================================================
// TEST 1: Create — basic fields persisted
// =========================================================================
echo "Test 1: Create — basic fields persisted\n";
$post = buildPost($db, [
'titre' => 'Round-trip test titre',
'subtitle' => 'Round-trip subtitle',
'synopsis' => 'Round-trip synopsis',
'année' => '2025',
]);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$row = $db->getThesis($thesisId);
assertEq('Round-trip test titre', $row['title'], 'title saved');
assertEq('Round-trip subtitle', $row['subtitle'], 'subtitle saved');
assertEq('Round-trip synopsis', $row['synopsis'], 'synopsis saved');
assertEq(2025, (int)$row['year'], 'year saved');
echo "\n";
// =========================================================================
// TEST 2: Create — duration_pages saved as individual column
// =========================================================================
echo "Test 2: Create — duration_pages saved as individual column\n";
$post = buildPost($db, [
'titre' => 'Duration pages test',
'duration_pages' => '84',
]);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$raw = $db->getThesisRawFields($thesisId);
assertEq(84, (int)$raw['duration_pages'], 'duration_pages column saved');
assertEq(null, $raw['duration_minutes'], 'duration_minutes null when not set');
echo "\n";
// =========================================================================
// TEST 3: Create — duration_minutes saved as individual column
// =========================================================================
echo "Test 3: Create — duration_minutes saved as individual column\n";
$post = buildPost($db, [
'titre' => 'Duration minutes test',
'duration_pages' => '',
'duration_minutes' => '42',
]);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$raw = $db->getThesisRawFields($thesisId);
assertEq(null, $raw['duration_pages'], 'duration_pages null when not set');
assertEq(42, (int)$raw['duration_minutes'], 'duration_minutes column saved');
echo "\n";
// =========================================================================
// TEST 4: Create — language_autre creates and links new language
// =========================================================================
echo "Test 4: Create — language_autre creates and links new language\n";
$uniqueLang = 'TestLang_' . bin2hex(random_bytes(4));
$post = buildPost($db, [
'titre' => 'Language autre test',
'languages' => [], // no checkbox
'language_autre' => $uniqueLang,
]);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$langIds = $db->getThesisLanguageIds($thesisId);
$allLangs = $db->getAllLanguages();
$found = array_filter($allLangs, fn($l) => $l['name'] === $uniqueLang);
assertNotEmpty($found, "language '$uniqueLang' created in languages table");
$createdLangId = (int)array_values($found)[0]['id'];
assertContains((string)$createdLangId, array_map('strval', $langIds),
'language_autre ID linked to thesis');
echo "\n";
// =========================================================================
// TEST 5: Create — language_autre + checkbox together
// =========================================================================
echo "Test 5: Create — language_autre appended alongside checked languages\n";
$db2 = Database::getInstance();
$allLangs = $db2->getAllLanguages();
$uniqueLang2 = 'TestLang2_' . bin2hex(random_bytes(4));
$post = buildPost($db, [
'titre' => 'Language combo test',
'languages' => [(string)$allLangs[0]['id']],
'language_autre' => $uniqueLang2,
]);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$langIds = $db->getThesisLanguageIds($thesisId);
assertContains((string)$allLangs[0]['id'], array_map('strval', $langIds),
'checkbox language linked');
$found2 = array_filter($db->getAllLanguages(), fn($l) => $l['name'] === $uniqueLang2);
$createdLang2 = (int)array_values($found2)[0]['id'];
assertContains((string)$createdLang2, array_map('strval', $langIds),
'language_autre also linked');
echo "\n";
// =========================================================================
// TEST 6: Edit — duration_pages / duration_minutes updated
// =========================================================================
echo "Test 6: Edit — duration_pages / duration_minutes updated\n";
$post = buildPost($db, ['titre' => 'Edit duration test']);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$editPost = buildPost($db, [
'titre' => 'Edit duration test',
'duration_pages' => '99',
'duration_minutes' => '30',
]);
$editCtrl->save($thesisId, $editPost, []);
$raw = $db->getThesisRawFields($thesisId);
assertEq(99, (int)$raw['duration_pages'], 'duration_pages updated on edit');
assertEq(30, (int)$raw['duration_minutes'], 'duration_minutes updated on edit');
echo "\n";
// =========================================================================
// TEST 7: Edit — clearing duration_pages saves null
// =========================================================================
echo "Test 7: Edit — clearing duration_pages saves null\n";
$editPost = buildPost($db, [
'titre' => 'Edit duration test',
'duration_pages' => '',
'duration_minutes' => '',
]);
$editCtrl->save($thesisId, $editPost, []);
$raw = $db->getThesisRawFields($thesisId);
assertEq(null, $raw['duration_pages'], 'duration_pages cleared to null');
assertEq(null, $raw['duration_minutes'], 'duration_minutes cleared to null');
echo "\n";
// =========================================================================
// TEST 8: Edit — languages pre-populated (checkboxes round-trip)
// =========================================================================
echo "Test 8: Edit — language checkboxes round-trip\n";
$allLangs = $db->getAllLanguages();
$lang1 = (string)$allLangs[0]['id'];
$lang2 = (string)$allLangs[1]['id'];
$post = buildPost($db, [
'titre' => 'Lang checkbox test',
'languages' => [$lang1],
]);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$editPost = buildPost($db, [
'titre' => 'Lang checkbox test',
'languages' => [$lang1, $lang2],
]);
$editCtrl->save($thesisId, $editPost, []);
$langIds = $db->getThesisLanguageIds($thesisId);
assertContains($lang1, array_map('strval', $langIds), 'first language retained on edit');
assertContains($lang2, array_map('strval', $langIds), 'second language added on edit');
echo "\n";
// =========================================================================
// TEST 9: Edit — language_autre adds new language
// =========================================================================
echo "Test 9: Edit — language_autre creates and links on edit\n";
$uniqueLang3 = 'EditLang_' . bin2hex(random_bytes(4));
$editPost = buildPost($db, [
'titre' => 'Lang checkbox test',
'languages' => [$lang1],
'language_autre' => $uniqueLang3,
]);
$editCtrl->save($thesisId, $editPost, []);
$langIds = $db->getThesisLanguageIds($thesisId);
$found3 = array_filter($db->getAllLanguages(), fn($l) => $l['name'] === $uniqueLang3);
assertNotEmpty($found3, "language '$uniqueLang3' created on edit");
$createdLang3 = (int)array_values($found3)[0]['id'];
assertContains((string)$createdLang3, array_map('strval', $langIds),
'language_autre linked on edit');
echo "\n";
// =========================================================================
// TEST 10: Create — backoffice fields persisted
// =========================================================================
echo "Test 10: Create — backoffice fields (remarks, jury_points, exemplaires, cc4r)\n";
$post = buildPost($db, [
'titre' => 'Backoffice fields test',
'remarks' => 'Internal note here',
'jury_points' => '15.5',
'exemplaire_baiu' => '1',
'exemplaire_erg' => '1',
'cc2r' => '1',
]);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$raw = $db->getThesisRawFields($thesisId);
assertEq('Internal note here', $raw['remarks'], 'remarks saved');
assertEq(15.5, (float)$raw['jury_points'], 'jury_points saved');
assertEq(1, (int)$raw['exemplaire_baiu'], 'exemplaire_baiu saved');
assertEq(1, (int)$raw['exemplaire_erg'], 'exemplaire_erg saved');
assertEq(1, (int)$raw['cc4r'], 'cc4r saved');
echo "\n";
// =========================================================================
// TEST 11: Edit — backoffice fields updated
// =========================================================================
echo "Test 11: Edit — backoffice fields updated\n";
$editPost = buildPost($db, [
'titre' => 'Backoffice fields test',
'remarks' => 'Updated note',
'jury_points' => '18',
'exemplaire_baiu' => '',
'exemplaire_erg' => '1',
'cc2r' => '',
]);
$editCtrl->save($thesisId, $editPost, []);
$raw = $db->getThesisRawFields($thesisId);
assertEq('Updated note', $raw['remarks'], 'remarks updated');
assertEq(18.0, (float)$raw['jury_points'], 'jury_points updated');
assertEq(0, (int)$raw['exemplaire_baiu'], 'exemplaire_baiu cleared');
assertEq(1, (int)$raw['exemplaire_erg'], 'exemplaire_erg retained');
assertEq(0, (int)$raw['cc4r'], 'cc4r cleared');
echo "\n";
// =========================================================================
// TEST 12: getOrCreateLanguage — idempotent
// =========================================================================
echo "Test 12: getOrCreateLanguage — idempotent (same name returns same ID)\n";
$uniqueName = 'Idempotent_' . bin2hex(random_bytes(4));
$id1 = $db->getOrCreateLanguage($uniqueName);
$id2 = $db->getOrCreateLanguage($uniqueName);
$id3 = $db->getOrCreateLanguage(strtolower($uniqueName)); // case-insensitive
assertEq($id1, $id2, 'same ID on second call');
assertEq($id1, $id3, 'same ID with different case');
echo "\n";
// =========================================================================
// TEST 13: Create — has_annexes flag in file_size_info
// =========================================================================
echo "Test 13: Create — has_annexes appended to file_size_info\n";
$post = buildPost($db, [
'titre' => 'Annexes test',
'duration_pages' => '50',
'has_annexes' => '1',
]);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$row = $db->getThesis($thesisId);
assertNotEmpty($row['file_size_info'], 'file_size_info not empty');
if (strpos((string)$row['file_size_info'], 'annexe') === false) {
throw new RuntimeException("FAIL has_annexes: 'annexe' not in file_size_info: " . $row['file_size_info']);
}
echo " ✓ 'annexe' present in file_size_info\n\n";
// =========================================================================
// TEST 14: Edit — context_note saved
// =========================================================================
echo "Test 14: Edit — context_note saved\n";
$post = buildPost($db, ['titre' => 'Context note test']);
$thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId;
$editPost = buildPost($db, [
'titre' => 'Context note test',
'context_note' => 'A contextual note visible publicly.',
]);
$editCtrl->save($thesisId, $editPost, []);
$raw = $db->getThesisRawFields($thesisId);
assertEq('A contextual note visible publicly.', $raw['context_note'], 'context_note saved');
echo "\n";
echo "✅ All form save tests passed!\n";
$result = true;
} catch (Exception $e) {
echo '❌ FAIL: ' . $e->getMessage() . "\n";
$result = false;
} finally {
// Clean up test theses
foreach ($createdIds as $id) {
try { $db->deleteThesis($id); } catch (Exception $e) { /* ignore */ }
}
// Clean up test languages
$allLangs = $db->getAllLanguages();
foreach ($allLangs as $lang) {
if (str_starts_with($lang['name'], 'TestLang_')
|| str_starts_with($lang['name'], 'TestLang2_')
|| str_starts_with($lang['name'], 'EditLang_')
|| str_starts_with($lang['name'], 'Idempotent_')) {
try {
$db->getConnection()->prepare('DELETE FROM languages WHERE id = ?')->execute([$lang['id']]);
} catch (Exception $e) { /* ignore */ }
}
}
}
return $result ?? false;

View File

@@ -0,0 +1,422 @@
<?php
/**
* Pure Logic Unit Test
*
* Tests deterministic, I/O-free logic extracted from controllers:
*
* TfeController (via subclass to access protected methods):
* - buildMetaDescription() — truncation at 160 chars, empty synopsis fallback
* - resolveOgImage() — cover preferred over image, fallback chain
* - splitJuryByRole() — correct binning of promoteur/lecteur/president/ulb
* - collectCaptionPaths() — VTT files extracted in order
*
* ThesisCreateController (static / public helpers):
* - autofocusFieldForError()
* - detectFileType() — via subclass to access private
* - generateAuthorSlug() — accent stripping, uppercase, underscore
* - buildFileSizeInfo() — pages, minutes, annexes combinations
*
* ExportController:
* - CSV_HEADERS count matches exportAllTheses() row width
*
* SearchController:
* - handleSearch() return array always contains 'coverMap' key (regression for undefined var bug)
*/
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/RateLimit.php';
require_once APP_ROOT . '/src/Controllers/TfeController.php';
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
require_once APP_ROOT . '/src/Controllers/SearchController.php';
require_once APP_ROOT . '/src/Controllers/ExportController.php';
// ── Test harness helpers ──────────────────────────────────────────────────────
function plAssert(bool $cond, string $label): void
{
if ($cond) {
echo "$label\n";
} else {
throw new RuntimeException("FAIL: $label");
}
}
function plAssertEq(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");
}
}
// ── Subclasses to expose protected / private methods ─────────────────────────
class TfeControllerTestable extends TfeController
{
public function testBuildMetaDescription(string $synopsis): string
{
return $this->buildMetaDescription($synopsis);
}
public function testResolveOgImage(array $files): string
{
return $this->resolveOgImage($files);
}
public function testSplitJuryByRole(array $jury): array
{
return $this->splitJuryByRole($jury);
}
public function testCollectCaptionPaths(array $files): array
{
return $this->collectCaptionPaths($files);
}
}
class ThesisCreateControllerTestable extends ThesisCreateController
{
public function testDetectFileType(string $mimeType, string $ext, string $name): string
{
return $this->detectFileType($mimeType, $ext, $name);
}
public function testGenerateAuthorSlug(string $name): string
{
return $this->generateAuthorSlug($name);
}
}
class ThesisEditControllerTestable extends ThesisEditController
{
public function testBuildFileSizeInfo(array $post): string
{
return $this->buildFileSizeInfo($post);
}
}
// ── Setup ─────────────────────────────────────────────────────────────────────
echo "Pure Logic Unit Test\n";
echo "====================\n\n";
$db = Database::getInstance();
$tfe = new TfeControllerTestable($db);
$createCtrl = new ThesisCreateControllerTestable($db);
$editCtrl = new ThesisEditControllerTestable($db);
try {
// =========================================================================
// SECTION A: TfeController helpers
// =========================================================================
// ── A1: buildMetaDescription ──────────────────────────────────────────────
echo "A1: buildMetaDescription — normal synopsis\n";
$desc = $tfe->testBuildMetaDescription('A short synopsis.');
plAssertEq('A short synopsis.', $desc, 'short synopsis returned as-is');
echo "\n";
echo "A2: buildMetaDescription — synopsis over 160 chars truncated\n";
$long = str_repeat('a', 200);
$desc = $tfe->testBuildMetaDescription($long);
plAssert(strlen($desc) <= 160, 'length <= 160');
plAssert(str_ends_with($desc, '…'), 'ends with ellipsis');
echo "\n";
echo "A3: buildMetaDescription — exactly 160 chars not truncated\n";
$exact = str_repeat('b', 160);
$desc = $tfe->testBuildMetaDescription($exact);
plAssertEq($exact, $desc, '160-char synopsis not truncated');
echo "\n";
echo "A4: buildMetaDescription — empty synopsis returns fallback\n";
$desc = $tfe->testBuildMetaDescription('');
plAssert($desc !== '', 'non-empty fallback returned');
plAssert(strlen($desc) <= 160, 'fallback length <= 160');
echo "\n";
echo "A5: buildMetaDescription — HTML tags stripped\n";
$desc = $tfe->testBuildMetaDescription('<p>Hello <strong>world</strong></p>');
plAssertEq('Hello world', $desc, 'HTML tags stripped');
echo "\n";
// ── A6: resolveOgImage ────────────────────────────────────────────────────
echo "A6: resolveOgImage — cover preferred over image files\n";
$files = [
['file_type' => 'image', 'file_path' => 'theses/2025/img.jpg'],
['file_type' => 'cover', 'file_path' => 'covers/cover.jpg'],
['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'],
];
$url = $tfe->testResolveOgImage($files);
plAssert(str_contains($url, rawurlencode('covers/cover.jpg')), 'cover used when available');
echo "\n";
echo "A7: resolveOgImage — falls back to first image when no cover\n";
$files = [
['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'],
['file_type' => 'image', 'file_path' => 'theses/2025/img.jpg'],
['file_type' => 'image', 'file_path' => 'theses/2025/img2.png'],
];
$url = $tfe->testResolveOgImage($files);
plAssert(str_contains($url, rawurlencode('theses/2025/img.jpg')), 'first image file used as fallback');
plAssert(!str_contains($url, rawurlencode('img2.png')), 'second image not used');
echo "\n";
echo "A8: resolveOgImage — empty string when no image at all\n";
$files = [
['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'],
['file_type' => 'audio', 'file_path' => 'theses/2025/audio.mp3'],
];
$url = $tfe->testResolveOgImage($files);
plAssertEq('', $url, 'empty string when no image');
echo "\n";
echo "A9: resolveOgImage — empty files array returns empty string\n";
plAssertEq('', $tfe->testResolveOgImage([]), 'empty array → empty string');
echo "\n";
// ── A10: splitJuryByRole ──────────────────────────────────────────────────
echo "A10: splitJuryByRole — all roles correctly binned\n";
$jury = [
['name' => 'Alice', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0],
['name' => 'Bob', 'role' => 'promoteur', 'is_external' => 0, 'is_ulb' => 0],
['name' => 'Carol', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 1],
['name' => 'Dave', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 0],
['name' => 'Eve', 'role' => 'lecteur', 'is_external' => 0, 'is_ulb' => 0],
['name' => 'Frank', 'role' => 'lecteur', 'is_external' => 1, 'is_ulb' => 0],
];
$split = $tfe->testSplitJuryByRole($jury);
plAssertEq(['Alice'], $split['presidents'], 'president');
plAssertEq(['Bob'], $split['internes'], 'interne promoteur');
plAssertEq(['Carol'], $split['ulb'], 'ulb promoteur');
plAssertEq(['Dave'], $split['externes'], 'externe promoteur (non-ULB)');
plAssertEq(['Eve'], $split['lecteurs_internes'], 'lecteur interne');
plAssertEq(['Frank'], $split['lecteurs_externes'], 'lecteur externe');
echo "\n";
echo "A11: splitJuryByRole — empty name skipped\n";
$jury = [['name' => '', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0]];
$split = $tfe->testSplitJuryByRole($jury);
plAssertEq([], $split['presidents'], 'empty name not added');
echo "\n";
echo "A12: splitJuryByRole — empty jury returns all-empty arrays\n";
$split = $tfe->testSplitJuryByRole([]);
plAssertEq([], $split['presidents'], 'presidents empty');
plAssertEq([], $split['internes'], 'internes empty');
plAssertEq([], $split['ulb'], 'ulb empty');
plAssertEq([], $split['externes'], 'externes empty');
plAssertEq([], $split['lecteurs_internes'], 'lecteurs_internes empty');
plAssertEq([], $split['lecteurs_externes'], 'lecteurs_externes empty');
echo "\n";
// ── A13: collectCaptionPaths ──────────────────────────────────────────────
echo "A13: collectCaptionPaths — VTT files extracted in order\n";
$files = [
['mime_type' => 'application/pdf', 'file_path' => 'main.pdf'],
['mime_type' => 'text/vtt', 'file_path' => 'captions1.vtt'],
['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'],
['mime_type' => 'text/plain', 'file_path' => 'captions2.vtt'],
];
$captions = $tfe->testCollectCaptionPaths($files);
plAssertEq(['captions1.vtt', 'captions2.vtt'], $captions, 'both VTT files returned in order');
echo "\n";
echo "A14: collectCaptionPaths — .vtt extension without mime match\n";
$files = [
['mime_type' => 'application/octet-stream', 'file_path' => 'sub.vtt'],
['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'],
];
$captions = $tfe->testCollectCaptionPaths($files);
plAssertEq(['sub.vtt'], $captions, '.vtt extension matches even with generic mime');
echo "\n";
echo "A15: collectCaptionPaths — no VTT returns empty array\n";
$files = [
['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'],
];
plAssertEq([], $tfe->testCollectCaptionPaths($files), 'empty array when no VTT');
echo "\n";
// =========================================================================
// SECTION B: ThesisCreateController helpers
// =========================================================================
// ── B1: autofocusFieldForError ────────────────────────────────────────────
echo "B1: autofocusFieldForError — known error messages map to fields\n";
$cases = [
["Titre du mémoire", 'titre'],
["Nom/Prénom/Pseudo", 'auteurice'],
["Synopsis", 'synopsis'],
["Année invalide", 'année'],
["orientation", 'orientation'],
["Atelier Pratique", 'ap'],
["finalité", 'finality'],
["langue", 'languages'],
["promoteur", 'jury_promoteur'],
["lecteur·ice interne", 'jury_lecteur_interne[]'],
["lecteur·ice externe", 'jury_lecteur_externe[]'],
["format", 'formats'],
["licence", 'license_id'],
["Lien URL", 'lien'],
];
foreach ($cases as [$message, $expected]) {
$actual = ThesisCreateController::autofocusFieldForError($message);
plAssertEq($expected, $actual, "\"$message\" $expected");
}
echo "\n";
echo "B2: autofocusFieldForError unknown message returns null\n";
plAssertEq(null, ThesisCreateController::autofocusFieldForError('completely unknown'), 'null for unknown');
echo "\n";
// ── B3: detectFileType ────────────────────────────────────────────────────
echo "B3: detectFileType mime-based detection\n";
$cases = [
['text/vtt', 'vtt', 'caption.vtt', 'caption'],
['audio/mpeg', 'mp3', 'track.mp3', 'audio'],
['audio/ogg', 'ogg', 'track.ogg', 'audio'],
['video/mp4', 'mp4', 'film.mp4', 'video'],
['video/webm', 'webm', 'film.webm', 'video'],
['application/pdf', 'pdf', 'report.pdf', 'main'],
['image/jpeg', 'jpg', 'photo.jpg', 'image'],
['image/png', 'png', 'photo.png', 'image'],
['application/zip', 'zip', 'archive.zip', 'other'],
];
foreach ($cases as [$mime, $ext, $name, $expected]) {
$actual = $createCtrl->testDetectFileType($mime, $ext, $name);
plAssertEq($expected, $actual, "$name ($mime) $expected");
}
echo "\n";
echo "B4: detectFileType extension-based fallback\n";
// application/octet-stream with known extensions
$cases = [
['application/octet-stream', 'mp3', 'track.mp3', 'audio'],
['application/octet-stream', 'mp4', 'video.mp4', 'video'],
['application/octet-stream', 'pdf', 'doc.pdf', 'main'],
['application/octet-stream', 'webp', 'img.webp', 'image'],
['application/octet-stream', 'vtt', 'subs.vtt', 'caption'],
];
foreach ($cases as [$mime, $ext, $name, $expected]) {
$actual = $createCtrl->testDetectFileType($mime, $ext, $name);
plAssertEq($expected, $actual, "octet-stream + .$ext $expected");
}
echo "\n";
// ── B5: generateAuthorSlug ────────────────────────────────────────────────
echo "B5: generateAuthorSlug basic ASCII\n";
plAssertEq('JANE_DOE', $createCtrl->testGenerateAuthorSlug('Jane Doe'), 'spaces to underscores, uppercase');
plAssertEq('AUTHOR', $createCtrl->testGenerateAuthorSlug(''), 'empty → AUTHOR');
echo "\n";
echo "B6: generateAuthorSlug French accents stripped\n";
plAssertEq('ELEONORE_DUPONT', $createCtrl->testGenerateAuthorSlug('Éléonore Dupont'), 'accents stripped');
plAssertEq('FRANCOISE', $createCtrl->testGenerateAuthorSlug('Françoise'), 'ç → C');
echo "\n";
echo "B7: generateAuthorSlug multiple authors comma-separated\n";
$slug = $createCtrl->testGenerateAuthorSlug('Alice Martin, Bob Durand');
plAssert(str_contains($slug, 'ALICE'), 'contains ALICE');
plAssert(str_contains($slug, 'BOB'), 'contains BOB');
echo "\n";
// =========================================================================
// SECTION C: ThesisEditController — buildFileSizeInfo
// =========================================================================
echo "C1: buildFileSizeInfo pages only\n";
$info = $editCtrl->testBuildFileSizeInfo(['duration_pages' => '84', 'duration_minutes' => '']);
plAssertEq('84 pages', $info, 'pages only');
echo "\n";
echo "C2: buildFileSizeInfo minutes only\n";
$info = $editCtrl->testBuildFileSizeInfo(['duration_pages' => '', 'duration_minutes' => '32']);
plAssertEq('32 minutes', $info, 'minutes only');
echo "\n";
echo "C3: buildFileSizeInfo pages + minutes\n";
$info = $editCtrl->testBuildFileSizeInfo(['duration_pages' => '84', 'duration_minutes' => '32']);
plAssertEq('84 pages + 32 minutes', $info, 'pages + minutes');
echo "\n";
echo "C4: buildFileSizeInfo annexes appended\n";
$info = $editCtrl->testBuildFileSizeInfo(['duration_pages' => '50', 'duration_minutes' => '', 'has_annexes' => '1']);
plAssertEq('50 pages + annexe(s)', $info, 'annexes appended');
echo "\n";
echo "C5: buildFileSizeInfo annexes only (no pages/minutes)\n";
$info = $editCtrl->testBuildFileSizeInfo(['duration_pages' => '', 'duration_minutes' => '', 'has_annexes' => '1']);
plAssertEq('Annexe(s)', $info, 'annexes only');
echo "\n";
echo "C6: buildFileSizeInfo empty fields = empty string\n";
$info = $editCtrl->testBuildFileSizeInfo(['duration_pages' => '', 'duration_minutes' => '']);
plAssertEq('', $info, 'empty when nothing set');
echo "\n";
// =========================================================================
// SECTION D: ExportController — CSV column count consistency
// =========================================================================
echo "D1: ExportController CSV_HEADERS count matches row column count\n";
$export = new ExportController($db);
$rows = $export->exportAllTheses();
$headerCount = count(ExportController::CSV_HEADERS);
plAssert($headerCount > 0, 'CSV_HEADERS is non-empty');
if (!empty($rows)) {
foreach ($rows as $i => $row) {
if (count($row) !== $headerCount) {
throw new RuntimeException(
"FAIL: row $i has " . count($row) . " columns, expected $headerCount"
);
}
}
echo " all " . count($rows) . " rows have $headerCount columns matching CSV_HEADERS\n";
} else {
echo " no rows to check (empty export) header count is $headerCount\n";
}
echo "\n";
// =========================================================================
// SECTION E: SearchController — coverMap key always present (regression)
// =========================================================================
echo "E1: SearchController::handleSearch() coverMap key always in return array\n";
// Simulate $_GET for the method (it reads from $_GET directly via collectSearchParams)
$_GET = ['query' => ''];
$rateLimit = new RateLimit(1000, 60);
$searchCtrl = new SearchController($db, $rateLimit);
$vars = $searchCtrl->handleSearch();
plAssert(array_key_exists('coverMap', $vars), 'coverMap key present in handleSearch() return');
plAssert(is_array($vars['coverMap']), 'coverMap is an array');
$_GET = [];
echo "\n";
echo " All pure logic tests passed!\n";
$result = true;
} catch (Exception $e) {
echo '❌ FAIL: ' . $e->getMessage() . "\n";
$result = false;
}
return $result ?? false;

View File

@@ -0,0 +1,234 @@
<?php
/**
* ShareLink Unit Test
*
* Tests pure-logic methods that require no HTTP context:
* - generateSlug() — format, uniqueness, entropy
* - validateLink() — all branches: not_found, archived, disabled, expired, needs_password, valid
* - verifyPassword() — correct / wrong / no-password links
*/
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/ShareLink.php';
// ── Helpers ───────────────────────────────────────────────────────────────────
function slAssert(bool $cond, string $label): void
{
if ($cond) {
echo "$label\n";
} else {
throw new RuntimeException("FAIL: $label");
}
}
function slAssertEq(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");
}
}
// ── Setup ─────────────────────────────────────────────────────────────────────
echo "ShareLink Unit Test\n";
echo "===================\n\n";
$db = Database::getInstance();
$model = new ShareLink($db);
// We need a dummy admin user id — just use 1 (or any int; share_links.created_by is not FK-checked)
$adminId = 1;
$createdIds = [];
try {
// =========================================================================
// TEST 1: generateSlug — format YYYYMMDD-XXXXXXXX
// =========================================================================
echo "Test 1: generateSlug — format\n";
$slug = ShareLink::generateSlug();
slAssert(
(bool) preg_match('/^\d{8}-[A-Z2-7]{8}$/', $slug),
"slug matches YYYYMMDD-[BASE32]{8}: $slug"
);
$year = (int) substr($slug, 0, 4);
$month = (int) substr($slug, 4, 2);
$day = (int) substr($slug, 6, 2);
slAssert($year >= 2020 && $year <= 2100, 'year in plausible range');
slAssert($month >= 1 && $month <= 12, 'month in range');
slAssert($day >= 1 && $day <= 31, 'day in range');
echo "\n";
// =========================================================================
// TEST 2: generateSlug — two calls produce different slugs
// =========================================================================
echo "Test 2: generateSlug — uniqueness\n";
$slugs = [];
for ($i = 0; $i < 20; $i++) {
$slugs[] = ShareLink::generateSlug();
}
slAssertEq(count($slugs), count(array_unique($slugs)), '20 consecutive slugs are all unique');
echo "\n";
// =========================================================================
// TEST 3: validateLink — not_found on missing slug
// =========================================================================
echo "Test 3: validateLink — not_found on missing slug\n";
$result = $model->validateLink('NONEXISTENT-SLUG');
slAssertEq(false, $result['valid'], 'valid=false');
slAssertEq('not_found', $result['reason'], 'reason=not_found');
$result = $model->validateLink(null);
slAssertEq(false, $result['valid'], 'null slug: valid=false');
slAssertEq('not_found', $result['reason'], 'null slug: reason=not_found');
$result = $model->validateLink('');
slAssertEq(false, $result['valid'], 'empty slug: valid=false');
slAssertEq('not_found', $result['reason'], 'empty slug: reason=not_found');
echo "\n";
// =========================================================================
// TEST 4: validateLink — valid active link with no password
// =========================================================================
echo "Test 4: validateLink — valid active link\n";
$link = $model->create($adminId, null, null);
$createdIds[] = $link['id'];
$result = $model->validateLink($link['slug']);
slAssertEq(true, $result['valid'], 'valid=true');
slAssert(isset($result['link']), 'link row returned');
echo "\n";
// =========================================================================
// TEST 5: validateLink — disabled link
// =========================================================================
echo "Test 5: validateLink — disabled link\n";
$model->toggleActive($link['id']); // deactivate
$result = $model->validateLink($link['slug']);
slAssertEq(false, $result['valid'], 'valid=false after disable');
slAssertEq('disabled', $result['reason'], 'reason=disabled');
$model->toggleActive($link['id']); // restore
echo "\n";
// =========================================================================
// TEST 6: validateLink — archived link
// =========================================================================
echo "Test 6: validateLink — archived link\n";
$archivedLink = $model->create($adminId, null, null);
$createdIds[] = $archivedLink['id'];
$model->archive($archivedLink['id']);
$result = $model->validateLink($archivedLink['slug']);
slAssertEq(false, $result['valid'], 'valid=false for archived');
slAssertEq('archived', $result['reason'], 'reason=archived');
echo "\n";
// =========================================================================
// TEST 7: validateLink — expired link
// =========================================================================
echo "Test 7: validateLink — expired link\n";
$pastDate = date('Y-m-d H:i:s', strtotime('-1 day'));
$expiredLink = $model->create($adminId, null, $pastDate);
$createdIds[] = $expiredLink['id'];
$result = $model->validateLink($expiredLink['slug']);
slAssertEq(false, $result['valid'], 'valid=false for expired');
slAssertEq('expired', $result['reason'], 'reason=expired');
echo "\n";
// =========================================================================
// TEST 8: validateLink — not expired (future date)
// =========================================================================
echo "Test 8: validateLink — future expiry is still valid\n";
$futureDate = date('Y-m-d H:i:s', strtotime('+30 days'));
$futureLink = $model->create($adminId, null, $futureDate);
$createdIds[] = $futureLink['id'];
$result = $model->validateLink($futureLink['slug']);
slAssertEq(true, $result['valid'], 'valid=true for future expiry');
echo "\n";
// =========================================================================
// TEST 9: validateLink — needs_password when password is set
// =========================================================================
echo "Test 9: validateLink — needs_password\n";
$pwLink = $model->create($adminId, 'secret123', null);
$createdIds[] = $pwLink['id'];
$result = $model->validateLink($pwLink['slug']);
slAssertEq(false, $result['valid'], 'valid=false (needs password)');
slAssertEq('needs_password', $result['reason'], 'reason=needs_password');
slAssert(isset($result['link']), 'link row returned even when password needed');
echo "\n";
// =========================================================================
// TEST 10: verifyPassword — correct password
// =========================================================================
echo "Test 10: verifyPassword — correct password\n";
$pwLinkRow = $model->findBySlug($pwLink['slug']);
slAssertEq(true, $model->verifyPassword($pwLinkRow, 'secret123'), 'correct password accepted');
slAssertEq(false, $model->verifyPassword($pwLinkRow, 'wrongpass'), 'wrong password rejected');
slAssertEq(false, $model->verifyPassword($pwLinkRow, ''), 'empty password rejected');
echo "\n";
// =========================================================================
// TEST 11: verifyPassword — link with no password always passes
// =========================================================================
echo "Test 11: verifyPassword — no password set always returns true\n";
$noPwRow = $model->findBySlug($link['slug']);
slAssertEq(true, $model->verifyPassword($noPwRow, ''), 'no-pw link: empty string passes');
slAssertEq(true, $model->verifyPassword($noPwRow, 'anything'), 'no-pw link: any string passes');
echo "\n";
// =========================================================================
// TEST 12: incrementUsage — counter goes up
// =========================================================================
echo "Test 12: incrementUsage — counter increments\n";
$fresh = $model->findById($link['id']);
$before = (int)$fresh['usage_count'];
$model->incrementUsage($link['id']);
$model->incrementUsage($link['id']);
$after = (int)($model->findById($link['id'])['usage_count'] ?? 0);
slAssertEq($before + 2, $after, 'usage_count incremented by 2');
echo "\n";
// =========================================================================
// TEST 13: objet_restriction is stored and returned
// =========================================================================
echo "Test 13: objet_restriction stored correctly\n";
$restrictedLink = $model->create($adminId, null, null, 'tfe');
$createdIds[] = $restrictedLink['id'];
slAssertEq('tfe', $restrictedLink['objet_restriction'], 'objet_restriction=tfe stored');
$anyLink = $model->create($adminId, null, null, 'invalid_value');
$createdIds[] = $anyLink['id'];
slAssertEq(null, $anyLink['objet_restriction'], 'invalid objet_restriction stored as null');
echo "\n";
echo "✅ All ShareLink tests passed!\n";
$result = true;
} catch (Exception $e) {
echo '❌ FAIL: ' . $e->getMessage() . "\n";
$result = false;
} finally {
$pdo = $db->getConnection();
foreach ($createdIds as $id) {
try {
$pdo->prepare('DELETE FROM share_links WHERE id = ?')->execute([$id]);
} catch (Exception $e) { /* ignore */ }
}
}
return $result ?? false;

View File

@@ -13,6 +13,9 @@ echo "╚═══════════════════════
$testFiles = [
['name' => 'Database (Unit)', 'path' => __DIR__ . '/Unit/DatabaseTest.php'],
['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' => 'Pure Logic (Unit)', 'path' => __DIR__ . '/Unit/PureLogicTest.php'],
['name' => 'Search (Integration)', 'path' => __DIR__ . '/Integration/SearchTest.php'],
['name' => 'Security', 'path' => __DIR__ . '/Security/SecurityTest.php'],
];