diff --git a/TODO.md b/TODO.md index 5e3b857..2ae6365 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/app/public/admin/add.php b/app/public/admin/add.php index 3981c81..bba0e84 100644 --- a/app/public/admin/add.php +++ b/app/public/admin/add.php @@ -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'; diff --git a/app/public/language-autre-fragment.php b/app/public/language-autre-fragment.php new file mode 100644 index 0000000..e85ad85 --- /dev/null +++ b/app/public/language-autre-fragment.php @@ -0,0 +1,46 @@ + +
+ +
+ +
+ + Si votre TFE contient une langue absente de la liste, précisez-la ici. +
+
+ +
+ +
+ + Si votre TFE contient une langue absente de la liste, précisez-la ici. +
+
+ +
diff --git a/app/public/partage/index.php b/app/public/partage/index.php index b448715..a6b0b8c 100644 --- a/app/public/partage/index.php +++ b/app/public/partage/index.php @@ -330,6 +330,7 @@ function renderShareLinkForm(string $slug, array $link): void + diff --git a/app/src/Controllers/SearchController.php b/app/src/Controllers/SearchController.php index daf2b5c..28d8b2e 100644 --- a/app/src/Controllers/SearchController.php +++ b/app/src/Controllers/SearchController.php @@ -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) { diff --git a/app/src/Controllers/TfeController.php b/app/src/Controllers/TfeController.php index 95256f9..1dd8743 100644 --- a/app/src/Controllers/TfeController.php +++ b/app/src/Controllers/TfeController.php @@ -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> $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 $data * @return array */ - 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> $jury * @return array{presidents: list, internes: list, externes: list, ulb: list, lecteurs_internes: list, lecteurs_externes: list} */ - 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> $files * @return list */ - private function collectCaptionPaths(array $files): array + protected function collectCaptionPaths(array $files): array { $captions = []; diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 52fb860..d969ba9 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -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 === '') { diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 83f503c..3044cdb 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -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'] ?? ''); diff --git a/app/src/Database.php b/app/src/Database.php index f44a99d..e6d64a9 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -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, diff --git a/app/templates/admin/edit.php b/app/templates/admin/edit.php index 733b3ca..4df2a58 100644 --- a/app/templates/admin/edit.php +++ b/app/templates/admin/edit.php @@ -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 diff --git a/app/templates/partials/form/checkbox-list.php b/app/templates/partials/form/checkbox-list.php index eeaabb4..554b69d 100644 --- a/app/templates/partials/form/checkbox-list.php +++ b/app/templates/partials/form/checkbox-list.php @@ -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'; ?>
*' : '' ?> @@ -31,7 +33,7 @@ $hxTarget = $hxTarget ?? ''; hx-target="" hx-trigger="change" hx-include="this, #website-url-fieldset" - hx-swap="outerHTML" + hx-swap="" >
    @@ -50,4 +52,4 @@ $hxTarget = $hxTarget ?? '';
-
> +

* Champs obligatoires

@@ -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); ?> +
+
+ +
+ > + Si votre TFE contient une langue absente de la liste, précisez-la ici. +
+
+
+ -
diff --git a/app/tests/Unit/FormSaveTest.php b/app/tests/Unit/FormSaveTest.php new file mode 100644 index 0000000..840ad45 --- /dev/null +++ b/app/tests/Unit/FormSaveTest.php @@ -0,0 +1,450 @@ +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; diff --git a/app/tests/Unit/PureLogicTest.php b/app/tests/Unit/PureLogicTest.php new file mode 100644 index 0000000..6c1d406 --- /dev/null +++ b/app/tests/Unit/PureLogicTest.php @@ -0,0 +1,422 @@ +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('

Hello world

'); + 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; diff --git a/app/tests/Unit/ShareLinkTest.php b/app/tests/Unit/ShareLinkTest.php new file mode 100644 index 0000000..44e4e1c --- /dev/null +++ b/app/tests/Unit/ShareLinkTest.php @@ -0,0 +1,234 @@ += 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; diff --git a/app/tests/run-tests.php b/app/tests/run-tests.php index b5d8b2f..bab5118 100755 --- a/app/tests/run-tests.php +++ b/app/tests/run-tests.php @@ -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'], ];