Files
xamxam/app/tests/Unit/PureLogicTest.php
Pontoporeia ab6e266807 fix: add help email, preserve file names on validation error, license fix
The share link (partage) form does not expose a license field and does
not send access_type_id (defaults to 2/Interne). Server-side validation
was unconditionally requiring a license for non-admin submissions,
causing all share link submissions to fail.

Now the license check is gated on adminMode=false AND accessTypeId=1
(Libre), matching the client-side HTMX fragment behaviour in
licence-fragment.php. Also fixed a use-before-definition where
accessTypeId was referenced before being assigned.

Student form improvements:
- Add xamxam@erg.be mailto link at top of form
- On validation error, append "Si le problème persiste, envoyez un
  e-mail à xamxam@erg.be" to the flash message
- Preserve uploaded file names across validation redirects: store in
  session (share_primed_files_<slug>), display as warning on form
  re-render so the student knows which files to re-select

- License: only required for non-admin when access_type_id=1 (Libre),
  not for Interne (2) or Interdit (3). Fixes share link submissions
  failing with "Veuillez sélectionner une licence". Also fixed
  use-before-definition of accessTypeId.
2026-05-19 00:08:05 +02:00

378 lines
16 KiB
PHP

<?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
*
* 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/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
{
return $this->detectFileType($mimeType, $ext);
}
public function testGenerateAuthorSlug(string $name): string
{
return $this->generateAuthorSlug($name);
}
}
// ── Setup ─────────────────────────────────────────────────────────────────────
echo "Pure Logic Unit Test\n";
echo "====================\n\n";
$db = Database::getInstance();
$tfe = new TfeControllerTestable($db);
$createCtrl = new ThesisCreateControllerTestable($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'],
['audio/mpeg', 'mp3', 'audio'],
['audio/ogg', 'ogg', 'audio'],
['video/mp4', 'mp4', 'video'],
['video/webm', 'webm', 'video'],
['application/pdf', 'pdf', 'main'],
['image/jpeg', 'jpg', 'image'],
['image/png', 'png', 'image'],
['application/zip', 'zip', 'other'],
];
foreach ($cases as [$mime, $ext, $expected]) {
$actual = $createCtrl->testDetectFileType($mime, $ext);
plAssertEq($expected, $actual, "$mime / $ext $expected");
}
echo "\n";
echo "B4: detectFileType extension-based fallback\n";
// application/octet-stream with known extensions
$cases = [
['application/octet-stream', 'mp3', 'audio'],
['application/octet-stream', 'mp4', 'video'],
['application/octet-stream', 'pdf', 'main'],
['application/octet-stream', 'webp', 'image'],
['application/octet-stream', 'vtt', 'caption'],
];
foreach ($cases as [$mime, $ext, $expected]) {
$actual = $createCtrl->testDetectFileType($mime, $ext);
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: ExportController — CSV column count consistency
// =========================================================================
echo "C1: 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 D: SearchController — coverMap key always present (regression)
// =========================================================================
echo "D1: 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;